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() 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() } } }