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