Listing_SuggestPrice/backend/commands/ai_suggest.ts

151 lines
5.8 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 } = 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
for (const id of ids) {
try {
if (this.dryRun) {
// Dry-run: chạy y hệt luồng AI nhưng không ghi DB.
const product = await Product.findOrFail(id)
const [dataSources, dataEbay] = await Promise.all([
ErpService.getSupplierPricing(product),
EbayService.getMarketData(product),
])
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
rows.push([
String(id),
product.sku,
`$${Number(product.price)}`,
`$${suggestion.suggestedPrice}`,
'—',
suggestion.engine,
])
} else {
const result = await PricingService.suggestForProduct(id, this.username, this.force)
rows.push([
String(id),
'', // SKU điền sau (suggestForProduct không trả sku)
`$${result.oldPrice}`,
`$${result.suggestion.suggestedPrice}`,
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
result.suggestion.engine,
])
}
ok++
} catch (error) {
fail++
rows.push([String(id), '—', '—', '—', `LỖI: ${(error as Error).message}`, '—'])
}
}
// SKU cho nhánh non-dry-run (suggestForProduct không trả sku) — nạp 1 lần.
const skuMap = new Map<number, string>()
if (!this.dryRun) {
const prods = await Product.query().whereIn('id', ids).select('id', 'sku')
prods.forEach((p: any) => skuMap.set(p.id, p.sku))
for (const r of rows) {
const idNum = Number(r[0])
if (skuMap.has(idNum)) r[1] = skuMap.get(idNum)!
}
}
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()
}
}
}