import env from '#start/env' import logger from '@adonisjs/core/services/logger' import type Product from '#models/product' import type { SupplierPricePoint, EbayData } from '#models/history' import axios from 'axios' export interface SuggestionInput { product: Product dataSources: SupplierPricePoint[] dataEbay: EbayData } export interface Suggestion { suggestedPrice: number priceRange: { min: number; max: number } reasoning: string engine: 'rule' | 'ai' } const round = (n: number) => Math.round(n * 100) / 100 const median = (arr: number[]) => { if (!arr.length) return undefined const s = [...arr].sort((a, b) => a - b) const m = Math.floor(s.length / 2) return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 } /** * Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based * (port từ heuristic của prototype cũ). */ export default class AiService { static async suggest(input: SuggestionInput): Promise { try { return await this.openAi(input) } catch (error) { logger.error({ err: error }, 'OpenAI suggest lỗi — fallback rule-based') return this.ruleBased(input) } } // --- Rule-based --- private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) const supplierCost = (product.costs?.find((entry) => entry.currency === 'USD')?.price ?? Number(product.costs?.[0]?.price)) || dataSources.at(-1)?.price || (dataSources.length ? dataSources.reduce((a, b) => a + b.price, 0) / dataSources.length : 100) const soldMed = median(dataEbay.sold.map((x) => Number(x.price)).filter(Boolean)) const saleMed = median(dataEbay.sale.map((x) => Number(x.price)).filter(Boolean)) const anchor = soldMed ?? saleMed ?? supplierCost * 1.6 const floor = round(supplierCost * floorMarkup) const suggested = round(Math.max(anchor * 0.98, floor)) const reasoning = [ `Chi phí supplier ~$${round(supplierCost)} (condition ${product.condition}).`, soldMed != null ? `Giá đã bán eBay (median) ~$${round(soldMed)}.` : 'Chưa có dữ liệu sold.', saleMed != null ? `Giá đang bán (median) ~$${round(saleMed)}.` : 'Chưa có dữ liệu sale.', `Đề xuất $${suggested}: neo quanh thị trường, nhỉnh dưới median để dễ bán, giữ sàn $${floor}.`, ].join(' ') return { suggestedPrice: suggested, priceRange: { min: round(suggested * 0.92), max: round(suggested * 1.08) }, reasoning, engine: 'rule', } } // --- OpenAI --- /** * System prompt cho engine AI. Tách riêng để dễ tinh chỉnh & kiểm thử. * * Nguyên tắc định giá (giữ đồng bộ với rule-based để kết quả nhất quán): * - Đơn vị: USD. * - Neo giá quanh giá eBay đã bán (sold) > đang bán (sale) > chi phí supplier. * - Sàn giá = chi phí supplier * floorMarkup để luôn đảm bảo biên lợi nhuận. * - Khử nhiễu: loại các điểm giá lệch quá xa median trước khi tính. * - BẮT BUỘC trả về đúng JSON schema (không kèm markdown/giải thích ngoài JSON). */ static buildSystemPrompt(floorMarkup: number): string { return [ 'Bạn là chuyên gia định giá listing trên eBay (thị trường Úc/Mỹ).', 'Nhiệm vụ: dựa trên chi phí supplier và dữ liệu giá eBay (đã bán "sold" và đang bán "sale"),', 'đề xuất MỘT mức giá listing tối ưu bằng USD: cạnh tranh để dễ bán nhưng vẫn đảm bảo biên lợi nhuận.', '', 'Quy tắc định giá:', '1. Ưu tiên neo giá theo median giá ĐÃ BÁN (sold); nếu thiếu, dùng median giá ĐANG BÁN (sale);', ' nếu vẫn thiếu, suy ra từ chi phí supplier với biên hợp lý (~1.6x).', '2. Khử nhiễu: loại bỏ các điểm giá quá cao hoặc quá thấp bất thường so với median trước khi tính.', `3. Sàn giá tuyệt đối = chi phí supplier (USD) * ${floorMarkup}. Giá đề xuất KHÔNG được thấp hơn sàn này.`, '4. Nhỉnh dưới median thị trường một chút để tăng khả năng bán.', '5. priceRange là khoảng dao động hợp lý quanh giá đề xuất (min < suggestedPrice < max).', '', 'Chỉ trả về DUY NHẤT một object JSON hợp lệ (không markdown, không text ngoài JSON) theo schema:', '{', ' "suggestedPrice": number, // giá đề xuất (USD), > 0', ' "priceRange": { "min": number, "max": number },', ' "reasoning": string // giải thích ngắn gọn bằng tiếng Việt', '}', ].join('\n') } private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) const payload = { sku: product.sku, condition: product.condition, cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null, supplier: dataSources, ebaySold: dataEbay.sold, ebaySale: dataEbay.sale, } const gptPayload = { model: process.env.OPENAI_MODEL, response_format: { type: 'json_object' }, messages: [ { role: 'system', content: this.buildSystemPrompt(floorMarkup) }, { role: 'user', content: JSON.stringify(payload) }, ], } const externalApiUrl = process.env.ERP_API_URL; const remoteResp = await axios.post( externalApiUrl + '/api/transferPostData', { urlAPI: '/api/open-ai-sfp/model-image-info', data: gptPayload, }, { headers: { Authorization: 'Bearer ' + process.env.ERP_API_KEY, }, } ) if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') { throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data)) } return this.normalize(remoteResp.data?.data) } /** * Chuẩn hoá kết quả AI về đúng shape `Suggestion`. * AI có thể trả về string JSON, object lồng trong nhiều dạng key khác nhau, * hoặc thiếu priceRange — ta coerce an toàn để downstream luôn dùng được. */ private static normalize(raw: any): Suggestion { let data = raw if (typeof data === 'string') { try { data = JSON.parse(data) } catch { throw new Error('OpenAI trả về không phải JSON hợp lệ: ' + raw) } } // một số API bọc kết quả trong { data } hoặc { result } data = data?.suggestedPrice != null ? data : (data?.data ?? data?.result ?? data) const suggestedPrice = round(Number(data?.suggestedPrice)) if (!Number.isFinite(suggestedPrice) || suggestedPrice <= 0) { throw new Error('OpenAI không trả về suggestedPrice hợp lệ: ' + JSON.stringify(raw)) } const min = Number(data?.priceRange?.min) const max = Number(data?.priceRange?.max) return { suggestedPrice, priceRange: { min: Number.isFinite(min) ? round(min) : round(suggestedPrice * 0.92), max: Number.isFinite(max) ? round(max) : round(suggestedPrice * 1.08), }, reasoning: typeof data?.reasoning === 'string' ? data.reasoning : '', engine: 'ai', } } }