Listing_SuggestPrice/backend/app/services/pricing_service.ts

191 lines
6.3 KiB
TypeScript

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<SuggestResult> {
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<SuggestResult[]> {
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<ReturnType<typeof ErpService.getSupplierPricing>>
dataEbay: Awaited<ReturnType<typeof EbayService.getMarketData>>
suggestion: Suggestion
username: string
forceApply: boolean
}): Promise<SuggestResult> {
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<Product> {
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<number[]> {
const rows = await Product.query().where('no_listing', false).select('id')
return rows.map((r) => r.id)
}
}