Listing_SuggestPrice/backend/commands/ai_suggest.ts

153 lines
6.0 KiB
TypeScript

import { BaseCommand, args, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
/**
* Chạy AI gợi ý giá cho sản phẩm và cập nhật `ai_price` (luôn) + `price`.
*
* Khác với queue (`node ace queue:work`), command này chạy ĐỒNG BỘ ngay trong
* tiến trình và in kết quả ra màn hình — tiện để chạy thủ công / cron đơn giản.
*
* node ace ai:suggest 12 # 1 sản phẩm theo id
* node ace ai:suggest # toàn bộ sản phẩm
* node ace ai:suggest --sku ABC123 # lọc theo SKU
* node ace ai:suggest --warehouse AU --condition New --limit 50
* node ace ai:suggest --only-missing # chỉ sản phẩm chưa có ai_price
* node ace ai:suggest --force # luôn ghi đè `price` bằng giá AI
* node ace ai:suggest --dry-run # chỉ xem, không lưu DB
*
* Mặc định (không --force): áp dụng hybrid — chỉ tự ghi `price` khi chênh lệch
* so với giá hiện tại <= PRICING_AUTO_APPLY_THRESHOLD_PCT, ngược lại chỉ lưu
* `ai_price` để chờ duyệt. `ai_price` LUÔN được cập nhật.
*/
export default class AiSuggest extends BaseCommand {
static commandName = 'ai:suggest'
static description = 'Chạy AI gợi ý giá & cập nhật price/ai_price cho sản phẩm'
static options: CommandOptions = { startApp: true }
@args.string({ description: 'ID sản phẩm cần gợi ý (bỏ trống = theo bộ lọc/toàn bộ)', required: false })
declare productId?: string
@flags.string({ description: 'Lọc theo SKU' })
declare sku?: string
@flags.string({ description: 'Lọc theo warehouse' })
declare warehouse?: string
@flags.string({ description: 'Lọc theo condition' })
declare condition?: string
@flags.number({ description: 'Giới hạn số sản phẩm xử lý' })
declare limit?: number
@flags.boolean({ description: 'Chỉ xử lý sản phẩm chưa có ai_price' })
declare onlyMissing: boolean
@flags.boolean({ description: 'Luôn ghi đè `price` bằng giá AI (bỏ qua ngưỡng hybrid)' })
declare force: boolean
@flags.boolean({ description: 'Chạy thử: gọi AI nhưng KHÔNG lưu DB' })
declare dryRun: boolean
@flags.string({ description: 'Tên người thực hiện (ghi vào log/history)', default: 'cli' })
declare username: string
async run() {
const { default: Product } = await import('#models/product')
const { default: PricingService } = await import('#services/pricing_service')
const { default: AiService, mapWithConcurrency } = await import('#services/ai_service')
const { default: ErpService } = await import('#services/erp_service')
const { default: EbayService } = await import('#services/ebay_service')
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
try {
// 1. Thu thập danh sách id sản phẩm cần xử lý
let ids: number[]
if (this.productId) {
ids = [Number(this.productId)]
} else {
const query = Product.query().select('id')
if (this.sku) query.where('sku', this.sku)
if (this.warehouse) query.where('warehouse', this.warehouse)
if (this.condition) query.where('condition', this.condition)
if (this.onlyMissing) query.whereNull('ai_price')
if (this.limit) query.limit(this.limit)
ids = (await query).map((p: any) => p.id)
}
if (!ids.length) {
this.logger.warning('Không có sản phẩm nào khớp bộ lọc.')
return
}
this.logger.info(
`Xử lý ${ids.length} sản phẩm` +
(this.force ? ' (force áp giá)' : '') +
(this.dryRun ? ' (dry-run, không lưu)' : '')
)
const rows: string[][] = []
let ok = 0
let fail = 0
try {
if (this.dryRun) {
// Dry-run: chạy luồng AI theo LÔ (gộp request GPT) nhưng KHÔNG ghi DB.
const products = await Product.query().whereIn('id', ids)
const inputs = await mapWithConcurrency(products, 5, async (product: any) => {
const [dataSources, dataEbay] = await Promise.all([
ErpService.getSupplierPricing(product),
EbayService.getMarketData(product),
])
return { product, dataSources, dataEbay }
})
const suggestions = await AiService.suggestBatch(inputs)
inputs.forEach((input, idx) => {
const suggestion = suggestions[idx]
rows.push([
String(input.product.id),
input.product.sku,
`$${Number(input.product.price)}`,
`$${suggestion.suggestedPrice}`,
'—',
suggestion.engine,
])
ok++
})
fail = ids.length - inputs.length
} else {
// Non-dry-run: gợi ý + lưu DB theo LÔ.
const results = await PricingService.suggestForProducts(ids, this.username, this.force)
const prods = await Product.query().whereIn('id', ids).select('id', 'sku')
const skuMap = new Map<number, string>()
prods.forEach((p: any) => skuMap.set(p.id, p.sku))
for (const result of results) {
rows.push([
String(result.productId),
skuMap.get(result.productId) ?? '',
`$${result.oldPrice}`,
`$${result.suggestion.suggestedPrice}`,
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
result.suggestion.engine,
])
ok++
}
fail = ids.length - results.length
}
} catch (error) {
this.logger.error(`Lỗi khi chạy batch: ${(error as Error).message}`)
fail = ids.length - ok
}
const table = this.ui.table()
table.head(['ID', 'SKU', 'Giá cũ', 'Giá AI', 'Kết quả', 'Engine'])
rows.forEach((r) => table.row(r))
table.render()
this.logger.success(`Hoàn tất: ${ok} thành công, ${fail} lỗi.`)
} finally {
// Đóng Chromium dùng chung của scraper để tiến trình thoát sạch.
await EbayScraperService.close()
}
}
}