191 lines
6.3 KiB
TypeScript
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)
|
|
}
|
|
}
|