153 lines
6.0 KiB
TypeScript
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()
|
|
}
|
|
}
|
|
}
|