import env from '#start/env' import Product from '#models/product' import ErpService from '#services/erp_service' import EbayService from '#services/ebay_service' import AiService, { mapWithConcurrency } from '#services/ai_service' import type { Suggestion } from '#services/ai_service' import HistoryService from '#services/history_service' import LogService from '#services/log_service' export interface SuggestResult { productId: number suggestion: Suggestion applied: boolean oldPrice: number newPrice: number historyId: number } /** * Orchestrator của service gợi ý giá — lõi của hệ thống. * * Luồng: * 1. Lấy dữ liệu supplier (ERP) + eBay (sold/sale) * 2. Lưu snapshot vào history * 3. Gọi AI/rule engine -> suggestion * 4. Lưu ai_price vào product * 5. Hybrid áp giá: chênh lệch <= ngưỡng -> tự áp price; vượt -> chờ duyệt */ export default class PricingService { /** * Gợi ý giá cho 1 product (on-demand). * * @param forceApply true = luôn áp giá AI vào `price` (bỏ qua ngưỡng hybrid). * false (mặc định) = chỉ tự áp khi chênh lệch <= ngưỡng. */ static async suggestForProduct( productId: number, username: string, forceApply = false ): Promise { const product = await Product.findOrFail(productId) const [dataSources, dataEbay] = await Promise.all([ ErpService.getSupplierPricing(product), EbayService.getMarketData(product), ]) const suggestion = await AiService.suggest({ product, dataSources, dataEbay }) return this.persist({ product, dataSources, dataEbay, suggestion, username, forceApply }) } /** * Gợi ý giá cho NHIỀU product một lượt (batch). * * Tối ưu tốc độ khi có nhiều sản phẩm: * 1. Lấy dữ liệu supplier + eBay của các product song song (giới hạn concurrency * vì eBay/scrape nặng). * 2. Gọi AI theo LÔ — nhiều product chung 1 request GPT (xem `AiService.suggestBatch`). * 3. Lưu history/price cho từng product (song song có giới hạn). * * Trả về mảng kết quả cùng thứ tự với các product tải được (product không tồn * tại sẽ bị bỏ qua). */ static async suggestForProducts( productIds: number[], username: string, forceApply = false ): Promise { if (!productIds.length) return [] // Bỏ qua sản phẩm rác đã gắn cờ noListing. const products = await Product.query().whereIn('id', productIds).where('no_listing', false) if (!products.length) return [] // 1. Thu thập dữ liệu đầu vào (song song, giới hạn concurrency). const inputs = await mapWithConcurrency(products, 5, async (product) => { const [dataSources, dataEbay] = await Promise.all([ ErpService.getSupplierPricing(product), EbayService.getMarketData(product), ]) return { product, dataSources, dataEbay } }) // 2. Gọi AI theo lô (gộp nhiều product / 1 request GPT). const suggestions = await AiService.suggestBatch(inputs) // 3. Lưu kết quả cho từng product (song song, giới hạn concurrency). return mapWithConcurrency(inputs, 5, (input, idx) => this.persist({ product: input.product, dataSources: input.dataSources, dataEbay: input.dataEbay, suggestion: suggestions[idx], username, forceApply, }) ) } /** * Lưu snapshot history + áp giá hybrid + ghi log cho 1 product đã có suggestion. * Tách riêng để dùng chung giữa luồng single (`suggestForProduct`) và batch * (`suggestForProducts`). */ private static async persist({ product, dataSources, dataEbay, suggestion, username, forceApply, }: { product: Product dataSources: Awaited> dataEbay: Awaited> suggestion: Suggestion username: string forceApply: boolean }): Promise { const history = await HistoryService.record({ username, productId: product.id, dataSources, dataEbay, aiResult: suggestion, }) const oldPrice = Number(product.price) const thresholdPct = Number(env.get('PRICING_AUTO_APPLY_THRESHOLD_PCT', 5)) const diffPct = oldPrice > 0 ? (Math.abs(suggestion.suggestedPrice - oldPrice) / oldPrice) * 100 : 100 // luôn cập nhật ai_price (giá gợi ý gần nhất) product.aiPrice = suggestion.suggestedPrice // hybrid: chỉ tự áp khi trong ngưỡng; forceApply bỏ qua ngưỡng để luôn áp. const applied = forceApply || diffPct <= thresholdPct if (applied) { product.price = suggestion.suggestedPrice } await product.save() await LogService.record({ username, actionName: applied ? forceApply ? 'Áp giá AI (force)' : 'Tự áp giá AI' : 'Gợi ý giá (chờ duyệt)', action: 'suggest', productId: product.id, meta: { oldPrice, suggested: suggestion.suggestedPrice, diffPct: Math.round(diffPct * 100) / 100, applied }, }) return { productId: product.id, suggestion, applied, oldPrice, newPrice: Number(product.price), historyId: history.id, } } /** Duyệt & áp giá AI gợi ý (cho trường hợp vượt ngưỡng). */ static async approve(productId: number, username: string, price?: number): Promise { const product = await Product.findOrFail(productId) const oldPrice = Number(product.price) const newPrice = price ?? Number(product.aiPrice) if (!newPrice) throw new Error('Chưa có giá AI để duyệt') product.price = newPrice await product.save() await LogService.record({ username, actionName: 'Duyệt & áp giá AI', action: 'update', productId: product.id, meta: { oldPrice, newPrice }, }) return product } /** Lấy danh sách id product để chạy batch (mặc định toàn bộ, trừ sản phẩm noListing). */ static async productIdsForBatch(): Promise { const rows = await Product.query().where('no_listing', false).select('id') return rows.map((r) => r.id) } }