first commit

This commit is contained in:
andrew.ng 2026-06-29 16:49:07 +07:00
commit 6e2ab902a1
77 changed files with 13034 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.claude/

43
README.md Normal file
View File

@ -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`.

47
backend/.env.example Normal file
View File

@ -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}

8
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
node_modules
build
coverage
.env
.env.local
tmp
*.log
.DS_Store

87
backend/README.md Normal file
View File

@ -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``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`).

52
backend/ace.js Normal file
View File

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

50
backend/adonisrc.ts Normal file
View File

@ -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',
},
})

View File

@ -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 }
}
}

View File

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

View File

@ -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
}
}

View File

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

View File

@ -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 }
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
}

39
backend/app/models/log.ts Normal file
View File

@ -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 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>
}

View File

@ -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 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
}
}

View File

@ -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 "token" trong schema của bạn.)
*/
static accessTokens = DbAccessTokensProvider.forModel(User)
}

View File

@ -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 ).
*/
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 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',
}
}
}

View File

@ -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 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
}
}

View File

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

View File

@ -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 }
}
}
}

View File

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

View File

@ -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
}
}

View File

@ -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 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,
}))
)
}
}

View File

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

View File

@ -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 ERP 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 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
}
}
}

View File

@ -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 command) lại tự mở kết nối 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 },
}))
)
}

View File

@ -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 fan-out mỗi sản phẩm thành 1 job `upsert`.
* 2. Worker xử 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), `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 skip=100 cho 2 block
* RỜI NHAU. vậy tăng `skip += pageSize` 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ử 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 }
}
}

View File

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

View File

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

31
backend/bin/console.ts Normal file
View File

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

34
backend/bin/server.ts Normal file
View File

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

View File

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

View File

@ -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})`)
}
}

View File

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

View File

@ -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ử )
* 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()
}
}
}

20
backend/config/app.ts Normal file
View File

@ -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',
},
})

24
backend/config/auth.ts Normal file
View File

@ -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> {}
}

View File

@ -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

13
backend/config/cors.ts Normal file
View File

@ -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

View File

@ -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

19
backend/config/hash.ts Normal file
View File

@ -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> {}
}

26
backend/config/logger.ts Normal file
View File

@ -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> {}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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')
})
}
}

View File

@ -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')
})
}
}

View File

@ -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'])
})
}
}

6794
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

62
backend/package.json Normal file
View File

@ -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"
]
}
}

57
backend/start/env.ts Normal file
View File

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

30
backend/start/kernel.ts Normal file
View File

@ -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'),
})

49
backend/start/routes.ts Normal file
View File

@ -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')

View File

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

View File

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

20
backend/tsconfig.json Normal file
View File

@ -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"]
}
}
}

2
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules/
dist/

BIN
frontend/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

13
frontend/index.html Normal file
View File

@ -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>

2059
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
frontend/package.json Normal file
View File

@ -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"
}
}

168
frontend/src/App.jsx Normal file
View File

@ -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>
);
}

View File

@ -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>
);
}

View File

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

View File

@ -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 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>
);
}

View File

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

View File

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

View File

@ -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>
);
}

10
frontend/src/main.jsx Normal file
View File

@ -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>
);

212
frontend/src/styles.css Normal file
View File

@ -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;
}
}

12
frontend/vite.config.js Normal file
View File

@ -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',
},
},
});