first commit
This commit is contained in:
commit
6e2ab902a1
|
|
@ -0,0 +1 @@
|
|||
.claude/
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
# SuggestPrice
|
||||
|
||||
Gợi ý giá listing cho một SKU dựa trên: lịch sử giá supplier (ERP) + giá eBay (đang bán / đã bán) → đưa GPT suggest giá.
|
||||
|
||||
## Trạng thái hiện tại
|
||||
Đang chạy bằng **MOCK DATA**. Các service `erp`, `ebay`, `gpt` đều trả dữ liệu giả lập, sẵn cấu trúc để cắm API thật sau (xem `USE_MOCK` trong `.env`).
|
||||
|
||||
## Cấu trúc
|
||||
```
|
||||
SuggestPrice/
|
||||
├─ server/ # Express API (Node, ESM)
|
||||
└─ web/ # React + Vite + Recharts
|
||||
```
|
||||
|
||||
## Chạy (2 terminal)
|
||||
|
||||
```bash
|
||||
# Terminal 1 - backend (cổng 3001)
|
||||
cd server
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# Terminal 2 - frontend (cổng 5173, proxy /api -> 3001)
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Mở http://localhost:5173 → nhập SKU bất kỳ + chọn condition → Suggest.
|
||||
|
||||
## API
|
||||
`POST /api/suggest-price` body: `{ "sku": "ABC123", "condition": "USED" }`
|
||||
|
||||
Trả về: `aiSuggestion`, `supplierSeries[]`, `ebayActiveSeries[]`, `ebaySoldSeries[]`.
|
||||
|
||||
## Khi có API thật
|
||||
1. ERP: điền `ERP_API_URL` / `ERP_API_KEY`, hoàn thiện `server/src/services/erpService.js`.
|
||||
2. eBay: điền `EBAY_CLIENT_ID` / `EBAY_CLIENT_SECRET`, hoàn thiện `server/src/services/ebayService.js`
|
||||
- Active listings: Browse API (dùng được ngay).
|
||||
- Sold listings: Marketplace Insights API (cần eBay duyệt - Limited Release).
|
||||
3. GPT: điền `OPENAI_API_KEY`, bỏ comment phần thật trong `server/src/services/gptService.js`.
|
||||
4. Đặt `USE_MOCK=false` trong `.env`.
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
TZ=UTC
|
||||
PORT=3333
|
||||
HOST=localhost
|
||||
LOG_LEVEL=info
|
||||
APP_KEY=
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (MySQL / MariaDB)
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_DATABASE=suggestprice
|
||||
|
||||
# Redis (queue / BullMQ)
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Sync ERP định kỳ (cron) — mặc định 2h sáng mỗi ngày, giờ Sydney
|
||||
SYNC_CRON=0 2 * * *
|
||||
SYNC_TZ=Australia/Sydney
|
||||
|
||||
# Pricing engine
|
||||
OPENAI_API_KEY=
|
||||
OPENAI_MODEL=gpt-4o-mini
|
||||
PRICING_AUTO_APPLY_THRESHOLD_PCT=5
|
||||
PRICING_FLOOR_MARKUP=1.25
|
||||
|
||||
# ERP (sync — bổ sung sau)
|
||||
ERP_API_URL=
|
||||
ERP_API_KEY=
|
||||
|
||||
# eBay
|
||||
EBAY_CLIENT_ID=
|
||||
EBAY_CLIENT_SECRET=
|
||||
# EBAY_BASE_URL=https://api.ebay.com
|
||||
# Marketplace suy từ product.warehouse: 'US' -> EBAY_US, còn lại -> EBAY_AU (không cấu hình qua env).
|
||||
|
||||
# Nguồn listing ĐÃ BÁN: 'scrape' (Puppeteer, mặc định) | 'api' (Insights API, cần Limited Release)
|
||||
EBAY_SOLD_SOURCE=scrape
|
||||
# EBAY_SCRAPE_TIMEOUT=60000 # timeout điều hướng (ms)
|
||||
# EBAY_SCRAPE_RETRIES=10 # số lần thử lại khi bị chặn
|
||||
# Tiền tệ đích cho giá sold (eBay đổi tiền theo geo-IP). Mặc định USD.
|
||||
EBAY_TARGET_CURRENCY=USD
|
||||
# Tỉ giá quy về USD (1 đơn vị -> USD) — override mặc định nếu cần chính xác hơn:
|
||||
# EBAY_FX_RATES={"VND":0.00004,"AUD":0.65,"GBP":1.27,"EUR":1.08,"CAD":0.73}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
build
|
||||
coverage
|
||||
.env
|
||||
.env.local
|
||||
tmp
|
||||
*.log
|
||||
.DS_Store
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
# SuggestPrice API (AdonisJS v6)
|
||||
|
||||
Backend thiết kế lại cho hệ thống SuggestPrice. Lõi: **service gợi ý giá** cập nhật giá cho cả sản phẩm **sync từ ERP** và sản phẩm **nhập tay / import Excel**.
|
||||
|
||||
## Stack
|
||||
- **AdonisJS v6** (TypeScript) + **Lucid ORM** → **MySQL 8 / MariaDB**
|
||||
- **BullMQ + Redis** cho job nền (chạy `web` và `worker` riêng)
|
||||
- **VineJS** validate, **access tokens** cho auth (bearer)
|
||||
- Engine giá: **rule-based** (mặc định) hoặc **OpenAI** (khi cấu hình)
|
||||
|
||||
## Cấu trúc
|
||||
```
|
||||
app/
|
||||
controllers/ auth, products, imports, pricing, logs, histories
|
||||
models/ user, product, log, history
|
||||
services/ product, import, pricing, ai, erp, ebay, sync, log, history, queue
|
||||
validators/ auth, product
|
||||
middleware/ auth, container_bindings
|
||||
commands/ queue:work (worker), make:user
|
||||
config/ database(mysql), auth, redis(queue), cors, ...
|
||||
database/migrations/ users, access_tokens, products, logs, histories
|
||||
start/ routes, kernel, env
|
||||
```
|
||||
|
||||
## Database (theo schema yêu cầu)
|
||||
| Bảng | Cột chính |
|
||||
|---|---|
|
||||
| `users` | username, password (hash), + `auth_access_tokens` (token) |
|
||||
| `products` | sku, condition, qty, price, ai_price, cost, package_contain, type, erp_id |
|
||||
| `logs` | username, action_name, action, product_id, meta, time |
|
||||
| `histories` | username, data_sources (JSON), data_ebay (JSON), ai_result, product_id, time |
|
||||
|
||||
> `erp_id != null` ⇒ sản phẩm sync; `erp_id == null` ⇒ manual/import.
|
||||
|
||||
## Cài đặt
|
||||
```bash
|
||||
cd backend-adonis
|
||||
npm install
|
||||
cp .env.example .env
|
||||
node ace generate:key # sinh APP_KEY
|
||||
# cập nhật DB_* và REDIS_* trong .env, tạo database 'suggestprice' (utf8mb4)
|
||||
node ace migration:run
|
||||
node ace make:user admin secret123
|
||||
```
|
||||
|
||||
## Chạy (2 process)
|
||||
```bash
|
||||
# Terminal 1 — HTTP API (cổng 3333)
|
||||
npm run dev
|
||||
|
||||
# Terminal 2 — worker xử lý queue (pricing batch / sync)
|
||||
npm run worker
|
||||
```
|
||||
|
||||
## API
|
||||
Tất cả (trừ register/login) cần header `Authorization: Bearer <token>`.
|
||||
|
||||
| Method | Endpoint | Mô tả |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/register` | tạo user |
|
||||
| POST | `/api/auth/login` | đăng nhập → token |
|
||||
| GET | `/api/auth/me` | thông tin user |
|
||||
| POST | `/api/auth/logout` | hủy token |
|
||||
| GET | `/api/products` | danh sách (page, search, condition, type, origin) |
|
||||
| POST | `/api/products` | tạo manual |
|
||||
| GET | `/api/products/:id` | chi tiết |
|
||||
| PATCH| `/api/products/:id` | sửa |
|
||||
| DELETE | `/api/products/:id` | xóa |
|
||||
| POST | `/api/imports/products` | import Excel (field `file`: sku, condition, qty, price) |
|
||||
| POST | `/api/pricing/suggest/:id` | gợi ý giá 1 SP (on-demand) |
|
||||
| POST | `/api/pricing/suggest/:id/approve` | duyệt & áp giá (khi vượt ngưỡng) |
|
||||
| POST | `/api/pricing/batch` | gợi ý hàng loạt (qua queue), body `{ productIds? }` |
|
||||
| GET | `/api/logs` | nhật ký thao tác |
|
||||
| GET | `/api/histories` | lịch sử lấy dữ liệu AI |
|
||||
| GET | `/api/histories/:id` | chi tiết history |
|
||||
|
||||
## Cơ chế gợi ý giá (hybrid)
|
||||
1. Lấy `data_sources` (ERP) + `data_ebay` (sold/sale) → lưu `histories`.
|
||||
2. Engine (rule/AI) ra `suggestedPrice` → ghi `ai_price`.
|
||||
3. Chênh lệch ≤ `PRICING_AUTO_APPLY_THRESHOLD_PCT` (mặc định 5%) ⇒ **tự áp** `price`; vượt ⇒ chờ **approve**.
|
||||
4. Mọi bước ghi `logs`.
|
||||
|
||||
Chế độ: **on-demand** (`/pricing/suggest/:id`) + **batch** (`/pricing/batch` → worker).
|
||||
|
||||
## Còn để mở (theo yêu cầu)
|
||||
- **API sync** ERP: service `sync_service.ts` đã scaffold; chỉ cần hoàn thiện `fetchProductsFromErp()` + thêm route khi có endpoint thật.
|
||||
- **eBay/OpenAI thật**: điền key trong `.env` (OpenAI: `OPENAI_API_KEY`; eBay: hoàn thiện `ebay_service.ts`).
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| JavaScript entrypoint for running ace commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Boots the AdonisJS application configured inside "bin/console.ts" file.
|
||||
|
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
|
||||
/**
|
||||
* Register the TypeScript loader so ace can import the app's command files
|
||||
* (commands/*.ts) when run against the TypeScript source in development.
|
||||
* In a compiled production build this dev dependency is absent, so the
|
||||
* failure is ignored and ace runs the already-compiled JavaScript.
|
||||
*/
|
||||
try {
|
||||
await import('ts-node-maintained/register/esm')
|
||||
} catch {}
|
||||
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
|
||||
/**
|
||||
* URL to the application root. AdonisJS need it to resolve paths to file and
|
||||
* directories for scaffolding commands
|
||||
*/
|
||||
const APP_ROOT = new URL('./', import.meta.url)
|
||||
|
||||
/**
|
||||
* The importer is used to import files in context of the application.
|
||||
*/
|
||||
const IMPORTER = (filePath) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.ace()
|
||||
.handle(process.argv.splice(2))
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { defineConfig } from '@adonisjs/core/app'
|
||||
|
||||
export default defineConfig({
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Commands
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
commands: [
|
||||
() => import('@adonisjs/core/commands'),
|
||||
() => import('@adonisjs/lucid/commands'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Service providers
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
providers: [
|
||||
() => import('@adonisjs/core/providers/app_provider'),
|
||||
() => import('@adonisjs/core/providers/hash_provider'),
|
||||
{
|
||||
file: () => import('@adonisjs/core/providers/repl_provider'),
|
||||
environment: ['repl', 'test'],
|
||||
},
|
||||
() => import('@adonisjs/core/providers/vinejs_provider'),
|
||||
() => import('@adonisjs/cors/cors_provider'),
|
||||
() => import('@adonisjs/lucid/database_provider'),
|
||||
() => import('@adonisjs/auth/auth_provider'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Preloads
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
preloads: [
|
||||
() => import('#start/routes'),
|
||||
() => import('#start/kernel'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Directories
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
directories: {
|
||||
commands: 'commands',
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import User from '#models/user'
|
||||
import { loginValidator, registerValidator } from '#validators/auth'
|
||||
|
||||
export default class AuthController {
|
||||
/** POST /api/auth/register */
|
||||
async register({ request, response }: HttpContext) {
|
||||
const data = await request.validateUsing(registerValidator)
|
||||
const user = await User.create(data)
|
||||
return response.created({ id: user.id, username: user.username, firstName: user.firstName, lastName: user.lastName })
|
||||
}
|
||||
|
||||
/** POST /api/auth/login -> trả về bearer token */
|
||||
async login({ request, response }: HttpContext) {
|
||||
const { username, password } = await request.validateUsing(loginValidator)
|
||||
|
||||
try {
|
||||
const remoteUrl = process.env.ERP_API_URL || 'https://stage.nswteam.net'
|
||||
const remoteResp = await fetch(`${remoteUrl}/api/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userEmail: username,
|
||||
password,
|
||||
}),
|
||||
})
|
||||
|
||||
const remoteData = (await remoteResp.json().catch(() => null)) as
|
||||
| { success?: boolean; data?: { firstName?: string | null; lastName?: string | null } }
|
||||
| null
|
||||
|
||||
if (!remoteResp.ok || !remoteData?.success) {
|
||||
return response.badRequest({
|
||||
status: false,
|
||||
message: 'Login ERP Fail, Email or password is incorrect',
|
||||
error: 'EMAIL_OR_PASSWORD_INCORRECT',
|
||||
})
|
||||
}
|
||||
|
||||
const remoteUser = remoteData.data
|
||||
const existingUser = await User.findBy('username', username)
|
||||
|
||||
const userPayload = {
|
||||
username,
|
||||
password,
|
||||
firstName: remoteUser?.firstName ?? existingUser?.firstName ?? null,
|
||||
lastName: remoteUser?.lastName ?? existingUser?.lastName ?? null,
|
||||
}
|
||||
|
||||
let user = existingUser
|
||||
|
||||
if (user) {
|
||||
const needsUpdate =
|
||||
user.firstName !== userPayload.firstName || user.lastName !== userPayload.lastName
|
||||
|
||||
if (needsUpdate) {
|
||||
user.firstName = userPayload.firstName
|
||||
user.lastName = userPayload.lastName
|
||||
await user.save()
|
||||
}
|
||||
} else {
|
||||
user = await User.create(userPayload)
|
||||
}
|
||||
|
||||
const token = await User.accessTokens.create(user)
|
||||
|
||||
return {
|
||||
user: { id: user.id, username: user.username, firstName: user.firstName, lastName: user.lastName },
|
||||
token: token.value!.release(),
|
||||
type: 'bearer',
|
||||
expiresAt: token.expiresAt,
|
||||
}
|
||||
} catch (error) {
|
||||
return response.badRequest({
|
||||
status: false,
|
||||
message: 'Login ERP Fail',
|
||||
error: error instanceof Error ? error.message : 'UNKNOWN_ERROR',
|
||||
details: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/auth/logout */
|
||||
async logout({ auth }: HttpContext) {
|
||||
const user = auth.getUserOrFail()
|
||||
const token = auth.user?.currentAccessToken
|
||||
if (token) await User.accessTokens.delete(user, token.identifier)
|
||||
return { revoked: true }
|
||||
}
|
||||
|
||||
/** GET /api/auth/me */
|
||||
async me({ auth }: HttpContext) {
|
||||
const user = auth.getUserOrFail()
|
||||
return { id: user.id, username: user.username, firstName: user.firstName, lastName: user.lastName }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import History from '#models/history'
|
||||
|
||||
export default class HistoriesController {
|
||||
/** GET /api/histories?productId=&page=&perPage= */
|
||||
async index({ request }: HttpContext) {
|
||||
const page = Number(request.input('page', 1))
|
||||
const perPage = Number(request.input('perPage', 25))
|
||||
const productId = request.input('productId')
|
||||
|
||||
const query = History.query().orderBy('time', 'desc')
|
||||
if (productId) query.where('product_id', productId)
|
||||
|
||||
return query.paginate(page, perPage)
|
||||
}
|
||||
|
||||
/** GET /api/histories/:id */
|
||||
async show({ params }: HttpContext) {
|
||||
return History.findOrFail(params.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import ImportService from '#services/import_service'
|
||||
|
||||
export default class ImportsController {
|
||||
/**
|
||||
* POST /api/imports/products (multipart, field "file")
|
||||
* Excel gồm cột: sku, condition, qty, price
|
||||
*/
|
||||
async products({ request, response, auth }: HttpContext) {
|
||||
const file = request.file('file', {
|
||||
size: '20mb',
|
||||
extnames: ['xlsx', 'xls', 'csv'],
|
||||
})
|
||||
|
||||
if (!file) {
|
||||
return response.badRequest({ error: 'Thiếu file (field "file")' })
|
||||
}
|
||||
if (!file.isValid) {
|
||||
return response.badRequest({ error: file.errors })
|
||||
}
|
||||
|
||||
// file.tmpPath có sẵn khi autoProcess=true
|
||||
const summary = await ImportService.importFromFile(file.tmpPath!, auth.getUserOrFail().username)
|
||||
return summary
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import Log from '#models/log'
|
||||
|
||||
export default class LogsController {
|
||||
/** GET /api/logs?productId=&action=&page=&perPage= */
|
||||
async index({ request }: HttpContext) {
|
||||
const page = Number(request.input('page', 1))
|
||||
const perPage = Number(request.input('perPage', 25))
|
||||
const productId = request.input('productId')
|
||||
const action = request.input('action')
|
||||
const username = request.input('username')
|
||||
|
||||
const query = Log.query().orderBy('time', 'desc')
|
||||
if (productId) query.where('product_id', productId)
|
||||
if (action) query.where('action', action)
|
||||
if (username) query.where('username', username)
|
||||
|
||||
return query.paginate(page, perPage)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import vine from '@vinejs/vine'
|
||||
import PricingService from '#services/pricing_service'
|
||||
import { enqueuePricingBatch } from '#services/queue_service'
|
||||
|
||||
const approveValidator = vine.compile(
|
||||
vine.object({ price: vine.number().min(0).optional() })
|
||||
)
|
||||
|
||||
export default class PricingController {
|
||||
/** POST /api/pricing/suggest/:id (on-demand, đồng bộ) */
|
||||
async suggest({ params, auth }: HttpContext) {
|
||||
return PricingService.suggestForProduct(Number(params.id), auth.getUserOrFail().username)
|
||||
}
|
||||
|
||||
/** POST /api/pricing/suggest/:id/approve (duyệt & áp giá khi vượt ngưỡng) */
|
||||
async approve({ params, request, auth }: HttpContext) {
|
||||
const { price } = await request.validateUsing(approveValidator)
|
||||
return PricingService.approve(Number(params.id), auth.getUserOrFail().username, price)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/pricing/batch (chạy hàng loạt qua queue)
|
||||
* body: { productIds?: number[] } — bỏ trống = toàn bộ
|
||||
*/
|
||||
async batch({ request, auth }: HttpContext) {
|
||||
const productIds: number[] =
|
||||
request.input('productIds') ?? (await PricingService.productIdsForBatch())
|
||||
await enqueuePricingBatch(productIds, auth.getUserOrFail().username)
|
||||
return { enqueued: productIds.length }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import Product from '#models/product'
|
||||
import ProductService from '#services/product_service'
|
||||
import {
|
||||
createProductValidator,
|
||||
updateProductValidator,
|
||||
listProductValidator,
|
||||
} from '#validators/product'
|
||||
|
||||
export default class ProductsController {
|
||||
/** GET /api/products */
|
||||
async index({ request }: HttpContext) {
|
||||
const params = await request.validateUsing(listProductValidator)
|
||||
const page = params.page ?? 1
|
||||
const perPage = params.perPage ?? 25
|
||||
const order = params.order ?? 'created_at'
|
||||
const direction: 'asc' | 'desc' = params.direction === 'asc' ? 'asc' : 'desc'
|
||||
|
||||
const query = Product.query()
|
||||
|
||||
if (params.condition) query.where('condition', params.condition)
|
||||
if (params.type) query.where('type', params.type)
|
||||
if (params.warehouse) query.where('warehouse', params.warehouse)
|
||||
if (params.sku) {
|
||||
// FULLTEXT khi có, fallback LIKE
|
||||
query.where((b) => {
|
||||
b.where('sku', 'like', `%${params.sku}%`)
|
||||
})
|
||||
}
|
||||
|
||||
return query.orderBy(order, direction).paginate(page, perPage)
|
||||
}
|
||||
|
||||
/** GET /api/products/:id */
|
||||
async show({ params }: HttpContext) {
|
||||
return Product.findOrFail(params.id)
|
||||
}
|
||||
|
||||
/** POST /api/products (manual) */
|
||||
async store({ request, response, auth }: HttpContext) {
|
||||
const data = await request.validateUsing(createProductValidator)
|
||||
const product = await ProductService.create(data, auth.getUserOrFail().username)
|
||||
return response.created(product)
|
||||
}
|
||||
|
||||
/** PATCH /api/products/:id */
|
||||
async update({ params, request, auth }: HttpContext) {
|
||||
const data = await request.validateUsing(updateProductValidator)
|
||||
return ProductService.update(params.id, data, auth.getUserOrFail().username)
|
||||
}
|
||||
|
||||
/** DELETE /api/products/:id */
|
||||
async destroy({ params, response, auth }: HttpContext) {
|
||||
await ProductService.destroy(params.id, auth.getUserOrFail().username)
|
||||
return response.noContent()
|
||||
}
|
||||
|
||||
async syncProductBySku({ request }: HttpContext) {
|
||||
const params = await request.validateUsing(listProductValidator)
|
||||
const query = Product.query()
|
||||
|
||||
if (params.condition) query.where('condition', params.condition)
|
||||
if (params.warehouse) query.where('warehouse', params.warehouse)
|
||||
if (params.type) query.where('type', params.type)
|
||||
if (params.sku) {
|
||||
// FULLTEXT khi có, fallback LIKE
|
||||
query.where((b) => {
|
||||
b.where('sku', 'like', `%${params.sku}%`)
|
||||
})
|
||||
}
|
||||
|
||||
return query.first()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import app from '@adonisjs/core/services/app'
|
||||
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
|
||||
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'
|
||||
|
||||
export default class HttpExceptionHandler extends ExceptionHandler {
|
||||
/**
|
||||
* In debug mode, the exception handler returns detailed errors.
|
||||
*/
|
||||
protected debug = !app.inProduction
|
||||
|
||||
/**
|
||||
* Render validation/known errors as JSON for an API-only backend.
|
||||
*/
|
||||
protected renderStatusPages = false
|
||||
|
||||
protected statusPages: Record<StatusPageRange, StatusPageRenderer> = {}
|
||||
|
||||
async handle(error: unknown, ctx: HttpContext) {
|
||||
return super.handle(error, ctx)
|
||||
}
|
||||
|
||||
async report(error: unknown, ctx: HttpContext) {
|
||||
return super.report(error, ctx)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
import type { Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
/**
|
||||
* Auth middleware is used to authenticate HTTP requests and deny
|
||||
* access to unauthenticated users.
|
||||
*/
|
||||
export default class AuthMiddleware {
|
||||
redirectTo = '/login'
|
||||
|
||||
async handle(
|
||||
ctx: HttpContext,
|
||||
next: NextFn,
|
||||
options: { guards?: (keyof Authenticators)[] } = {}
|
||||
) {
|
||||
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Logger } from '@adonisjs/core/logger'
|
||||
import { HttpContext } from '@adonisjs/core/http'
|
||||
import type { NextFn } from '@adonisjs/core/types/http'
|
||||
|
||||
/**
|
||||
* The container bindings middleware binds classes to their request
|
||||
* specific value using the container resolver.
|
||||
*/
|
||||
export default class ContainerBindingsMiddleware {
|
||||
handle(ctx: HttpContext, next: NextFn) {
|
||||
ctx.containerResolver.bindValue(HttpContext, ctx)
|
||||
ctx.containerResolver.bindValue(Logger, ctx.logger)
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Product from '#models/product'
|
||||
|
||||
export interface SupplierPricePoint {
|
||||
price: number
|
||||
date?: string
|
||||
source?: string
|
||||
[k: string]: any
|
||||
}
|
||||
|
||||
export interface EbayData {
|
||||
sold: Array<Record<string, any>>
|
||||
sale: Array<Record<string, any>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot dữ liệu mỗi lần lấy về để đưa AI gợi ý giá.
|
||||
* Lưu nguyên đầu vào (supplier + eBay) phục vụ audit & re-run.
|
||||
*/
|
||||
export default class History extends BaseModel {
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare username: string
|
||||
|
||||
@column()
|
||||
declare productId: number
|
||||
|
||||
/** [{ price, date, source, ... }, ...] — lịch sử giá nguồn (ERP, supplier). */
|
||||
@column({
|
||||
columnName: 'data_sources',
|
||||
prepare: (v) => JSON.stringify(v ?? []),
|
||||
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
|
||||
})
|
||||
declare dataSources: SupplierPricePoint[]
|
||||
|
||||
/** { sold: [...], sale: [...] } — dữ liệu eBay (đã bán / đang bán). */
|
||||
@column({
|
||||
columnName: 'data_ebay',
|
||||
prepare: (v) => JSON.stringify(v ?? { sold: [], sale: [] }),
|
||||
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
|
||||
})
|
||||
declare dataEbay: EbayData
|
||||
|
||||
/** Kết quả AI tại thời điểm đó (tùy chọn). */
|
||||
@column({
|
||||
columnName: 'ai_result',
|
||||
prepare: (v) => (v === null || v === undefined ? null : JSON.stringify(v)),
|
||||
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
|
||||
})
|
||||
declare aiResult: Record<string, any> | null
|
||||
|
||||
@column.dateTime({ autoCreate: true, columnName: 'time' })
|
||||
declare time: DateTime
|
||||
|
||||
@belongsTo(() => Product)
|
||||
declare product: BelongsTo<typeof Product>
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, belongsTo } from '@adonisjs/lucid/orm'
|
||||
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
|
||||
import Product from '#models/product'
|
||||
|
||||
/**
|
||||
* Nhật ký thao tác CUD của người dùng lên sản phẩm.
|
||||
*/
|
||||
export default class Log extends BaseModel {
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare username: string
|
||||
|
||||
/** Tên hành động dễ đọc, vd: "Tạo sản phẩm", "Cập nhật giá". */
|
||||
@column()
|
||||
declare actionName: string
|
||||
|
||||
/** Mã hành động: create | update | delete | import | sync | suggest. */
|
||||
@column()
|
||||
declare action: string
|
||||
|
||||
@column()
|
||||
declare productId: number | null
|
||||
|
||||
/** Dữ liệu trước/sau (diff) — tùy chọn, hữu ích để truy vết. */
|
||||
@column({
|
||||
prepare: (v) => (v === null || v === undefined ? null : JSON.stringify(v)),
|
||||
consume: (v) => (typeof v === 'string' ? JSON.parse(v) : v),
|
||||
})
|
||||
declare meta: Record<string, any> | null
|
||||
|
||||
@column.dateTime({ autoCreate: true, columnName: 'time' })
|
||||
declare time: DateTime
|
||||
|
||||
@belongsTo(() => Product)
|
||||
declare product: BelongsTo<typeof Product>
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm'
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations'
|
||||
import Log from '#models/log'
|
||||
import History from '#models/history'
|
||||
|
||||
/**
|
||||
* Một product có thể đến từ ERP (sync) hoặc nhập thủ công.
|
||||
* - erpId != null -> sản phẩm sync từ ERP
|
||||
* - erpId == null -> sản phẩm manual / import excel
|
||||
*/
|
||||
export default class Product extends BaseModel {
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare sku: string
|
||||
|
||||
@column()
|
||||
declare condition: string
|
||||
|
||||
@column()
|
||||
declare qty: number
|
||||
|
||||
/** Giá bán hiện tại (đang áp dụng). */
|
||||
@column()
|
||||
declare price: number
|
||||
|
||||
/** Giá do AI gợi ý gần nhất (có thể chưa được áp dụng). */
|
||||
@column()
|
||||
declare aiPrice: number | null
|
||||
|
||||
/** Giá vốn / chi phí theo nhiều currency. */
|
||||
@column({
|
||||
prepare: (value) => (value == null ? null : JSON.stringify(value)),
|
||||
consume: (value) => (typeof value === 'string' ? JSON.parse(value) : value),
|
||||
})
|
||||
declare costs: Array<{ currency: string; price: number }> | null
|
||||
|
||||
/** Số lượng đơn vị trong 1 package. */
|
||||
@column()
|
||||
declare packageContain: string | null
|
||||
|
||||
/** Loại / nhóm sản phẩm. */
|
||||
@column()
|
||||
declare type: string | null
|
||||
|
||||
/** ID tham chiếu bên ERP (null = manual). */
|
||||
@column()
|
||||
declare erpId: string | null
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime
|
||||
|
||||
@hasMany(() => Log)
|
||||
declare logs: HasMany<typeof Log>
|
||||
|
||||
@hasMany(() => History)
|
||||
declare histories: HasMany<typeof History>
|
||||
|
||||
/** Kho hàng. */
|
||||
@column()
|
||||
declare warehouse: string | null
|
||||
|
||||
/** true nếu sản phẩm được sync từ ERP. */
|
||||
get isSynced() {
|
||||
return this.erpId !== null && this.erpId !== undefined
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { DateTime } from 'luxon'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
import { compose } from '@adonisjs/core/helpers'
|
||||
import { BaseModel, column } from '@adonisjs/lucid/orm'
|
||||
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
|
||||
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
|
||||
|
||||
const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
|
||||
uids: ['username'],
|
||||
passwordColumnName: 'password',
|
||||
})
|
||||
|
||||
export default class User extends compose(BaseModel, AuthFinder) {
|
||||
@column({ isPrimary: true })
|
||||
declare id: number
|
||||
|
||||
@column()
|
||||
declare username: string
|
||||
|
||||
@column({ serializeAs: null })
|
||||
declare password: string
|
||||
|
||||
@column.dateTime({ autoCreate: true })
|
||||
declare createdAt: DateTime
|
||||
|
||||
@column.dateTime({ autoCreate: true, autoUpdate: true })
|
||||
declare updatedAt: DateTime | null
|
||||
|
||||
@column()
|
||||
declare firstName: string | null
|
||||
|
||||
@column()
|
||||
declare lastName: string | null
|
||||
|
||||
/**
|
||||
* Bearer tokens used to authenticate API requests.
|
||||
* (Bảng auth_access_tokens — chính là "token" trong schema của bạn.)
|
||||
*/
|
||||
static accessTokens = DbAccessTokensProvider.forModel(User)
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
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<Suggestion> {
|
||||
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<Suggestion> {
|
||||
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',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,300 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { Browser, Page } from 'puppeteer'
|
||||
|
||||
/** 1 listing đã bán scrape được từ trang kết quả eBay. */
|
||||
export interface ScrapedSoldItem {
|
||||
id: string
|
||||
link_detail: string
|
||||
title: string
|
||||
description: string
|
||||
condition_item: string
|
||||
price: number
|
||||
currencyID: string
|
||||
priceText: string
|
||||
date: string
|
||||
source: 'ebay-scrape'
|
||||
[k: string]: any
|
||||
}
|
||||
|
||||
/** Map marketplace id -> domain eBay tương ứng để build URL search. */
|
||||
const MARKETPLACE_DOMAIN: Record<string, string> = {
|
||||
EBAY_US: 'www.ebay.com',
|
||||
EBAY_AU: 'www.ebay.com.au',
|
||||
EBAY_GB: 'www.ebay.co.uk',
|
||||
EBAY_DE: 'www.ebay.de',
|
||||
EBAY_CA: 'www.ebay.ca',
|
||||
}
|
||||
|
||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* Scrape listing ĐÃ BÁN (sold/completed) trên eBay bằng Puppeteer.
|
||||
*
|
||||
* Dùng khi không có quyền gọi Marketplace Insights API (Limited Release).
|
||||
* Browser được tái sử dụng (lazy singleton) để tiết kiệm tài nguyên khi
|
||||
* chạy batch; nhớ gọi `EbayScraperService.close()` khi tiến trình kết thúc
|
||||
* (command / worker) để giải phóng Chromium.
|
||||
*/
|
||||
export default class EbayScraperService {
|
||||
private static _browser: Browser | null = null
|
||||
private static _launching: Promise<Browser> | null = null
|
||||
|
||||
/** Lấy (hoặc khởi tạo) browser dùng chung. Tránh launch trùng khi gọi song song. */
|
||||
private static async getBrowser(): Promise<Browser> {
|
||||
if (this._browser?.connected) return this._browser
|
||||
if (this._launching) return this._launching
|
||||
|
||||
this._launching = (async () => {
|
||||
const { default: puppeteer } = await import('puppeteer')
|
||||
const browser = await puppeteer.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--no-zygote',
|
||||
],
|
||||
})
|
||||
this._browser = browser
|
||||
this._launching = null
|
||||
return browser
|
||||
})()
|
||||
|
||||
return this._launching
|
||||
}
|
||||
|
||||
/** Đóng browser dùng chung (gọi khi command/worker tắt). */
|
||||
static async close(): Promise<void> {
|
||||
if (this._browser) {
|
||||
await this._browser.close().catch(() => {})
|
||||
this._browser = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Build URL trang kết quả "đã bán" của eBay cho 1 sku + condition. */
|
||||
private static buildSoldUrl(sku: string, conditionId: string | undefined, marketplace: string): string {
|
||||
const domain = MARKETPLACE_DOMAIN[marketplace] || 'www.ebay.com'
|
||||
const params = new URLSearchParams({
|
||||
_nkw: sku,
|
||||
LH_Sold: '1',
|
||||
LH_Complete: '1',
|
||||
_ipg: '60', // items per page
|
||||
})
|
||||
if (conditionId) params.set('LH_ItemCondition', conditionId)
|
||||
return `https://${domain}/sch/i.html?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape danh sách listing đã bán cho 1 SKU.
|
||||
* @param conditionId ID condition của eBay (1000/3000...) để lọc trên URL.
|
||||
* @param marketplace Marketplace eBay (EBAY_US/EBAY_AU...) -> chọn domain.
|
||||
*/
|
||||
static async scrapeSold(
|
||||
rawSku: string,
|
||||
conditionId?: string,
|
||||
marketplace: string = 'EBAY_AU'
|
||||
): Promise<ScrapedSoldItem[]> {
|
||||
const sku = rawSku?.trim()
|
||||
if (!sku) return []
|
||||
|
||||
const url = this.buildSoldUrl(sku, conditionId, marketplace)
|
||||
const timeout = Number(process.env.EBAY_SCRAPE_TIMEOUT || 60000)
|
||||
const maxRetries = Number(process.env.EBAY_SCRAPE_RETRIES || 10)
|
||||
|
||||
const browser = await this.getBrowser()
|
||||
let page: Page | null = null
|
||||
try {
|
||||
page = await browser.newPage()
|
||||
await page.setUserAgent(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||
'(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
)
|
||||
await page.setExtraHTTPHeaders({ 'Accept-Language': 'en-US,en;q=0.9' })
|
||||
// Ẩn dấu hiệu automation để giảm khả năng bị eBay chặn.
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined })
|
||||
})
|
||||
|
||||
// Tối ưu băng thông: chặn ảnh, font, media (KHÔNG chặn stylesheet/script
|
||||
// để trang search render đúng, tránh bị eBay trả "Error Page").
|
||||
await page.setRequestInterception(true)
|
||||
page.on('request', (req) => {
|
||||
if (['image', 'font', 'media'].includes(req.resourceType())) req.abort()
|
||||
else req.continue()
|
||||
})
|
||||
|
||||
// QUAN TRỌNG: eBay chặn truy cập trực tiếp vào /sch/i.html (trả Error Page).
|
||||
// Phải vào homepage trước để lấy cookie/session, rồi mới điều hướng tới search.
|
||||
const origin = new URL(url).origin
|
||||
await page.goto(origin + '/', { waitUntil: 'domcontentloaded', timeout }).catch(() => {})
|
||||
await wait(1500)
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout })
|
||||
|
||||
// Chờ qua màn anti-bot / Error Page / chờ card xuất hiện.
|
||||
let retries = 0
|
||||
while (retries < maxRetries) {
|
||||
const html = await this.safeGetContent(page)
|
||||
const blocked =
|
||||
html.includes('Checking your browser') ||
|
||||
html.includes('Pardon Our Interruption') ||
|
||||
html.includes('Something went wrong on our end')
|
||||
if (blocked) {
|
||||
// Bị chặn -> quay lại homepage rồi vào lại search.
|
||||
await page.goto(new URL(url).origin + '/', { waitUntil: 'domcontentloaded', timeout }).catch(() => {})
|
||||
await wait(1500)
|
||||
await page.goto(url, { waitUntil: 'networkidle2', timeout }).catch(() => {})
|
||||
retries++
|
||||
continue
|
||||
}
|
||||
if (await page.$('li.s-card--horizontal, li.s-item, li.s-card')) break
|
||||
await wait(2000)
|
||||
retries++
|
||||
}
|
||||
|
||||
// Chỉ lấy text thô từ DOM; toàn bộ parse/quy đổi tiền tệ làm ở Node để dễ kiểm thử.
|
||||
const raw = await page.$$eval(
|
||||
'li.s-card--horizontal, li.s-item, li.s-card',
|
||||
(nodes) =>
|
||||
nodes.map((node) => {
|
||||
const linkEl: any =
|
||||
node.querySelector('div.su-image a') ||
|
||||
node.querySelector('a.s-card__link, a.s-item__link, a.su-link')
|
||||
return {
|
||||
link_detail: linkEl && linkEl.href ? linkEl.href : '',
|
||||
listingId: node.getAttribute('data-listingid') || '',
|
||||
title: (node.querySelector('.s-card__title, .s-item__title')?.textContent || '')
|
||||
.replace(/New\s*listing/i, '')
|
||||
.trim(),
|
||||
condition_item: (
|
||||
node.querySelector('.s-card__subtitle, .SECONDARY_INFO, .s-item__subtitle')?.textContent || ''
|
||||
).trim(),
|
||||
caption: (
|
||||
node.querySelector('.s-card__caption, .s-item__caption, .POSITIVE')?.textContent || ''
|
||||
).trim(),
|
||||
priceText: (node.querySelector('.s-card__price, .s-item__price')?.textContent || '').trim(),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const targetCurrency = (process.env.EBAY_TARGET_CURRENCY || 'USD').toUpperCase()
|
||||
const fx = this.fxRates()
|
||||
let skippedFx = 0
|
||||
|
||||
const normalized = raw
|
||||
.map((it) => {
|
||||
// Bỏ qua dòng quảng cáo "Shop on eBay" / không phải listing đã bán.
|
||||
if (!/sold/i.test(it.caption)) return null
|
||||
|
||||
const idMatch = it.link_detail.match(/\/itm\/(\d+)/)
|
||||
const id = idMatch ? idMatch[1] : it.listingId
|
||||
if (!id) return null
|
||||
|
||||
const { amount, currency } = this.parsePrice(it.priceText)
|
||||
if (!Number.isFinite(amount) || amount <= 0) return null
|
||||
|
||||
// Quy đổi về target currency (eBay đổi tiền theo geo-IP nên có thể trả VND/AUD...).
|
||||
const cur = currency || targetCurrency
|
||||
const converted = this.convert(amount, cur, targetCurrency, fx)
|
||||
if (converted == null) {
|
||||
skippedFx++
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
link_detail: it.link_detail,
|
||||
title: it.title || sku,
|
||||
description: it.title || '',
|
||||
condition_item: it.condition_item,
|
||||
price: Math.round(converted * 100) / 100,
|
||||
currencyID: targetCurrency,
|
||||
priceText: it.priceText,
|
||||
date: this.parseDate(it.caption),
|
||||
source: 'ebay-scrape' as const,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ScrapedSoldItem[]
|
||||
|
||||
logger.info(
|
||||
{ sku, count: normalized.length, skippedFx, targetCurrency, url },
|
||||
'eBay scrape sold xong'
|
||||
)
|
||||
return normalized
|
||||
} catch (err) {
|
||||
logger.error({ err, url }, `eBay scrape lỗi cho SKU ${sku}`)
|
||||
return []
|
||||
} finally {
|
||||
if (page) await page.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Lấy content an toàn (page có thể đang điều hướng). */
|
||||
private static async safeGetContent(page: Page): Promise<string> {
|
||||
try {
|
||||
return await page.content()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse text ngày eBay (vd "Sold Mar 12, 2024") -> "YYYY-MM-DD"; lỗi thì rỗng. */
|
||||
private static parseDate(text?: string): string {
|
||||
if (!text) return ''
|
||||
const m = text.match(/([A-Za-z]{3,}\.?\s+\d{1,2},?\s+\d{4})/)
|
||||
const cleaned = (m ? m[1] : text.replace(/sold\s*/i, '')).trim()
|
||||
const ts = Date.parse(cleaned)
|
||||
if (Number.isNaN(ts)) return ''
|
||||
return new Date(ts).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** Tách số tiền + mã tiền tệ từ text giá eBay (vd "AU $1,234.50", "2.702.130 VND"). */
|
||||
private static parsePrice(text: string): { amount: number; currency: string } {
|
||||
const t = (text || '').toUpperCase()
|
||||
let currency = ''
|
||||
if (t.includes('VND') || text.includes('₫')) currency = 'VND'
|
||||
else if (t.includes('GBP') || text.includes('£')) currency = 'GBP'
|
||||
else if (t.includes('EUR') || text.includes('€')) currency = 'EUR'
|
||||
else if (t.includes('AUD') || t.includes('AU $') || t.includes('AU$')) currency = 'AUD'
|
||||
else if (t.includes('CAD') || t.includes('C $') || t.includes('C$')) currency = 'CAD'
|
||||
else if (t.includes('USD') || t.includes('US $') || t.includes('US$') || text.includes('$')) currency = 'USD'
|
||||
|
||||
// Lấy số đầu tiên (eBay có thể hiện khoảng giá "x to y" -> lấy x). Comma = phân cách nghìn.
|
||||
const numMatch = text.replace(/,/g, '').match(/\d+(\.\d+)?/)
|
||||
const amount = numMatch ? Number(numMatch[0]) : NaN
|
||||
return { amount, currency }
|
||||
}
|
||||
|
||||
/** Tỉ giá quy về USD (1 đơn vị tiền -> USD). Override qua env EBAY_FX_RATES (JSON). */
|
||||
private static fxRates(): Record<string, number> {
|
||||
const defaults: Record<string, number> = {
|
||||
USD: 1,
|
||||
VND: 0.00004,
|
||||
AUD: 0.65,
|
||||
GBP: 1.27,
|
||||
EUR: 1.08,
|
||||
CAD: 0.73,
|
||||
}
|
||||
try {
|
||||
const override = process.env.EBAY_FX_RATES ? JSON.parse(process.env.EBAY_FX_RATES) : {}
|
||||
return { ...defaults, ...override }
|
||||
} catch {
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
/** Quy đổi amount từ `from` sang `to`. Trả null nếu thiếu tỉ giá (để bỏ qua item). */
|
||||
private static convert(
|
||||
amount: number,
|
||||
from: string,
|
||||
to: string,
|
||||
fx: Record<string, number>
|
||||
): number | null {
|
||||
if (from === to) return amount
|
||||
const rFrom = fx[from]
|
||||
const rTo = fx[to]
|
||||
if (!rFrom || !rTo) return null
|
||||
return (amount * rFrom) / rTo
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,267 @@
|
|||
import type Product from '#models/product'
|
||||
import type { EbayData } from '#models/history'
|
||||
|
||||
const EBAY_CONDITION_ID: Record<string, string> = {
|
||||
NEW: '1000',
|
||||
OPEN_BOX: '1500',
|
||||
REFURBISHED: '2000',
|
||||
USED: '3000',
|
||||
FOR_PARTS: '7000',
|
||||
}
|
||||
|
||||
/** Scope cơ bản — đủ cho Browse API (listing đang bán). */
|
||||
const SCOPE_BASE = 'https://api.ebay.com/oauth/api_scope'
|
||||
/**
|
||||
* Scope cho Marketplace Insights API (listing đã bán).
|
||||
* Lưu ý: đây là Limited Release — app phải được eBay duyệt mới được cấp scope này,
|
||||
* nếu chưa duyệt thì token request trả invalid_scope / API trả 403.
|
||||
*/
|
||||
const SCOPE_INSIGHTS = 'https://api.ebay.com/oauth/api_scope/buy.marketplace.insights'
|
||||
|
||||
/**
|
||||
* Lấy dữ liệu eBay: đã bán (sold) + đang bán (sale/active).
|
||||
* Nếu chưa cấu hình OAuth thì trả về dữ liệu mock để hệ thống vẫn chạy.
|
||||
*/
|
||||
export default class EbayService {
|
||||
static async getMarketData(product: Product): Promise<EbayData> {
|
||||
const sku = product.sku?.trim()
|
||||
const condition = this.normalizeCondition(product.condition)
|
||||
|
||||
if (!sku) {
|
||||
return { sold: [], sale: [] }
|
||||
}
|
||||
|
||||
const clientId = process.env.EBAY_CLIENT_ID
|
||||
const clientSecret = process.env.EBAY_CLIENT_SECRET
|
||||
const baseUrl = process.env.EBAY_BASE_URL || 'https://api.ebay.com'
|
||||
const hasApiCreds = !!(clientId && clientSecret && baseUrl)
|
||||
|
||||
const conditionId = this.getConditionId(condition)
|
||||
// Marketplace theo warehouse của product: US -> EBAY_US, còn lại -> EBAY_AU.
|
||||
const marketplace = this.resolveMarketplace(product.warehouse)
|
||||
|
||||
// Hai nguồn độc lập: lỗi nguồn này không được làm mất nguồn kia.
|
||||
// - sale (Browse API): cần API creds; thiếu creds thì bỏ qua (->[]).
|
||||
// - sold: mặc định scrape (không cần creds) hoặc Insights API tùy EBAY_SOLD_SOURCE.
|
||||
const [sale, sold] = await Promise.all([
|
||||
hasApiCreds
|
||||
? this.tryFetch('sale', () =>
|
||||
this.getAccessToken(clientId!, clientSecret!, baseUrl, SCOPE_BASE).then((token) =>
|
||||
this.searchActiveListings(token, baseUrl, sku, conditionId, marketplace)
|
||||
)
|
||||
)
|
||||
: Promise.resolve([] as Array<Record<string, any>>),
|
||||
this.tryFetch('sold', () =>
|
||||
this.getSoldListings(clientId, clientSecret, baseUrl, sku, conditionId, marketplace)
|
||||
),
|
||||
])
|
||||
|
||||
// Chỉ dùng mock khi KHÔNG có dữ liệu thật nào — giữ lại nguồn nào còn sống.
|
||||
if (!sale.length && !sold.length) {
|
||||
return this.buildMockData(sku, condition)
|
||||
}
|
||||
return { sale, sold }
|
||||
}
|
||||
|
||||
/**
|
||||
* Nguồn listing ĐÃ BÁN. Chọn theo env EBAY_SOLD_SOURCE:
|
||||
* - 'scrape' (mặc định): scrape bằng Puppeteer (không cần quyền Insights API).
|
||||
* - 'api': gọi Marketplace Insights API (cần Limited Release approval).
|
||||
*/
|
||||
private static async getSoldListings(
|
||||
clientId: string | undefined,
|
||||
clientSecret: string | undefined,
|
||||
baseUrl: string,
|
||||
sku: string,
|
||||
conditionId: string,
|
||||
marketplace: string
|
||||
): Promise<Array<Record<string, any>>> {
|
||||
const source = (process.env.EBAY_SOLD_SOURCE || 'scrape').toLowerCase()
|
||||
|
||||
if (source === 'api') {
|
||||
if (!clientId || !clientSecret) throw new Error('Thiếu EBAY creds cho Insights API')
|
||||
const token = await this.getAccessToken(clientId, clientSecret, baseUrl, SCOPE_INSIGHTS)
|
||||
return this.searchSoldListings(token, baseUrl, sku, conditionId, marketplace)
|
||||
}
|
||||
|
||||
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
||||
return EbayScraperService.scrapeSold(sku, conditionId, marketplace)
|
||||
}
|
||||
|
||||
/** Marketplace eBay theo warehouse: 'US' -> EBAY_US, mọi giá trị khác -> EBAY_AU. */
|
||||
private static resolveMarketplace(warehouse?: string | null): string {
|
||||
return (warehouse || '').trim().toUpperCase() === 'US' ? 'EBAY_US' : 'EBAY_AU'
|
||||
}
|
||||
|
||||
/** Chạy 1 nguồn dữ liệu, nuốt lỗi và trả [] để không kéo sập nguồn còn lại. */
|
||||
private static async tryFetch(
|
||||
label: string,
|
||||
fn: () => Promise<Array<Record<string, any>>>
|
||||
): Promise<Array<Record<string, any>>> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (error) {
|
||||
console.warn(`eBay ${label} fetch failed:`, (error as Error).message)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static async getAccessToken(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
baseUrl: string,
|
||||
scope: string = SCOPE_BASE
|
||||
): Promise<string> {
|
||||
const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64')
|
||||
const response = await fetch(`${baseUrl.replace(/\/$/, '')}/identity/v1/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${credentials}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: `grant_type=client_credentials&scope=${encodeURIComponent(scope)}`,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
// 400 invalid_scope -> app chưa được cấp scope (vd Insights Limited Release).
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`eBay OAuth lỗi: ${response.status} ${detail}`.trim())
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string }
|
||||
if (!data.access_token) {
|
||||
throw new Error('eBay OAuth không trả về access token')
|
||||
}
|
||||
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
private static async searchActiveListings(
|
||||
token: string,
|
||||
baseUrl: string,
|
||||
sku: string,
|
||||
conditionId: string,
|
||||
marketplace: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${baseUrl.replace(/\/$/, '')}/buy/browse/v1/item_summary/search?q=${encodeURIComponent(sku)}&filter=conditionIds:{${conditionId}}&limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-EBAY-C-MARKETPLACE-ID': marketplace,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`eBay Browse lỗi: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { itemSummaries?: Array<Record<string, any>> }
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (data.itemSummaries || [])
|
||||
.map((item) => {
|
||||
const price = Number(item?.price?.value ?? item?.sellingStatus?.currentPrice?.value ?? item?.price)
|
||||
if (!Number.isFinite(price)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
date: today,
|
||||
price,
|
||||
source: 'ebay',
|
||||
title: item?.title || sku,
|
||||
itemId: item?.itemId,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, any>>
|
||||
}
|
||||
|
||||
private static async searchSoldListings(
|
||||
token: string,
|
||||
baseUrl: string,
|
||||
sku: string,
|
||||
conditionId: string,
|
||||
marketplace: string
|
||||
) {
|
||||
const response = await fetch(
|
||||
`${baseUrl.replace(/\/$/, '')}/buy/marketplace_insights/v1_beta/item_sales/search?q=${encodeURIComponent(sku)}&filter=conditionIds:{${conditionId}}&limit=50`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-EBAY-C-MARKETPLACE-ID': marketplace,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`eBay Insights lỗi: ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as { itemSales?: Array<Record<string, any>> }
|
||||
|
||||
return (data.itemSales || [])
|
||||
.map((item) => {
|
||||
const price = Number(item?.lastSoldPrice?.value ?? item?.price?.value ?? item?.price)
|
||||
const date = typeof item?.lastSoldDate === 'string' ? item.lastSoldDate.slice(0, 10) : ''
|
||||
if (!date || !Number.isFinite(price)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
date,
|
||||
price,
|
||||
source: 'ebay',
|
||||
title: item?.title || sku,
|
||||
itemId: item?.itemId,
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<Record<string, any>>
|
||||
}
|
||||
|
||||
private static buildMockData(sku: string, condition: string): EbayData {
|
||||
const basePrice = 80 + this.hashString(`${sku}:${condition}`) % 220
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const sale = Array.from({ length: 6 }, (_, index) => ({
|
||||
date: today,
|
||||
price: Number((basePrice * (1 + index * 0.03)).toFixed(2)),
|
||||
source: 'ebay-mock',
|
||||
title: sku,
|
||||
condition,
|
||||
}))
|
||||
|
||||
const sold = Array.from({ length: 5 }, (_, index) => ({
|
||||
date: new Date(Date.now() - index * 86400000).toISOString().slice(0, 10),
|
||||
price: Number((basePrice * 0.9 * (1 + index * 0.02)).toFixed(2)),
|
||||
source: 'ebay-mock',
|
||||
title: sku,
|
||||
condition,
|
||||
}))
|
||||
|
||||
return { sale, sold }
|
||||
}
|
||||
|
||||
private static normalizeCondition(condition?: string): string {
|
||||
const normalized = (condition || 'USED').toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
return normalized || 'USED'
|
||||
}
|
||||
|
||||
private static getConditionId(condition: string): string {
|
||||
if (condition.includes('OPEN')) return EBAY_CONDITION_ID.OPEN_BOX
|
||||
if (condition.includes('REFURB')) return EBAY_CONDITION_ID.REFURBISHED
|
||||
if (condition.includes('PART')) return EBAY_CONDITION_ID.FOR_PARTS
|
||||
if (condition.includes('NEW') || condition.includes('NIB') || condition.includes('NOB')) return EBAY_CONDITION_ID.NEW
|
||||
if (condition.includes('USE')) return EBAY_CONDITION_ID.USED
|
||||
|
||||
return EBAY_CONDITION_ID.USED
|
||||
}
|
||||
|
||||
private static hashString(value: string): number {
|
||||
let hash = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) | 0
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,155 @@
|
|||
import env from '#start/env'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type Product from '#models/product'
|
||||
import type { SupplierPricePoint } from '#models/history'
|
||||
import axios from 'axios'
|
||||
|
||||
interface ErpProductListParams {
|
||||
limit?: number
|
||||
skip?: number
|
||||
order?: string
|
||||
where?: {
|
||||
sku?: string
|
||||
warehouse?: string
|
||||
condition?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ErpProductItem {
|
||||
sku: string
|
||||
condition: string
|
||||
qty: number
|
||||
price: number
|
||||
costs?: Array<{ currency: string; price: number }> | null
|
||||
packageContain?: string | null
|
||||
type?: string | null
|
||||
erpId?: string | null
|
||||
warehouse?: string | null
|
||||
}
|
||||
|
||||
export interface ErpProductPage {
|
||||
items: ErpProductItem[]
|
||||
/** Tổng số bản ghi ở ERP (dùng để phân trang vòng lặp sync) */
|
||||
total: number
|
||||
skip: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Gọi ERP để lấy lịch sử giá supplier theo SKU/condition.
|
||||
*/
|
||||
export default class ErpService {
|
||||
static async getSupplierPricing(product: Product): Promise<SupplierPricePoint[]> {
|
||||
const url = env.get('ERP_API_URL')
|
||||
if (!url) throw new Error('ERP_API_URL chưa cấu hình')
|
||||
|
||||
const body = {
|
||||
urlAPI: '/api/wtbquote-result/get-result-list',
|
||||
filter: {
|
||||
limit: 50,
|
||||
skip: 0,
|
||||
order: 'updatedAt desc',
|
||||
where: { _q: product.sku, condition: product.condition },
|
||||
},
|
||||
}
|
||||
|
||||
const resp = await axios.post(url + "/api/transferGetData", body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.get('ERP_API_KEY')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
if (!resp.data) throw new Error(`ERP API lỗi: ${resp.status}`)
|
||||
|
||||
const data: any = await resp.data
|
||||
logger.debug({ count: data?.data?.length }, 'ERP supplier pricing fetched')
|
||||
|
||||
return (data?.data ?? []).map((r: any) => ({
|
||||
price: Number(r.price),
|
||||
date: r.createdAt,
|
||||
source: 'erp',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Gọi ERP để lấy danh sách sản phẩm tồn kho cho sync.
|
||||
*/
|
||||
static async getProductsForSync(params: ErpProductListParams = {}): Promise<ErpProductPage> {
|
||||
const url = env.get('ERP_API_URL')
|
||||
if (!url) throw new Error('ERP_API_URL chưa cấu hình')
|
||||
const limit = params.limit ?? 20
|
||||
const skip = params.skip ?? 0
|
||||
try {
|
||||
const body = {
|
||||
urlAPI: '/api/products/instock',
|
||||
filter: {
|
||||
limit,
|
||||
skip,
|
||||
order: params.order ?? '',
|
||||
where: {
|
||||
sku: params.where?.sku ?? '',
|
||||
warehouse: params.where?.warehouse ?? '',
|
||||
condition: params.where?.condition ?? '',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const resp = await axios.post(url + "/api/transferGetData", body, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.get('ERP_API_KEY')}`,
|
||||
},
|
||||
})
|
||||
if (!resp.data) throw new Error(`ERP API lỗi: ${resp.status}`)
|
||||
|
||||
const data: any = await resp.data
|
||||
const raw = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
|
||||
const total = Number(data?.total ?? raw.length)
|
||||
|
||||
logger.debug({ count: raw.length, total, skip }, 'ERP products fetched for sync')
|
||||
|
||||
const items: ErpProductItem[] = raw.map((item: any) => {
|
||||
const sku = String(item?.sku ?? item?.SKU ?? item?.productSku ?? item?.product?.sku ?? '').trim()
|
||||
const condition = String(
|
||||
item?.condition ?? item?.Condition ?? item?.conditionName ?? item?.productCondition ?? ''
|
||||
).trim()
|
||||
const qty = Number(item?.qty ?? item?.quantity ?? item?.stock ?? item?.availableQty ?? item?.inStockQty ?? 0)
|
||||
const price = Number(item?.price ?? item?.salePrice ?? item?.listPrice ?? item?.unitPrice ?? 0)
|
||||
const costs = Array.isArray(item?.costs)
|
||||
? item.costs.map((entry: any) => ({
|
||||
currency: String(entry?.currency ?? 'USD').trim(),
|
||||
price: Number(entry?.price ?? 0),
|
||||
}))
|
||||
: item?.cost != null || item?.purchasePrice != null || item?.costPrice != null
|
||||
? [
|
||||
{
|
||||
currency: 'USD',
|
||||
price: Number(item?.cost ?? item?.purchasePrice ?? item?.costPrice ?? 0),
|
||||
},
|
||||
]
|
||||
: null
|
||||
const packageContain = item?.packageContain ?? item?.package_contain ?? item?.packageQty ?? null
|
||||
const erpId = item?.productId ?? null
|
||||
const warehouse = item?.warehouse ?? 'AU'
|
||||
|
||||
return {
|
||||
sku,
|
||||
condition,
|
||||
qty: Number.isFinite(qty) ? qty : 0,
|
||||
price: Number.isFinite(price) ? price : 0,
|
||||
costs,
|
||||
packageContain: packageContain == null ? null : String(packageContain),
|
||||
type: 'ERP',
|
||||
erpId: erpId ? String(erpId) : null,
|
||||
warehouse,
|
||||
}
|
||||
})
|
||||
|
||||
return { items, total: Number.isFinite(total) ? total : items.length, skip, limit }
|
||||
}
|
||||
catch (error) {
|
||||
logger.error({ err: error }, 'ERP getProductsForSync lỗi')
|
||||
// throw error
|
||||
return { items: [], total: 0, skip, limit }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import History from '#models/history'
|
||||
import type { SupplierPricePoint, EbayData } from '#models/history'
|
||||
|
||||
interface RecordHistoryInput {
|
||||
username: string
|
||||
productId: number
|
||||
dataSources: SupplierPricePoint[]
|
||||
dataEbay: EbayData
|
||||
aiResult?: Record<string, any> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Lưu snapshot dữ liệu mỗi lần lấy về để AI gợi ý giá.
|
||||
*/
|
||||
export default class HistoryService {
|
||||
static async record(input: RecordHistoryInput): Promise<History> {
|
||||
return History.create({
|
||||
username: input.username,
|
||||
productId: input.productId,
|
||||
dataSources: input.dataSources,
|
||||
dataEbay: input.dataEbay,
|
||||
aiResult: input.aiResult ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
static async listForProduct(productId: number, limit = 50) {
|
||||
return History.query()
|
||||
.where('product_id', productId)
|
||||
.orderBy('time', 'desc')
|
||||
.limit(limit)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import xlsx from 'xlsx'
|
||||
import ProductService from '#services/product_service'
|
||||
import LogService from '#services/log_service'
|
||||
|
||||
export interface ImportRowResult {
|
||||
row: number
|
||||
sku?: string
|
||||
status: 'created' | 'updated' | 'error'
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ImportSummary {
|
||||
total: number
|
||||
created: number
|
||||
updated: number
|
||||
failed: number
|
||||
rows: ImportRowResult[]
|
||||
}
|
||||
|
||||
const VALID_HEADERS = ['sku', 'condition', 'qty', 'price']
|
||||
|
||||
/**
|
||||
* Import sản phẩm từ file Excel. Cột yêu cầu: sku, condition, qty, price.
|
||||
*/
|
||||
export default class ImportService {
|
||||
static async importFromFile(filePath: string, username: string): Promise<ImportSummary> {
|
||||
const wb = xlsx.readFile(filePath)
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]]
|
||||
const rows = xlsx.utils.sheet_to_json<Record<string, any>>(sheet, { defval: null })
|
||||
return this.processRows(rows, username)
|
||||
}
|
||||
|
||||
static async importFromBuffer(buffer: Buffer, username: string): Promise<ImportSummary> {
|
||||
const wb = xlsx.read(buffer, { type: 'buffer' })
|
||||
const sheet = wb.Sheets[wb.SheetNames[0]]
|
||||
const rows = xlsx.utils.sheet_to_json<Record<string, any>>(sheet, { defval: null })
|
||||
return this.processRows(rows, username)
|
||||
}
|
||||
|
||||
private static async processRows(
|
||||
rows: Record<string, any>[],
|
||||
username: string
|
||||
): Promise<ImportSummary> {
|
||||
const summary: ImportSummary = { total: rows.length, created: 0, updated: 0, failed: 0, rows: [] }
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const rowNo = i + 2 // +1 header, +1 1-based
|
||||
const raw = this.normalizeKeys(rows[i])
|
||||
|
||||
const error = this.validateRow(raw)
|
||||
if (error) {
|
||||
summary.failed++
|
||||
summary.rows.push({ row: rowNo, sku: raw.sku, status: 'error', message: error })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const { created } = await ProductService.upsert({
|
||||
sku: String(raw.sku).trim(),
|
||||
condition: String(raw.condition).trim(),
|
||||
qty: Number(raw.qty),
|
||||
price: Number(raw.price),
|
||||
})
|
||||
created ? summary.created++ : summary.updated++
|
||||
summary.rows.push({ row: rowNo, sku: raw.sku, status: created ? 'created' : 'updated' })
|
||||
} catch (e: any) {
|
||||
summary.failed++
|
||||
summary.rows.push({ row: rowNo, sku: raw.sku, status: 'error', message: e.message })
|
||||
}
|
||||
}
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Import Excel',
|
||||
action: 'import',
|
||||
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
|
||||
})
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
private static normalizeKeys(row: Record<string, any>): Record<string, any> {
|
||||
const out: Record<string, any> = {}
|
||||
for (const [k, v] of Object.entries(row)) {
|
||||
out[String(k).trim().toLowerCase()] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static validateRow(raw: Record<string, any>): string | null {
|
||||
for (const h of VALID_HEADERS) {
|
||||
if (raw[h] === null || raw[h] === undefined || raw[h] === '') {
|
||||
return `Thiếu cột "${h}"`
|
||||
}
|
||||
}
|
||||
if (Number.isNaN(Number(raw.qty))) return 'qty không hợp lệ'
|
||||
if (Number.isNaN(Number(raw.price))) return 'price không hợp lệ'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import Log from '#models/log'
|
||||
|
||||
export type LogAction = 'create' | 'update' | 'delete' | 'import' | 'sync' | 'suggest'
|
||||
|
||||
interface RecordLogInput {
|
||||
username: string
|
||||
actionName: string
|
||||
action: LogAction
|
||||
productId?: number | null
|
||||
meta?: Record<string, any> | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ghi nhật ký thao tác CUD của người dùng lên sản phẩm.
|
||||
*/
|
||||
export default class LogService {
|
||||
static async record(input: RecordLogInput): Promise<Log> {
|
||||
return Log.create({
|
||||
username: input.username,
|
||||
actionName: input.actionName,
|
||||
action: input.action,
|
||||
productId: input.productId ?? null,
|
||||
meta: input.meta ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
/** Ghi log hàng loạt (vd import nhiều dòng). */
|
||||
static async recordMany(inputs: RecordLogInput[]): Promise<void> {
|
||||
if (!inputs.length) return
|
||||
await Log.createMany(
|
||||
inputs.map((i) => ({
|
||||
username: i.username,
|
||||
actionName: i.actionName,
|
||||
action: i.action,
|
||||
productId: i.productId ?? null,
|
||||
meta: i.meta ?? null,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import env from '#start/env'
|
||||
import Product from '#models/product'
|
||||
import ErpService from '#services/erp_service'
|
||||
import EbayService from '#services/ebay_service'
|
||||
import AiService from '#services/ai_service'
|
||||
import type { Suggestion } from '#services/ai_service'
|
||||
import HistoryService from '#services/history_service'
|
||||
import LogService from '#services/log_service'
|
||||
|
||||
export interface SuggestResult {
|
||||
productId: number
|
||||
suggestion: Suggestion
|
||||
applied: boolean
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
historyId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator của service gợi ý giá — lõi của hệ thống.
|
||||
*
|
||||
* Luồng:
|
||||
* 1. Lấy dữ liệu supplier (ERP) + eBay (sold/sale)
|
||||
* 2. Lưu snapshot vào history
|
||||
* 3. Gọi AI/rule engine -> suggestion
|
||||
* 4. Lưu ai_price vào product
|
||||
* 5. Hybrid áp giá: chênh lệch <= ngưỡng -> tự áp price; vượt -> chờ duyệt
|
||||
*/
|
||||
export default class PricingService {
|
||||
/**
|
||||
* Gợi ý giá cho 1 product (on-demand).
|
||||
*
|
||||
* @param forceApply true = luôn áp giá AI vào `price` (bỏ qua ngưỡng hybrid).
|
||||
* false (mặc định) = chỉ tự áp khi chênh lệch <= ngưỡng.
|
||||
*/
|
||||
static async suggestForProduct(
|
||||
productId: number,
|
||||
username: string,
|
||||
forceApply = false
|
||||
): Promise<SuggestResult> {
|
||||
const product = await Product.findOrFail(productId)
|
||||
|
||||
const [dataSources, dataEbay] = await Promise.all([
|
||||
ErpService.getSupplierPricing(product),
|
||||
EbayService.getMarketData(product),
|
||||
])
|
||||
|
||||
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
|
||||
|
||||
const history = await HistoryService.record({
|
||||
username,
|
||||
productId: product.id,
|
||||
dataSources,
|
||||
dataEbay,
|
||||
aiResult: suggestion,
|
||||
})
|
||||
|
||||
const oldPrice = Number(product.price)
|
||||
const thresholdPct = Number(env.get('PRICING_AUTO_APPLY_THRESHOLD_PCT', 5))
|
||||
const diffPct = oldPrice > 0 ? (Math.abs(suggestion.suggestedPrice - oldPrice) / oldPrice) * 100 : 100
|
||||
|
||||
// luôn cập nhật ai_price (giá gợi ý gần nhất)
|
||||
product.aiPrice = suggestion.suggestedPrice
|
||||
|
||||
// hybrid: chỉ tự áp khi trong ngưỡng; forceApply bỏ qua ngưỡng để luôn áp.
|
||||
const applied = forceApply || diffPct <= thresholdPct
|
||||
if (applied) {
|
||||
product.price = suggestion.suggestedPrice
|
||||
}
|
||||
await product.save()
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: applied
|
||||
? forceApply
|
||||
? 'Áp giá AI (force)'
|
||||
: 'Tự áp giá AI'
|
||||
: 'Gợi ý giá (chờ duyệt)',
|
||||
action: 'suggest',
|
||||
productId: product.id,
|
||||
meta: { oldPrice, suggested: suggestion.suggestedPrice, diffPct: Math.round(diffPct * 100) / 100, applied },
|
||||
})
|
||||
|
||||
return {
|
||||
productId: product.id,
|
||||
suggestion,
|
||||
applied,
|
||||
oldPrice,
|
||||
newPrice: Number(product.price),
|
||||
historyId: history.id,
|
||||
}
|
||||
}
|
||||
|
||||
/** Duyệt & áp giá AI gợi ý (cho trường hợp vượt ngưỡng). */
|
||||
static async approve(productId: number, username: string, price?: number): Promise<Product> {
|
||||
const product = await Product.findOrFail(productId)
|
||||
const oldPrice = Number(product.price)
|
||||
const newPrice = price ?? Number(product.aiPrice)
|
||||
if (!newPrice) throw new Error('Chưa có giá AI để duyệt')
|
||||
|
||||
product.price = newPrice
|
||||
await product.save()
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Duyệt & áp giá AI',
|
||||
action: 'update',
|
||||
productId: product.id,
|
||||
meta: { oldPrice, newPrice },
|
||||
})
|
||||
return product
|
||||
}
|
||||
|
||||
/** Lấy danh sách id product để chạy batch (mặc định toàn bộ). */
|
||||
static async productIdsForBatch(): Promise<number[]> {
|
||||
const rows = await Product.query().select('id')
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import Product from '#models/product'
|
||||
import LogService from '#services/log_service'
|
||||
|
||||
interface ListParams {
|
||||
page?: number
|
||||
perPage?: number
|
||||
search?: string
|
||||
condition?: string
|
||||
type?: string
|
||||
origin?: 'sync' | 'manual'
|
||||
}
|
||||
|
||||
interface ProductData {
|
||||
sku: string
|
||||
condition: string
|
||||
qty: number
|
||||
price: number
|
||||
costs?: Array<{ currency: string; price: number }> | null
|
||||
aiPrice?: number | null
|
||||
packageContain?: string | null
|
||||
type?: string | null
|
||||
erpId?: string | null
|
||||
warehouse?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* CRUD sản phẩm (manual + sync) + ghi log thao tác.
|
||||
*/
|
||||
export default class ProductService {
|
||||
static async list(params: ListParams) {
|
||||
const page = params.page ?? 1
|
||||
const perPage = params.perPage ?? 25
|
||||
|
||||
const query = Product.query()
|
||||
|
||||
if (params.condition) query.where('condition', params.condition)
|
||||
if (params.type) query.where('type', params.type)
|
||||
if (params.search) {
|
||||
// FULLTEXT khi có, fallback LIKE
|
||||
query.where((b) => {
|
||||
b.where('sku', 'like', `%${params.search}%`).orWhere('type', 'like', `%${params.search}%`)
|
||||
})
|
||||
}
|
||||
|
||||
return query.orderBy('updated_at', 'desc').paginate(page, perPage)
|
||||
}
|
||||
|
||||
static async create(data: ProductData, username: string): Promise<Product> {
|
||||
const product = await Product.create(data)
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Tạo sản phẩm',
|
||||
action: 'create',
|
||||
productId: product.id,
|
||||
meta: { sku: product.sku, condition: product.condition },
|
||||
})
|
||||
return product
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<ProductData>, username: string): Promise<Product> {
|
||||
const product = await Product.findOrFail(id)
|
||||
const before = product.serialize()
|
||||
product.merge(data)
|
||||
await product.save()
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Cập nhật sản phẩm',
|
||||
action: 'update',
|
||||
productId: product.id,
|
||||
meta: { before, after: product.serialize() },
|
||||
})
|
||||
return product
|
||||
}
|
||||
|
||||
static async destroy(id: number, username: string): Promise<void> {
|
||||
const product = await Product.findOrFail(id)
|
||||
const snapshot = product.serialize()
|
||||
await product.delete()
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Xóa sản phẩm',
|
||||
action: 'delete',
|
||||
productId: id,
|
||||
meta: snapshot,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert dùng cho import & sync (chạy hằng ngày).
|
||||
*
|
||||
* Định danh bản ghi theo ĐÚNG khóa unique của bảng: (sku, condition, warehouse).
|
||||
* Tồn tại -> update (gồm cả cập nhật lại erp_id), không -> insert mới.
|
||||
*
|
||||
* LƯU Ý: KHÔNG tra cứu theo erp_id vì ERP có thể trả nhiều dòng cùng
|
||||
* (sku, condition, warehouse) với erp_id khác nhau -> nếu match theo erp_id sẽ
|
||||
* không tìm thấy và insert trùng -> lỗi Duplicate entry. Khóa tra cứu phải
|
||||
* khớp khóa unique để upsert idempotent.
|
||||
*
|
||||
* Trả về { product, created }.
|
||||
*/
|
||||
static async upsert(data: ProductData): Promise<{ product: Product; created: boolean }> {
|
||||
const warehouse = data.warehouse ?? 'AU'
|
||||
|
||||
const findExisting = () =>
|
||||
Product.query()
|
||||
.where('sku', data.sku)
|
||||
.where('condition', data.condition)
|
||||
.where('warehouse', warehouse)
|
||||
.first()
|
||||
|
||||
const existing = await findExisting()
|
||||
if (existing) {
|
||||
existing.merge({ ...data, warehouse })
|
||||
await existing.save()
|
||||
return { product: existing, created: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await Product.create({ ...data, warehouse })
|
||||
return { product: created, created: true }
|
||||
} catch (err: any) {
|
||||
// Race: 1 worker khác vừa insert cùng (sku, condition, warehouse) giữa lúc
|
||||
// ta SELECT và INSERT -> tìm lại rồi update thay vì để lỗi.
|
||||
if (err?.code === 'ER_DUP_ENTRY') {
|
||||
const raced = await findExisting()
|
||||
if (raced) {
|
||||
raced.merge({ ...data, warehouse })
|
||||
await raced.save()
|
||||
return { product: raced, created: false }
|
||||
}
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import { Queue } from 'bullmq'
|
||||
import env from '#start/env'
|
||||
import type { ErpProductItem } from '#services/erp_service'
|
||||
|
||||
export const redisConnection = {
|
||||
host: env.get('REDIS_HOST', '127.0.0.1'),
|
||||
port: Number(env.get('REDIS_PORT', 6379)),
|
||||
password: env.get('REDIS_PASSWORD') || undefined,
|
||||
}
|
||||
|
||||
export const QUEUE_NAMES = {
|
||||
pricing: 'pricing',
|
||||
sync: 'sync',
|
||||
product: 'product',
|
||||
import: 'import',
|
||||
} as const
|
||||
|
||||
export const JOB_NAMES = {
|
||||
/** Job orchestrator: quét ERP rồi fan-out các job upsert. */
|
||||
erpSync: 'erp',
|
||||
/** Job upsert 1 sản phẩm (BullMQ tự retry khi lỗi). */
|
||||
upsertProduct: 'upsert',
|
||||
} as const
|
||||
|
||||
const defaultJobOptions = {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential' as const, delay: 5000 },
|
||||
removeOnComplete: 1000,
|
||||
removeOnFail: 5000,
|
||||
}
|
||||
|
||||
/*
|
||||
| Lazy singletons — chỉ mở kết nối Redis khi thực sự enqueue, tránh việc
|
||||
| import module (vd ace liệt kê command) lại tự mở kết nối và treo process.
|
||||
*/
|
||||
let _pricingQueue: Queue | undefined
|
||||
let _syncQueue: Queue | undefined
|
||||
let _productQueue: Queue | undefined
|
||||
let _importQueue: Queue | undefined
|
||||
|
||||
export function pricingQueue(): Queue {
|
||||
if (!_pricingQueue) {
|
||||
_pricingQueue = new Queue(QUEUE_NAMES.pricing, { connection: redisConnection, defaultJobOptions })
|
||||
}
|
||||
return _pricingQueue
|
||||
}
|
||||
|
||||
export function syncQueue(): Queue {
|
||||
if (!_syncQueue) {
|
||||
_syncQueue = new Queue(QUEUE_NAMES.sync, { connection: redisConnection, defaultJobOptions })
|
||||
}
|
||||
return _syncQueue
|
||||
}
|
||||
|
||||
export function productQueue(): Queue {
|
||||
if (!_productQueue) {
|
||||
_productQueue = new Queue(QUEUE_NAMES.product, { connection: redisConnection, defaultJobOptions })
|
||||
}
|
||||
return _productQueue
|
||||
}
|
||||
|
||||
export function importQueue(): Queue {
|
||||
if (!_importQueue) {
|
||||
_importQueue = new Queue(QUEUE_NAMES.import, { connection: redisConnection, defaultJobOptions })
|
||||
}
|
||||
return _importQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Đóng tất cả kết nối Redis của các queue đã mở.
|
||||
* Dùng cho command chạy 1 lần để tiến trình thoát sạch.
|
||||
*/
|
||||
export async function closeQueues(): Promise<void> {
|
||||
await Promise.all([
|
||||
_pricingQueue?.close(),
|
||||
_syncQueue?.close(),
|
||||
_productQueue?.close(),
|
||||
_importQueue?.close(),
|
||||
])
|
||||
_pricingQueue = _syncQueue = _productQueue = _importQueue = undefined
|
||||
}
|
||||
|
||||
/** Đẩy job gợi ý giá cho 1 product. */
|
||||
export async function enqueuePricingSuggest(productId: number, username: string) {
|
||||
return pricingQueue().add('suggest', { productId, username })
|
||||
}
|
||||
|
||||
/** Đẩy nhiều job gợi ý giá (batch). */
|
||||
export async function enqueuePricingBatch(productIds: number[], username: string) {
|
||||
return pricingQueue().addBulk(
|
||||
productIds.map((productId) => ({ name: 'suggest', data: { productId, username } }))
|
||||
)
|
||||
}
|
||||
|
||||
/** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */
|
||||
export async function enqueueSync(username: string) {
|
||||
return syncQueue().add(JOB_NAMES.erpSync, { username })
|
||||
}
|
||||
|
||||
/** ID cố định cho scheduler sync hằng ngày (idempotent). */
|
||||
export const SYNC_SCHEDULER_ID = 'daily-erp-sync'
|
||||
|
||||
/**
|
||||
* Tạo/cập nhật job scheduler chạy sync ERP định kỳ (cron).
|
||||
* Idempotent: gọi lại nhiều lần chỉ cập nhật lịch, không nhân đôi.
|
||||
*/
|
||||
export async function upsertSyncScheduler(
|
||||
pattern: string,
|
||||
tz: string | undefined,
|
||||
username = 'cron'
|
||||
) {
|
||||
return syncQueue().upsertJobScheduler(
|
||||
SYNC_SCHEDULER_ID,
|
||||
{ pattern, ...(tz ? { tz } : {}) },
|
||||
{ name: JOB_NAMES.erpSync, data: { username } }
|
||||
)
|
||||
}
|
||||
|
||||
/** Gỡ scheduler sync định kỳ. */
|
||||
export async function removeSyncScheduler() {
|
||||
return syncQueue().removeJobScheduler(SYNC_SCHEDULER_ID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Đẩy batch job upsert sản phẩm — mỗi sản phẩm 1 job.
|
||||
* BullMQ tự retry job lỗi theo `defaultJobOptions` (attempts + backoff),
|
||||
* nên các sản phẩm lỗi sẽ được sync lại tự động sau cùng.
|
||||
*/
|
||||
export async function enqueueProductUpserts(items: ErpProductItem[], username: string) {
|
||||
return productQueue().addBulk(
|
||||
items.map((item) => ({
|
||||
name: JOB_NAMES.upsertProduct,
|
||||
data: { item, username },
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import logger from '@adonisjs/core/services/logger'
|
||||
import ProductService from '#services/product_service'
|
||||
import LogService from '#services/log_service'
|
||||
import ErpService, { type ErpProductItem } from '#services/erp_service'
|
||||
import { enqueueProductUpserts } from '#services/queue_service'
|
||||
|
||||
export interface SyncSummary {
|
||||
/** Tổng số bản ghi ERP tự báo cáo (field `total`) — CHỈ tham khảo, không tin cậy. */
|
||||
total: number
|
||||
/** Số item fetch về từ ERP. */
|
||||
fetched: number
|
||||
/** Số item hợp lệ đã đẩy lên queue. */
|
||||
enqueued: number
|
||||
/** Số item bị bỏ qua (sku rỗng / rác). */
|
||||
skipped: number
|
||||
/** Số lần gọi ERP (số page). */
|
||||
pages: number
|
||||
startedAt: string
|
||||
finishedAt?: string
|
||||
}
|
||||
|
||||
interface SyncOptions {
|
||||
/** Bước phân trang (giá trị `skip` tăng mỗi lần). Mặc định 100. */
|
||||
pageSize?: number
|
||||
/** Trần số trang để chặn lặp vô hạn nếu ERP không bao giờ trả rỗng. Mặc định 1000. */
|
||||
maxPages?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Service đồng bộ sản phẩm từ ERP qua BullMQ.
|
||||
*
|
||||
* Luồng:
|
||||
* 1. `syncFromErp` (chạy trong job `erp`) — orchestrator: quét toàn bộ ERP
|
||||
* theo phân trang và fan-out mỗi sản phẩm thành 1 job `upsert`.
|
||||
* 2. Worker xử lý job `upsert` gọi `upsertProduct`. Nếu lỗi, BullMQ tự retry
|
||||
* theo `attempts` + exponential backoff -> sản phẩm lỗi được sync lại sau
|
||||
* cùng (sau backoff), không cần hàng đợi retry thủ công.
|
||||
*
|
||||
* LƯU Ý PHÂN TRANG (ERP /api/products/instock):
|
||||
* - ERP BỎ QUA `limit` (trả số item/trang tùy ý, vd 195/309), và `total` báo
|
||||
* về KHÔNG khớp số bản ghi thật -> KHÔNG dùng `total` làm điều kiện dừng.
|
||||
* - `skip` hoạt động như cursor bước `pageSize`: skip=0 và skip=100 cho 2 block
|
||||
* RỜI NHAU. Vì vậy tăng `skip += pageSize` và lặp tới khi trang trả về RỖNG.
|
||||
*/
|
||||
export default class SyncService {
|
||||
static async syncFromErp(username = 'system', options: SyncOptions = {}): Promise<SyncSummary> {
|
||||
const pageSize = options.pageSize ?? 100
|
||||
const maxPages = options.maxPages ?? 1000
|
||||
|
||||
const summary: SyncSummary = {
|
||||
total: 0,
|
||||
fetched: 0,
|
||||
enqueued: 0,
|
||||
skipped: 0,
|
||||
pages: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
let skip = 0
|
||||
|
||||
while (summary.pages < maxPages) {
|
||||
const page = await ErpService.getProductsForSync({
|
||||
limit: pageSize,
|
||||
skip,
|
||||
order: '',
|
||||
where: { sku: '', warehouse: '', condition: '' },
|
||||
})
|
||||
|
||||
summary.pages++
|
||||
if (Number.isFinite(page.total)) summary.total = page.total
|
||||
|
||||
// Điều kiện dừng TIN CẬY: ERP hết dữ liệu (trả trang rỗng).
|
||||
if (page.items.length === 0) break
|
||||
|
||||
summary.fetched += page.items.length
|
||||
|
||||
// Bỏ qua item rác không có sku (sku rỗng gây trùng unique key -> lỗi insert).
|
||||
const valid = page.items.filter((i) => i.sku && i.sku.trim() !== '')
|
||||
summary.skipped += page.items.length - valid.length
|
||||
|
||||
if (valid.length > 0) {
|
||||
// Fan-out: mỗi sản phẩm 1 job upsert. BullMQ lo retry khi lỗi.
|
||||
await enqueueProductUpserts(valid, username)
|
||||
summary.enqueued += valid.length
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ skip, fetched: page.items.length, valid: valid.length, reportedTotal: page.total },
|
||||
'ERP page đã enqueue'
|
||||
)
|
||||
skip += pageSize
|
||||
}
|
||||
|
||||
if (summary.pages >= maxPages) {
|
||||
logger.warn({ maxPages }, 'Sync ERP chạm trần maxPages — có thể chưa quét hết, kiểm tra lại')
|
||||
}
|
||||
|
||||
summary.finishedAt = new Date().toISOString()
|
||||
|
||||
await LogService.record({
|
||||
username,
|
||||
actionName: 'Đồng bộ ERP',
|
||||
action: 'sync',
|
||||
meta: summary,
|
||||
})
|
||||
|
||||
logger.info(summary, 'Sync ERP: đã enqueue toàn bộ sản phẩm')
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Xử lý 1 job upsert (gọi bởi worker).
|
||||
* Cố tình ném lỗi khi thất bại để BullMQ tự retry job.
|
||||
*/
|
||||
static async upsertProduct(item: ErpProductItem): Promise<{ sku: string; condition: string; created: boolean }> {
|
||||
const { created } = await ProductService.upsert(item)
|
||||
return { sku: item.sku, condition: item.condition, created }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
export const registerValidator = vine.compile(
|
||||
vine.object({
|
||||
username: vine.string().trim().minLength(3).maxLength(100),
|
||||
password: vine.string().minLength(6).maxLength(180),
|
||||
})
|
||||
)
|
||||
|
||||
export const loginValidator = vine.compile(
|
||||
vine.object({
|
||||
username: vine.string().trim(),
|
||||
password: vine.string(),
|
||||
})
|
||||
)
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import vine from '@vinejs/vine'
|
||||
|
||||
const base = {
|
||||
sku: vine.string().trim().maxLength(191),
|
||||
condition: vine.string().trim().maxLength(50),
|
||||
qty: vine.number().min(0),
|
||||
price: vine.number().min(0),
|
||||
costs: vine.array(
|
||||
vine.object({
|
||||
currency: vine.string().trim().maxLength(10),
|
||||
price: vine.number().min(0),
|
||||
})
|
||||
).optional().nullable(),
|
||||
aiPrice: vine.number().min(0).optional().nullable(),
|
||||
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
||||
}
|
||||
|
||||
export const createProductValidator = vine.compile(vine.object(base))
|
||||
|
||||
export const updateProductValidator = vine.compile(
|
||||
vine.object({
|
||||
sku: vine.string().trim().maxLength(191).optional(),
|
||||
condition: vine.string().trim().maxLength(50).optional(),
|
||||
qty: vine.number().min(0).optional(),
|
||||
price: vine.number().min(0).optional(),
|
||||
costs: vine.array(
|
||||
vine.object({
|
||||
currency: vine.string().trim().maxLength(10),
|
||||
price: vine.number().min(0),
|
||||
})
|
||||
).optional().nullable(),
|
||||
aiPrice: vine.number().min(0).optional().nullable(),
|
||||
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||
erpId: vine.string().trim().maxLength(191).optional().nullable(),
|
||||
})
|
||||
)
|
||||
|
||||
export const listProductValidator = vine.compile(
|
||||
vine.object({
|
||||
page: vine.number().min(1).optional(),
|
||||
perPage: vine.number().min(1).max(200).optional(),
|
||||
sku: vine.string().trim(),
|
||||
condition: vine.string().trim(),
|
||||
warehouse: vine.string().trim().optional(),
|
||||
type: vine.string().trim().optional(),
|
||||
order: vine.string().trim().optional(),
|
||||
direction: vine.string().trim().optional(),
|
||||
})
|
||||
)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Ace console entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.ace()
|
||||
.handle(process.argv.splice(2))
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP server entrypoint
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Boots the AdonisJS application and starts the HTTP server.
|
||||
|
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { Ignitor, prettyPrintError } from '@adonisjs/core'
|
||||
|
||||
const APP_ROOT = new URL('../', import.meta.url)
|
||||
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
.tap((app) => {
|
||||
app.booting(async () => {
|
||||
await import('#start/env')
|
||||
})
|
||||
app.listen('SIGTERM', () => app.terminate())
|
||||
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
|
||||
})
|
||||
.httpServer()
|
||||
.start()
|
||||
.catch((error) => {
|
||||
process.exitCode = 1
|
||||
prettyPrintError(error)
|
||||
})
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { BaseCommand, args } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
|
||||
/**
|
||||
* Tạo nhanh user đăng nhập.
|
||||
* Chạy: `node ace make:user admin secret123`
|
||||
*/
|
||||
export default class MakeUser extends BaseCommand {
|
||||
static commandName = 'make:user'
|
||||
static description = 'Tạo user mới (username, password)'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@args.string({ description: 'Username' })
|
||||
declare username: string
|
||||
|
||||
@args.string({ description: 'Password' })
|
||||
declare password: string
|
||||
|
||||
async run() {
|
||||
const { default: User } = await import('#models/user')
|
||||
const user = await User.create({ username: this.username, password: this.password })
|
||||
this.logger.success(`Đã tạo user #${user.id} (${user.username})`)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { BaseCommand } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { Worker } from 'bullmq'
|
||||
import { QUEUE_NAMES, JOB_NAMES, redisConnection, upsertSyncScheduler } from '#services/queue_service'
|
||||
import env from '#start/env'
|
||||
import PricingService from '#services/pricing_service'
|
||||
import SyncService from '#services/sync_service'
|
||||
|
||||
/**
|
||||
* Worker xử lý job nền (pricing batch, sync ERP).
|
||||
* Chạy: `node ace queue:work` (process riêng với HTTP server).
|
||||
*/
|
||||
export default class QueueWork extends BaseCommand {
|
||||
static commandName = 'queue:work'
|
||||
static description = 'Khởi động worker xử lý queue (pricing, sync, import)'
|
||||
static options: CommandOptions = { startApp: true, staysAlive: true }
|
||||
|
||||
async run() {
|
||||
const concurrency = 5
|
||||
this.logger.info('Queue worker đang chạy...')
|
||||
|
||||
// Đăng ký cron sync ERP hằng ngày (idempotent) — luôn active khi worker chạy.
|
||||
const cronPattern = env.get('SYNC_CRON', '0 2 * * *')
|
||||
const cronTz = env.get('SYNC_TZ', 'Australia/Sydney')
|
||||
await upsertSyncScheduler(cronPattern, cronTz, 'cron')
|
||||
this.logger.info(`Cron sync ERP: "${cronPattern}" (tz: ${cronTz})`)
|
||||
|
||||
const pricingWorker = new Worker(
|
||||
QUEUE_NAMES.pricing,
|
||||
async (job) => {
|
||||
if (job.name === 'suggest') {
|
||||
const { productId, username } = job.data
|
||||
return PricingService.suggestForProduct(productId, username)
|
||||
}
|
||||
},
|
||||
{ connection: redisConnection, concurrency }
|
||||
)
|
||||
|
||||
// Orchestrator: quét ERP rồi fan-out job upsert. Để concurrency 1 (1 lần sync).
|
||||
const syncWorker = new Worker(
|
||||
QUEUE_NAMES.sync,
|
||||
async (job) => {
|
||||
if (job.name === JOB_NAMES.erpSync) {
|
||||
return SyncService.syncFromErp(job.data?.username)
|
||||
}
|
||||
},
|
||||
{ connection: redisConnection, concurrency: 1 }
|
||||
)
|
||||
|
||||
// Worker upsert từng sản phẩm — chạy song song để xử lý nhanh khối lượng lớn.
|
||||
// Job lỗi sẽ được BullMQ tự retry (attempts + backoff) -> sync lại sau cùng.
|
||||
const productWorker = new Worker(
|
||||
QUEUE_NAMES.product,
|
||||
async (job) => {
|
||||
if (job.name === JOB_NAMES.upsertProduct) {
|
||||
return SyncService.upsertProduct(job.data?.item)
|
||||
}
|
||||
},
|
||||
{ connection: redisConnection, concurrency: 10 }
|
||||
)
|
||||
|
||||
for (const [name, w] of [
|
||||
['pricing', pricingWorker],
|
||||
['sync', syncWorker],
|
||||
['product', productWorker],
|
||||
] as const) {
|
||||
w.on('completed', (job) => this.logger.info(`[${name}] job ${job.id} done`))
|
||||
w.on('failed', (job, err) => this.logger.error(`[${name}] job ${job?.id} failed: ${err.message}`))
|
||||
}
|
||||
|
||||
// Giữ process sống cho tới khi nhận tín hiệu tắt
|
||||
await new Promise<void>((resolve) => {
|
||||
app.terminating(async () => {
|
||||
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
||||
await Promise.all([
|
||||
pricingWorker.close(),
|
||||
syncWorker.close(),
|
||||
productWorker.close(),
|
||||
EbayScraperService.close(),
|
||||
])
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { BaseCommand, flags } from '@adonisjs/core/ace'
|
||||
import type { CommandOptions } from '@adonisjs/core/types/ace'
|
||||
import env from '#start/env'
|
||||
|
||||
/**
|
||||
* Trigger đồng bộ sản phẩm từ ERP qua queue.
|
||||
*
|
||||
* node ace sync:erp # đẩy 1 job sync NGAY (worker xử lý)
|
||||
* node ace sync:erp --schedule # tạo/cập nhật cron sync hằng ngày
|
||||
* node ace sync:erp --unschedule # gỡ cron
|
||||
* node ace sync:erp --schedule --pattern "0 3 * * *" --tz "Australia/Sydney"
|
||||
*
|
||||
* Lưu ý: việc upsert thực tế do worker `node ace queue:work` đảm nhận,
|
||||
* command này chỉ đưa job vào hàng đợi.
|
||||
*/
|
||||
export default class SyncErp extends BaseCommand {
|
||||
static commandName = 'sync:erp'
|
||||
static description = 'Đồng bộ ERP: đẩy job sync ngay, hoặc tạo/gỡ cron hằng ngày'
|
||||
static options: CommandOptions = { startApp: true }
|
||||
|
||||
@flags.boolean({ description: 'Tạo/cập nhật cron sync hằng ngày thay vì chạy ngay' })
|
||||
declare schedule: boolean
|
||||
|
||||
@flags.boolean({ description: 'Gỡ cron sync hằng ngày' })
|
||||
declare unschedule: boolean
|
||||
|
||||
@flags.string({ description: 'Cron pattern (mặc định lấy từ SYNC_CRON hoặc "0 2 * * *")' })
|
||||
declare pattern: string
|
||||
|
||||
@flags.string({ description: 'Timezone cho cron (mặc định SYNC_TZ hoặc "Australia/Sydney")' })
|
||||
declare tz: string
|
||||
|
||||
async run() {
|
||||
const { upsertSyncScheduler, removeSyncScheduler, enqueueSync, closeQueues } = await import(
|
||||
'#services/queue_service'
|
||||
)
|
||||
|
||||
try {
|
||||
if (this.unschedule) {
|
||||
await removeSyncScheduler()
|
||||
this.logger.success('Đã gỡ cron sync ERP hằng ngày')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.schedule) {
|
||||
const pattern = this.pattern ?? env.get('SYNC_CRON', '0 2 * * *')
|
||||
const tz = this.tz ?? env.get('SYNC_TZ', 'Australia/Sydney')
|
||||
await upsertSyncScheduler(pattern, tz, 'cron')
|
||||
this.logger.success(`Đã đặt cron sync ERP: "${pattern}" (tz: ${tz})`)
|
||||
return
|
||||
}
|
||||
|
||||
// Mặc định: đẩy 1 job sync ngay.
|
||||
const job = await enqueueSync('cli')
|
||||
this.logger.success(`Đã đẩy job sync ERP #${job.id} vào queue. Worker sẽ xử lý.`)
|
||||
} finally {
|
||||
// Đóng kết nối Redis để command thoát sạch.
|
||||
await closeQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { Secret } from '@adonisjs/core/helpers'
|
||||
import { defineConfig } from '@adonisjs/core/http'
|
||||
|
||||
export const appKey = new Secret(env.get('APP_KEY'))
|
||||
|
||||
export const http = defineConfig({
|
||||
generateRequestId: true,
|
||||
allowMethodSpoofing: false,
|
||||
useAsyncLocalStorage: false,
|
||||
cookie: {
|
||||
domain: '',
|
||||
path: '/',
|
||||
maxAge: '2h',
|
||||
httpOnly: true,
|
||||
secure: app.inProduction,
|
||||
sameSite: 'lax',
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { defineConfig } from '@adonisjs/auth'
|
||||
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
|
||||
import type { InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
|
||||
|
||||
const authConfig = defineConfig({
|
||||
default: 'api',
|
||||
guards: {
|
||||
api: tokensGuard({
|
||||
provider: tokensUserProvider({
|
||||
tokens: 'accessTokens',
|
||||
model: () => import('#models/user'),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default authConfig
|
||||
|
||||
declare module '@adonisjs/auth/types' {
|
||||
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
|
||||
}
|
||||
declare module '@adonisjs/core/types' {
|
||||
interface EventsList extends InferAuthEvents<Authenticators> {}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { defineConfig } from '@adonisjs/core/bodyparser'
|
||||
|
||||
const bodyParserConfig = defineConfig({
|
||||
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
|
||||
form: {
|
||||
convertEmptyStringsToNull: true,
|
||||
types: ['application/x-www-form-urlencoded'],
|
||||
},
|
||||
|
||||
json: {
|
||||
convertEmptyStringsToNull: true,
|
||||
types: [
|
||||
'application/json',
|
||||
'application/json-patch+json',
|
||||
'application/vnd.api+json',
|
||||
'application/csp-report',
|
||||
],
|
||||
},
|
||||
|
||||
multipart: {
|
||||
autoProcess: true,
|
||||
convertEmptyStringsToNull: true,
|
||||
processManually: [],
|
||||
limit: '20mb',
|
||||
types: ['multipart/form-data'],
|
||||
},
|
||||
})
|
||||
|
||||
export default bodyParserConfig
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from '@adonisjs/cors'
|
||||
|
||||
const corsConfig = defineConfig({
|
||||
enabled: true,
|
||||
origin: true,
|
||||
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
headers: true,
|
||||
exposeHeaders: [],
|
||||
credentials: true,
|
||||
maxAge: 90,
|
||||
})
|
||||
|
||||
export default corsConfig
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import env from '#start/env'
|
||||
import { defineConfig } from '@adonisjs/lucid'
|
||||
|
||||
const dbConfig = defineConfig({
|
||||
connection: 'mysql',
|
||||
connections: {
|
||||
mysql: {
|
||||
client: 'mysql2',
|
||||
connection: {
|
||||
host: env.get('DB_HOST'),
|
||||
port: env.get('DB_PORT'),
|
||||
user: env.get('DB_USER'),
|
||||
password: env.get('DB_PASSWORD'),
|
||||
database: env.get('DB_DATABASE'),
|
||||
},
|
||||
pool: {
|
||||
min: 2,
|
||||
max: 10,
|
||||
// MariaDB/MySQL mặc định bật NO_ZERO_DATE (strict) → cột timestamp not-null
|
||||
// không có default sẽ lỗi "Invalid default value". Nới sql_mode cho mỗi kết nối.
|
||||
afterCreate: (conn: any, done: (err: Error | null, conn: any) => void) => {
|
||||
conn.query("SET SESSION sql_mode='NO_ENGINE_SUBSTITUTION'", (err: Error | null) =>
|
||||
done(err, conn)
|
||||
)
|
||||
},
|
||||
},
|
||||
migrations: {
|
||||
naturalSort: true,
|
||||
paths: ['database/migrations'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default dbConfig
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig, drivers } from '@adonisjs/core/hash'
|
||||
|
||||
const hashConfig = defineConfig({
|
||||
default: 'scrypt',
|
||||
list: {
|
||||
scrypt: drivers.scrypt({
|
||||
cost: 16384,
|
||||
blockSize: 8,
|
||||
parallelization: 1,
|
||||
maxMemory: 33554432,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
export default hashConfig
|
||||
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface HashersList extends InferHashers<typeof hashConfig> {}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import env from '#start/env'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import { defineConfig, targets } from '@adonisjs/core/logger'
|
||||
|
||||
const loggerConfig = defineConfig({
|
||||
default: 'app',
|
||||
loggers: {
|
||||
app: {
|
||||
enabled: true,
|
||||
name: 'suggestprice',
|
||||
level: env.get('LOG_LEVEL', 'info'),
|
||||
transport: {
|
||||
targets: targets()
|
||||
.pushIf(!app.inProduction, targets.pretty())
|
||||
.pushIf(app.inProduction, targets.file({ destination: 1 }))
|
||||
.toArray(),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export default loggerConfig
|
||||
|
||||
declare module '@adonisjs/core/types' {
|
||||
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'users'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').notNullable()
|
||||
table.string('username', 100).notNullable().unique()
|
||||
table.string('password').notNullable()
|
||||
table.timestamp('created_at').notNullable()
|
||||
table.timestamp('updated_at').nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'auth_access_tokens'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id')
|
||||
table
|
||||
.integer('tokenable_id')
|
||||
.notNullable()
|
||||
.unsigned()
|
||||
.references('id')
|
||||
.inTable('users')
|
||||
.onDelete('CASCADE')
|
||||
|
||||
table.string('type').notNullable()
|
||||
table.string('name').nullable()
|
||||
table.string('hash').notNullable()
|
||||
table.text('abilities').notNullable()
|
||||
table.timestamp('created_at')
|
||||
table.timestamp('updated_at')
|
||||
table.timestamp('last_used_at').nullable()
|
||||
table.timestamp('expires_at').nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'products'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').notNullable()
|
||||
table.string('sku', 191).notNullable()
|
||||
table.string('condition', 50).notNullable()
|
||||
table.integer('qty').notNullable().defaultTo(0)
|
||||
table.decimal('price', 12, 2).notNullable().defaultTo(0)
|
||||
table.decimal('ai_price', 12, 2).nullable()
|
||||
table.json('costs').nullable()
|
||||
table.string('package_contain', 255).nullable()
|
||||
table.string('type', 100).nullable()
|
||||
table.string('erp_id', 191).nullable()
|
||||
table.timestamp('created_at').notNullable()
|
||||
table.timestamp('updated_at').notNullable()
|
||||
|
||||
// Một SKU có thể có nhiều condition -> unique theo (sku, condition)
|
||||
table.unique(['sku', 'condition'])
|
||||
// ERP id duy nhất khi có (sync idempotent)
|
||||
table.index(['erp_id'], 'products_erp_id_index')
|
||||
})
|
||||
|
||||
// Full-text search theo sku + type (MySQL 8 / MariaDB 10.5+)
|
||||
this.schema.raw(
|
||||
'ALTER TABLE `products` ADD FULLTEXT `products_fulltext` (`sku`, `type`)'
|
||||
)
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'logs'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').notNullable()
|
||||
table.string('username', 100).notNullable()
|
||||
table.string('action_name', 191).notNullable()
|
||||
table.string('action', 50).notNullable()
|
||||
table
|
||||
.integer('product_id')
|
||||
.unsigned()
|
||||
.nullable()
|
||||
.references('id')
|
||||
.inTable('products')
|
||||
.onDelete('SET NULL')
|
||||
table.json('meta').nullable()
|
||||
table.timestamp('time').notNullable()
|
||||
|
||||
table.index(['product_id'])
|
||||
table.index(['action'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'histories'
|
||||
|
||||
async up() {
|
||||
this.schema.createTable(this.tableName, (table) => {
|
||||
table.increments('id').notNullable()
|
||||
table.string('username', 100).notNullable()
|
||||
table
|
||||
.integer('product_id')
|
||||
.unsigned()
|
||||
.notNullable()
|
||||
.references('id')
|
||||
.inTable('products')
|
||||
.onDelete('CASCADE')
|
||||
table.json('data_sources').notNullable()
|
||||
table.json('data_ebay').notNullable()
|
||||
table.json('ai_result').nullable()
|
||||
table.timestamp('time').notNullable()
|
||||
|
||||
table.index(['product_id'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.dropTable(this.tableName)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'users'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('first_name', 100).nullable()
|
||||
table.string('last_name', 100).nullable()
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('first_name')
|
||||
table.dropColumn('last_name')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'products'
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.string('warehouse')
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('warehouse')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { BaseSchema } from '@adonisjs/lucid/schema'
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'products'
|
||||
|
||||
async up() {
|
||||
// Backfill warehouse trống -> 'AU' để unique key ổn định.
|
||||
this.defer(async (db) => {
|
||||
await db.from(this.tableName).whereNull('warehouse').update({ warehouse: 'AU' })
|
||||
})
|
||||
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
// Bỏ unique cũ (sku, condition).
|
||||
table.dropUnique(['sku', 'condition'])
|
||||
// Key mới gồm warehouse: cùng 1 sku+condition có thể tồn ở nhiều kho.
|
||||
table.unique(['sku', 'condition', 'warehouse'])
|
||||
})
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropUnique(['sku', 'condition', 'warehouse'])
|
||||
table.unique(['sku', 'condition'])
|
||||
})
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "suggestprice-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"start": "node bin/server.js",
|
||||
"build": "node ace build",
|
||||
"dev": "node ace serve --hmr",
|
||||
"worker": "node ace queue:work",
|
||||
"test": "node ace test",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"imports": {
|
||||
"#controllers/*": "./app/controllers/*.js",
|
||||
"#models/*": "./app/models/*.js",
|
||||
"#services/*": "./app/services/*.js",
|
||||
"#validators/*": "./app/validators/*.js",
|
||||
"#middleware/*": "./app/middleware/*.js",
|
||||
"#exceptions/*": "./app/exceptions/*.js",
|
||||
"#providers/*": "./providers/*.js",
|
||||
"#start/*": "./start/*.js",
|
||||
"#config/*": "./config/*.js",
|
||||
"#database/*": "./database/*.js",
|
||||
"#commands/*": "./commands/*.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adonisjs/auth": "^9.2.3",
|
||||
"@adonisjs/core": "^6.14.0",
|
||||
"@adonisjs/cors": "^2.2.1",
|
||||
"@adonisjs/lucid": "^21.3.0",
|
||||
"@vinejs/vine": "^2.1.0",
|
||||
"axios": "^1.18.1",
|
||||
"bullmq": "^5.12.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"luxon": "^3.5.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"openai": "^4.56.0",
|
||||
"puppeteer": "^24.43.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@adonisjs/assembler": "^7.8.0",
|
||||
"@adonisjs/tsconfig": "^1.4.0",
|
||||
"@swc/core": "^1.7.0",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.5.0",
|
||||
"hot-hook": "^0.4.0",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"ts-node-maintained": "^10.9.4",
|
||||
"typescript": "~5.5"
|
||||
},
|
||||
"hotHook": {
|
||||
"boundaries": [
|
||||
"./app/controllers/**/*.ts",
|
||||
"./app/middleware/*.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { Env } from '@adonisjs/core/env'
|
||||
|
||||
export default await Env.create(new URL('../', import.meta.url), {
|
||||
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
|
||||
PORT: Env.schema.number(),
|
||||
APP_KEY: Env.schema.string(),
|
||||
HOST: Env.schema.string({ format: 'host' }),
|
||||
LOG_LEVEL: Env.schema.string.optional(),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Database (MySQL)
|
||||
|----------------------------------------------------------
|
||||
*/
|
||||
DB_HOST: Env.schema.string({ format: 'host' }),
|
||||
DB_PORT: Env.schema.number(),
|
||||
DB_USER: Env.schema.string(),
|
||||
DB_PASSWORD: Env.schema.string.optional(),
|
||||
DB_DATABASE: Env.schema.string(),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Redis (queue / BullMQ)
|
||||
|----------------------------------------------------------
|
||||
*/
|
||||
REDIS_HOST: Env.schema.string({ format: 'host' }),
|
||||
REDIS_PORT: Env.schema.number(),
|
||||
REDIS_PASSWORD: Env.schema.string.optional(),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Sync ERP định kỳ (cron cho BullMQ job scheduler)
|
||||
|----------------------------------------------------------
|
||||
| SYNC_CRON: biểu thức cron (mặc định 2h sáng mỗi ngày).
|
||||
| SYNC_TZ: timezone áp cho cron (mặc định Australia/Sydney).
|
||||
*/
|
||||
SYNC_CRON: Env.schema.string.optional(),
|
||||
SYNC_TZ: Env.schema.string.optional(),
|
||||
|
||||
/*
|
||||
|----------------------------------------------------------
|
||||
| Pricing engine / external services
|
||||
|----------------------------------------------------------
|
||||
*/
|
||||
OPENAI_API_KEY: Env.schema.string.optional(),
|
||||
OPENAI_MODEL: Env.schema.string.optional(),
|
||||
PRICING_AUTO_APPLY_THRESHOLD_PCT: Env.schema.number.optional(),
|
||||
PRICING_FLOOR_MARKUP: Env.schema.number.optional(),
|
||||
|
||||
// ERP (sync service — API hoàn thiện sau)
|
||||
ERP_API_URL: Env.schema.string.optional(),
|
||||
ERP_API_KEY: Env.schema.string.optional(),
|
||||
|
||||
// eBay
|
||||
EBAY_CLIENT_ID: Env.schema.string.optional(),
|
||||
EBAY_CLIENT_SECRET: Env.schema.string.optional(),
|
||||
})
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import router from '@adonisjs/core/services/router'
|
||||
import server from '@adonisjs/core/services/server'
|
||||
|
||||
/**
|
||||
* The error handler is used to convert an exception to a HTTP response.
|
||||
*/
|
||||
server.errorHandler(() => import('#exceptions/handler'))
|
||||
|
||||
/**
|
||||
* Server middleware run on all requests (even unregistered routes).
|
||||
*/
|
||||
server.use([
|
||||
() => import('#middleware/container_bindings_middleware'),
|
||||
() => import('@adonisjs/cors/cors_middleware'),
|
||||
() => import('@adonisjs/core/bodyparser_middleware'),
|
||||
])
|
||||
|
||||
/**
|
||||
* Router middleware run only on routes that match a registered route.
|
||||
*/
|
||||
router.use([
|
||||
() => import('@adonisjs/auth/initialize_auth_middleware'),
|
||||
])
|
||||
|
||||
/**
|
||||
* Named middleware available to assign to routes.
|
||||
*/
|
||||
export const middleware = router.named({
|
||||
auth: () => import('#middleware/auth_middleware'),
|
||||
})
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import router from '@adonisjs/core/services/router'
|
||||
import { middleware } from '#start/kernel'
|
||||
|
||||
const AuthController = () => import('#controllers/auth_controller')
|
||||
const ProductsController = () => import('#controllers/products_controller')
|
||||
const ImportsController = () => import('#controllers/imports_controller')
|
||||
const PricingController = () => import('#controllers/pricing_controller')
|
||||
const LogsController = () => import('#controllers/logs_controller')
|
||||
const HistoriesController = () => import('#controllers/histories_controller')
|
||||
|
||||
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
|
||||
router.get('/api/health', async () => ({ ok: true }))
|
||||
router.get('/api/products/syncProductBySku', [ProductsController, 'syncProductBySku'])
|
||||
router.get('/api/products', [ProductsController, 'index'])
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
// --- Auth ---
|
||||
router.post('/auth/register', [AuthController, 'register'])
|
||||
router.post('/auth/login', [AuthController, 'login'])
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.post('/auth/logout', [AuthController, 'logout'])
|
||||
router.get('/auth/me', [AuthController, 'me'])
|
||||
|
||||
// --- Products (CRUD: manual + sync) ---
|
||||
|
||||
router.post('/products', [ProductsController, 'store'])
|
||||
router.get('/products/:id', [ProductsController, 'show'])
|
||||
router.patch('/products/:id', [ProductsController, 'update'])
|
||||
router.delete('/products/:id', [ProductsController, 'destroy'])
|
||||
|
||||
// --- Import Excel ---
|
||||
router.post('/imports/products', [ImportsController, 'products'])
|
||||
|
||||
// --- Pricing (service gợi ý giá) ---
|
||||
router.post('/pricing/suggest/:id', [PricingController, 'suggest'])
|
||||
router.post('/pricing/suggest/:id/approve', [PricingController, 'approve'])
|
||||
router.post('/pricing/batch', [PricingController, 'batch'])
|
||||
|
||||
// --- Log & History ---
|
||||
router.get('/logs', [LogsController, 'index'])
|
||||
router.get('/histories', [HistoriesController, 'index'])
|
||||
router.get('/histories/:id', [HistoriesController, 'show'])
|
||||
})
|
||||
.use(middleware.auth())
|
||||
})
|
||||
.prefix('/api')
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { Ignitor } from '@adonisjs/core'
|
||||
|
||||
const APP_ROOT = new URL('../../', import.meta.url)
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
const ignitor = new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
const app = ignitor.createApp('web')
|
||||
await app.init()
|
||||
await app.boot()
|
||||
|
||||
const ErpService = (await import('#services/erp_service')).default
|
||||
const AiService = (await import('#services/ai_service')).default
|
||||
const Product = (await import('#models/product')).default
|
||||
const EbayService = (await import('#services/ebay_service')).default
|
||||
|
||||
async function runSyncServiceTest() {
|
||||
const product = await Product.query().first()
|
||||
if (!product) {
|
||||
throw new Error('Không có sản phẩm nào trong database để test AI suggest')
|
||||
}
|
||||
console.log(Date.now())
|
||||
console.log(`Testing AI suggest for product SKU=${product.sku}, condition=${product.condition}`)
|
||||
const dataSources = await ErpService.getSupplierPricing(product)
|
||||
console.log("dataSources", dataSources.length)
|
||||
const dataEbay = await EbayService.getMarketData(product)
|
||||
console.log("dataEbay", dataEbay.sale.length, dataEbay.sold.length)
|
||||
const result = await AiService.suggest({ dataEbay, dataSources, product })
|
||||
|
||||
assert.ok(result, 'Không nhận được dữ liệu từ AI suggest')
|
||||
console.log(result)
|
||||
console.log(Date.now())
|
||||
}
|
||||
|
||||
await runSyncServiceTest()
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { Ignitor } from '@adonisjs/core'
|
||||
|
||||
const APP_ROOT = new URL('../../', import.meta.url)
|
||||
const IMPORTER = (filePath: string) => {
|
||||
if (filePath.startsWith('./') || filePath.startsWith('../')) {
|
||||
return import(new URL(filePath, APP_ROOT).href)
|
||||
}
|
||||
return import(filePath)
|
||||
}
|
||||
|
||||
const ignitor = new Ignitor(APP_ROOT, { importer: IMPORTER })
|
||||
const app = ignitor.createApp('web')
|
||||
await app.init()
|
||||
await app.boot()
|
||||
|
||||
const SyncService = (await import('#services/sync_service')).default
|
||||
|
||||
async function runSyncServiceTest() {
|
||||
const startedAt = Date.now()
|
||||
// syncFromErp giờ là orchestrator: chỉ quét ERP và enqueue job upsert lên BullMQ.
|
||||
// Việc upsert thực tế (và retry khi lỗi) do worker `node ace queue:work` xử lý.
|
||||
const summary = await SyncService.syncFromErp('tester', { pageSize: 100 })
|
||||
console.log(`Enqueued ${summary.enqueued}/${summary.total} sản phẩm trong ${Date.now() - startedAt}ms`, summary)
|
||||
}
|
||||
|
||||
await runSyncServiceTest()
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./",
|
||||
"outDir": "./build",
|
||||
"paths": {
|
||||
"#controllers/*": ["./app/controllers/*.js"],
|
||||
"#models/*": ["./app/models/*.js"],
|
||||
"#services/*": ["./app/services/*.js"],
|
||||
"#validators/*": ["./app/validators/*.js"],
|
||||
"#middleware/*": ["./app/middleware/*.js"],
|
||||
"#exceptions/*": ["./app/exceptions/*.js"],
|
||||
"#providers/*": ["./providers/*.js"],
|
||||
"#start/*": ["./start/*.js"],
|
||||
"#config/*": ["./config/*.js"],
|
||||
"#database/*": ["./database/*.js"],
|
||||
"#commands/*": ["./commands/*.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>Listing - Suggest Price</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "suggestprice-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.12.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import { useState } from 'react';
|
||||
import ProductTablePanel from './components/ProductTablePanel.jsx';
|
||||
import ProductFormPanel from './components/ProductFormPanel.jsx';
|
||||
import FeedPanel from './components/FeedPanel.jsx';
|
||||
|
||||
const initialErpProducts = [
|
||||
{ id: 1, sku: 'ERP-001', title: 'Apple iPhone 15', category: 'Phone', price: 999, status: 'Active' },
|
||||
{ id: 2, sku: 'ERP-002', title: 'Samsung Galaxy S24', category: 'Phone', price: 899, status: 'Active' },
|
||||
{ id: 3, sku: 'ERP-003', title: 'Sony WH-1000XM5', category: 'Audio', price: 349, status: 'Draft' },
|
||||
];
|
||||
|
||||
const initialManualProducts = [
|
||||
{ id: 10, sku: 'MAN-001', title: 'Dell XPS 13', category: 'Laptop', price: 1299, status: 'Listed' },
|
||||
{ id: 11, sku: 'MAN-002', title: 'Logitech MX Master 3', category: 'Accessory', price: 99, status: 'Draft' },
|
||||
];
|
||||
|
||||
const initialFeed = [
|
||||
{ id: 1, message: 'Add sản phẩm tên ABCD bị trùng', type: 'warning' },
|
||||
{ id: 2, message: 'List thành công sản phẩm XYZ', type: 'success' },
|
||||
];
|
||||
|
||||
const initialForm = {
|
||||
sku: '',
|
||||
title: '',
|
||||
category: '',
|
||||
price: '',
|
||||
status: 'Draft',
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const [isLoggedIn, setIsLoggedIn] = useState(true);
|
||||
const [currentUser, setCurrentUser] = useState('Nguyễn Văn A');
|
||||
const [erpProducts, setErpProducts] = useState(initialErpProducts);
|
||||
const [manualProducts, setManualProducts] = useState(initialManualProducts);
|
||||
const [feedEntries, setFeedEntries] = useState(initialFeed);
|
||||
const [formMode, setFormMode] = useState('add');
|
||||
const [form, setForm] = useState(initialForm);
|
||||
|
||||
function resetForm() {
|
||||
setFormMode('add');
|
||||
setForm(initialForm);
|
||||
}
|
||||
|
||||
function handleSelectProduct(product, source) {
|
||||
setFormMode('edit');
|
||||
setForm({
|
||||
sku: product.sku,
|
||||
title: product.title,
|
||||
category: product.category,
|
||||
price: product.price,
|
||||
status: product.status,
|
||||
});
|
||||
|
||||
if (source === 'erp') {
|
||||
setFeedEntries((prev) => [
|
||||
{ id: Date.now(), message: `Đã chọn ERP product ${product.title} để chỉnh sửa`, type: 'info' },
|
||||
...prev,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleImportFromErp() {
|
||||
const selected = erpProducts[0];
|
||||
if (!selected) return;
|
||||
setForm({
|
||||
sku: selected.sku,
|
||||
title: selected.title,
|
||||
category: selected.category,
|
||||
price: selected.price,
|
||||
status: 'Draft',
|
||||
});
|
||||
setFeedEntries((prev) => [
|
||||
{ id: Date.now(), message: `Import sản phẩm ${selected.title} từ ERP`, type: 'info' },
|
||||
...prev,
|
||||
]);
|
||||
}
|
||||
|
||||
function handleChange(event) {
|
||||
const { name, value } = event.target;
|
||||
setForm((prev) => ({ ...prev, [name]: value }));
|
||||
}
|
||||
|
||||
function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
|
||||
if (formMode === 'add') {
|
||||
const newProduct = {
|
||||
id: Date.now(),
|
||||
sku: form.sku || `MAN-${Date.now()}`,
|
||||
title: form.title,
|
||||
category: form.category,
|
||||
price: Number(form.price) || 0,
|
||||
status: form.status,
|
||||
};
|
||||
|
||||
setManualProducts((prev) => [newProduct, ...prev]);
|
||||
setFeedEntries((prev) => [
|
||||
{ id: Date.now(), message: `Add sản phẩm ${newProduct.title} thành công`, type: 'success' },
|
||||
...prev,
|
||||
]);
|
||||
} else {
|
||||
setManualProducts((prev) =>
|
||||
prev.map((item) => (item.sku === form.sku ? { ...item, ...form, price: Number(form.price) || 0 } : item))
|
||||
);
|
||||
setFeedEntries((prev) => [
|
||||
{ id: Date.now(), message: `Cập nhật sản phẩm ${form.title} thành công`, type: 'success' },
|
||||
...prev,
|
||||
]);
|
||||
}
|
||||
|
||||
resetForm();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div className="topbar-left">
|
||||
{isLoggedIn ? (
|
||||
<span className="user-pill">Hi, {currentUser}</span>
|
||||
) : (
|
||||
<span className="user-pill">Chưa đăng nhập</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="page-title">Listing - Suggest Price</div>
|
||||
|
||||
<div className="topbar-actions">
|
||||
{isLoggedIn ? (
|
||||
<button type="button" className="secondary" onClick={() => setIsLoggedIn(false)}>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={() => setIsLoggedIn(true)}>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="dashboard-grid">
|
||||
<ProductTablePanel
|
||||
title="Product ERP"
|
||||
badge="Source"
|
||||
products={erpProducts}
|
||||
onSelect={(product) => handleSelectProduct(product, 'erp')}
|
||||
/>
|
||||
|
||||
<ProductTablePanel
|
||||
title="Product Manual"
|
||||
badge="Local"
|
||||
products={manualProducts}
|
||||
onSelect={(product) => handleSelectProduct(product, 'manual')}
|
||||
/>
|
||||
|
||||
<ProductFormPanel
|
||||
formMode={formMode}
|
||||
form={form}
|
||||
onChange={handleChange}
|
||||
onSubmit={handleSubmit}
|
||||
onImport={handleImportFromErp}
|
||||
onClear={resetForm}
|
||||
/>
|
||||
|
||||
<FeedPanel entries={feedEntries} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export default function AiResult({ ai }) {
|
||||
if (!ai) return null;
|
||||
return (
|
||||
<div className="ai-card">
|
||||
<div>
|
||||
<span className="ai-price">${ai.suggestedPrice}</span>
|
||||
{ai._mock && <span className="mock-badge">MOCK</span>}
|
||||
</div>
|
||||
<div className="ai-range">
|
||||
Khoảng đề xuất: ${ai.priceRange?.min} – ${ai.priceRange?.max}
|
||||
</div>
|
||||
<div className="ai-reasoning">{ai.reasoning}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
export default function FeedPanel({ entries }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>New Feed</h2>
|
||||
<span className="panel-badge">Activity</span>
|
||||
</div>
|
||||
<div className="feed-list">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.id} className={`feed-item ${entry.type}`}>
|
||||
<span className="feed-dot" />
|
||||
<span>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
|
||||
/**
|
||||
* Gộp nhiều series ({date, price}) theo ngày thành 1 mảng cho Recharts.
|
||||
* lines: [{ key, name, color, data: [{date, price}] }]
|
||||
*/
|
||||
function mergeByDate(lines) {
|
||||
const map = new Map();
|
||||
for (const line of lines) {
|
||||
for (const pt of line.data || []) {
|
||||
if (!map.has(pt.date)) map.set(pt.date, { date: pt.date });
|
||||
map.get(pt.date)[line.key] = pt.price;
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
export default function PriceChart({ title, lines }) {
|
||||
const hasData = lines.some((l) => (l.data || []).length > 0);
|
||||
const merged = mergeByDate(lines);
|
||||
|
||||
return (
|
||||
<div className="chart-card">
|
||||
<h3>{title}</h3>
|
||||
{!hasData ? (
|
||||
<p className="empty">Chưa có dữ liệu.</p>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={merged} margin={{ top: 8, right: 16, bottom: 8, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#eee" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis
|
||||
tick={{ fontSize: 11 }}
|
||||
width={56}
|
||||
tickFormatter={(v) => `$${v}`}
|
||||
domain={['auto', 'auto']}
|
||||
/>
|
||||
<Tooltip formatter={(v) => `$${v}`} />
|
||||
<Legend />
|
||||
{lines.map((l) => (
|
||||
<Line
|
||||
key={l.key}
|
||||
type="monotone"
|
||||
dataKey={l.key}
|
||||
name={l.name}
|
||||
stroke={l.color}
|
||||
dot={false}
|
||||
connectNulls
|
||||
strokeWidth={2}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
export default function ProductFormPanel({ formMode, form, onChange, onSubmit, onImport, onClear }) {
|
||||
return (
|
||||
<section className="panel form-panel">
|
||||
<div className="panel-header">
|
||||
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
|
||||
{formMode === 'add' && (
|
||||
<button type="button" className="secondary" onClick={onImport}>
|
||||
Import
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="product-form" onSubmit={onSubmit}>
|
||||
<label>
|
||||
SKU
|
||||
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" />
|
||||
</label>
|
||||
<label>
|
||||
Tên sản phẩm
|
||||
<input name="title" value={form.title} onChange={onChange} placeholder="Tên sản phẩm" />
|
||||
</label>
|
||||
<label>
|
||||
Category
|
||||
<input name="category" value={form.category} onChange={onChange} placeholder="Category" />
|
||||
</label>
|
||||
<label>
|
||||
Price
|
||||
<input name="price" type="number" value={form.price} onChange={onChange} placeholder="Price" />
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select name="status" value={form.status} onChange={onChange}>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Listed">Listed</option>
|
||||
<option value="Active">Active</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit">{formMode === 'add' ? 'Add' : 'Save'}</button>
|
||||
<button type="button" className="secondary" onClick={onClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
export default function ProductTablePanel({ title, badge, products, onSelect }) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>{title}</h2>
|
||||
<span className="panel-badge">{badge}</span>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>SKU</th>
|
||||
<th>Tên</th>
|
||||
<th>Category</th>
|
||||
<th>Price</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} onClick={() => onSelect(product)}>
|
||||
<td>{product.sku}</td>
|
||||
<td>{product.title}</td>
|
||||
<td>{product.category}</td>
|
||||
<td>${product.price}</td>
|
||||
<td>{product.status}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
const CONDITIONS = [
|
||||
{ value: 'NEW', label: 'New' },
|
||||
{ value: 'REF', label: 'Refurbished' },
|
||||
{ value: 'USED', label: 'Used' },
|
||||
];
|
||||
|
||||
export default function SuggestForm({ sku, setSku, condition, setCondition, onSubmit, loading }) {
|
||||
return (
|
||||
<form
|
||||
className="form"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<div className="field">
|
||||
<label htmlFor="sku">SKU</label>
|
||||
<input
|
||||
id="sku"
|
||||
value={sku}
|
||||
onChange={(e) => setSku(e.target.value)}
|
||||
placeholder="VD: C9200L-24T-4G-E"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="condition">Condition</label>
|
||||
<select id="condition" value={condition} onChange={(e) => setCondition(e.target.value)}>
|
||||
{CONDITIONS.map((c) => (
|
||||
<option key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" disabled={loading || !sku.trim()}>
|
||||
{loading ? 'Đang xử lý…' : 'Suggest'}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
background: #f3f5f9;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: #2563eb;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.topbar-left,
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.user-pill,
|
||||
.panel-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
||||
height: 86vh;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.product-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.product-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.product-form input,
|
||||
.product-form select {
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.feed-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.feed-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f8fafc;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.feed-item.warning {
|
||||
background: #fff7ed;
|
||||
color: #9a2c00;
|
||||
}
|
||||
|
||||
.feed-item.success {
|
||||
background: #ecfdf3;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.feed-item.info {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.feed-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8386',
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue