This commit is contained in:
andrew.ng 2026-07-01 15:59:08 +07:00
parent e14c504d01
commit 53e2f33064
33 changed files with 1525 additions and 283 deletions

View File

@ -0,0 +1,41 @@
import type { HttpContext } from '@adonisjs/core/http'
import NotificationService from '#services/notification_service'
import type { NotificationType } from '#models/notification'
export default class NotificationsController {
/** GET /api/notifications?type=&isRead=&page=&perPage= */
async index({ request }: HttpContext) {
const page = Number(request.input('page', 1))
const perPage = Number(request.input('perPage', 25))
const type = request.input('type') as NotificationType | undefined
// isRead: 'true' | 'false' | undefined (không lọc)
const rawRead = request.input('isRead')
const isRead = rawRead === undefined ? undefined : rawRead === 'true' || rawRead === true
return NotificationService.list({ page, perPage, type, isRead })
}
/** GET /api/notifications/unread-count */
async unreadCount() {
return { count: await NotificationService.unreadCount() }
}
/** PATCH /api/notifications/:id/read */
async markRead({ params }: HttpContext) {
return NotificationService.markRead(Number(params.id))
}
/** PATCH /api/notifications/read-all?type= */
async markAllRead({ request }: HttpContext) {
const type = request.input('type') as NotificationType | undefined
const updated = await NotificationService.markAllRead(type)
return { updated }
}
/** DELETE /api/notifications/:id */
async destroy({ params, response }: HttpContext) {
await NotificationService.destroy(Number(params.id))
return response.noContent()
}
}

View File

@ -5,6 +5,7 @@ import {
createProductValidator,
updateProductValidator,
listProductValidator,
setNoListingValidator,
} from '#validators/product'
export default class ProductsController {
@ -13,14 +14,15 @@ export default class ProductsController {
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 order = params.order ?? 'id'
const direction: 'asc' | 'desc' = params.direction === 'desc' ? 'desc' : 'asc'
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.noListing !== undefined) query.where('no_listing', params.noListing)
if (params.sku) {
// FULLTEXT khi có, fallback LIKE
query.where((b) => {
@ -49,6 +51,16 @@ export default class ProductsController {
return ProductService.update(params.id, data, auth.getUserOrFail().username)
}
/**
* PATCH /api/products/no-listing
* body: { ids: number[], noListing: boolean }
* Gắn / bỏ cờ noListing hàng loạt (bỏ qua khi gợi ý giá).
*/
async setNoListing({ request, auth }: HttpContext) {
const { ids, noListing } = await request.validateUsing(setNoListingValidator)
return ProductService.setNoListing(ids, noListing, auth.getUserOrFail().username)
}
/** DELETE /api/products/:id */
async destroy({ params, response, auth }: HttpContext) {
await ProductService.destroy(params.id, auth.getUserOrFail().username)

View File

@ -0,0 +1,42 @@
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
/** Loại thông báo hiển thị cho người dùng. */
export type NotificationType = 'warning' | 'error' | 'success' | 'news'
/**
* Thông báo hệ thống cho người dùng (giống bảng logs nhưng hướng người dùng):
* sản phẩm mới khi sync, cảnh báo import trùng, kết quả thao tác...
* trạng thái đã đc / chưa đc.
*/
export default class Notification extends BaseModel {
@column({ isPrimary: true })
declare id: number
/** warning | error | success | news */
@column()
declare type: NotificationType
@column()
declare title: string
@column()
declare message: string | null
/** false = chưa đọc (unread), true = đã đọc. */
@column()
declare isRead: boolean
/** Dữ liệu kèm theo (vd { productId, sku }) để frontend điều hướng / hành động. */
@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 })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

View File

@ -65,6 +65,10 @@ export default class Product extends BaseModel {
@column()
declare warehouse: string | null
/** true = sản phẩm rác / không cần gợi ý giá (bỏ qua khi chạy gợi ý giá). */
@column()
declare noListing: boolean
/** true nếu sản phẩm được sync từ ERP. */
get isSynced() {
return this.erpId !== null && this.erpId !== undefined

View File

@ -25,6 +25,29 @@ const median = (arr: number[]) => {
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
}
/** Số sản phẩm gộp trong 1 request GPT (batch). */
export const AI_BATCH_CHUNK_SIZE = 25
/** Số điểm giá tối đa (mới nhất) gửi kèm mỗi nguồn để tiết kiệm token. */
const MAX_POINTS_PER_SOURCE = 25
/** Chạy `fn` trên `items` với concurrency giới hạn, giữ nguyên thứ tự kết quả. */
export async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length)
let cursor = 0
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => {
while (cursor < items.length) {
const index = cursor++
results[index] = await fn(items[index], index)
}
})
await Promise.all(workers)
return results
}
/**
* 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 ).
@ -39,6 +62,38 @@ export default class AiService {
}
}
/**
* Gợi ý giá cho NHIỀU sản phẩm cùng lúc: gộp mỗi `AI_BATCH_CHUNK_SIZE`
* sản phẩm vào 1 request GPT (giảm số round-trip từ N -> N/chunk), các
* chạy song song giới hạn. Trả về mảng cùng thứ tự với `inputs`.
*
* Item nào không trong phản hồi AI (hoặc cả lỗi) sẽ tự đng fallback
* rule-based, nên kết quả luôn đ đ dài & không rớt sản phẩm nào.
*/
static async suggestBatch(inputs: SuggestionInput[]): Promise<Suggestion[]> {
if (!inputs.length) return []
const chunks: Array<{ start: number; items: SuggestionInput[] }> = []
for (let i = 0; i < inputs.length; i += AI_BATCH_CHUNK_SIZE) {
chunks.push({ start: i, items: inputs.slice(i, i + AI_BATCH_CHUNK_SIZE) })
}
const results = new Array<Suggestion>(inputs.length)
await mapWithConcurrency(chunks, 5, async (chunk) => {
let byId: Map<number, Suggestion>
try {
byId = await this.openAiBatch(chunk.items)
} catch (error) {
logger.error({ err: error }, 'OpenAI batch suggest lỗi — fallback rule-based cả lô')
byId = new Map()
}
chunk.items.forEach((input, idx) => {
results[chunk.start + idx] = byId.get(input.product.id) ?? this.ruleBased(input)
})
})
return results
}
// --- Rule-based ---
private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion {
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
@ -103,25 +158,82 @@ export default class AiService {
].join('\n')
}
private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise<Suggestion> {
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
const payload = {
/**
* System prompt cho engine AI chế đ BATCH: định giá cho một mảng sản phẩm
* trong cùng một request. Giữ nguyên quy tắc đnh giá như bản single, chỉ khác
* chỗ đu vào mảng `items` đu ra mảng `results` map theo `id`.
*/
static buildBatchSystemPrompt(floorMarkup: number): string {
return [
'Bạn là chuyên gia định giá listing trên eBay (thị trường Úc/Mỹ).',
'Nhiệm vụ: với MỖI sản phẩm trong mảng "items" (mỗi phần tử có "id", chi phí supplier,',
'và dữ liệu giá eBay đã bán "ebaySold" / đang bán "ebaySale"), đề 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á (áp dụng ĐỘC LẬP cho từng sản phẩm):',
'1. Ưu tiên neo giá theo median giá ĐÃ BÁN (ebaySold); nếu thiếu, dùng median giá ĐANG BÁN (ebaySale);',
' 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).',
'',
'BẮT BUỘC trả về đủ MỘT kết quả cho MỖI id trong "items", giữ nguyên "id" tương ứng.',
'Chỉ trả về DUY NHẤT một object JSON hợp lệ (không markdown, không text ngoài JSON) theo schema:',
'{',
' "results": [',
' {',
' "id": number, // đúng id của sản phẩm trong items',
' "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')
}
/**
* Rút gọn một mảng điểm giá về đúng field cần cho đnh giá: `{ price, date }`.
* Bỏ các field thừa (title, itemId, source...), loại giá không hợp lệ,
* ưu tiên các điểm mới nhất giới hạn số lượng đ tiết kiệm token.
*/
private static compactPoints(points?: Array<Record<string, any>>): Array<{ price: number; date?: string }> {
if (!Array.isArray(points)) return []
return points
.map((p) => ({
price: Number(p?.price),
date: typeof p?.date === 'string' ? p.date : undefined,
}))
.filter((p) => Number.isFinite(p.price) && p.price > 0)
.sort((a, b) => (b.date ?? '').localeCompare(a.date ?? ''))
.slice(0, MAX_POINTS_PER_SOURCE)
}
/** Payload gọn cho 1 sản phẩm (dùng chung cho cả single & batch). */
private static buildItemPayload({ product, dataSources, dataEbay }: SuggestionInput) {
return {
id: product.id,
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,
supplier: this.compactPoints(dataSources),
ebaySold: this.compactPoints(dataEbay?.sold),
ebaySale: this.compactPoints(dataEbay?.sale),
}
}
/** Gọi ERP proxy -> GPT, trả về data thô để normalize. */
private static async callGpt(systemPrompt: string, userPayload: unknown): Promise<any> {
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) },
{ role: 'system', content: systemPrompt },
{ role: 'user', content: JSON.stringify(userPayload) },
],
}
const externalApiUrl = process.env.ERP_API_URL;
const externalApiUrl = process.env.ERP_API_URL
const remoteResp = await axios.post(
externalApiUrl + '/api/transferPostData',
@ -138,7 +250,59 @@ export default class AiService {
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)
return remoteResp.data?.data
}
private static async openAi(input: SuggestionInput): Promise<Suggestion> {
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
const { id, ...payload } = this.buildItemPayload(input)
const data = await this.callGpt(this.buildSystemPrompt(floorMarkup), payload)
return this.normalize(data)
}
/** Gọi GPT cho 1 lô sản phẩm, trả về map productId -> Suggestion. */
private static async openAiBatch(inputs: SuggestionInput[]): Promise<Map<number, Suggestion>> {
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
const payload = { items: inputs.map((input) => this.buildItemPayload(input)) }
const data = await this.callGpt(this.buildBatchSystemPrompt(floorMarkup), payload)
return this.normalizeBatch(data)
}
/**
* Chuẩn hoá phản hồi batch về map `id -> Suggestion`.
* AI thể trả string JSON, mảng trực tiếp, hoặc bọc trong { results | data | result }.
* Item lỗi/không hợp lệ bị bỏ qua đ caller fallback rule-based từng cái.
*/
private static normalizeBatch(raw: any): Map<number, Suggestion> {
let data = raw
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
throw new Error('OpenAI batch trả về không phải JSON hợp lệ: ' + raw)
}
}
const arr: any[] = Array.isArray(data)
? data
: Array.isArray(data?.results)
? data.results
: Array.isArray(data?.data)
? data.data
: Array.isArray(data?.result)
? data.result
: []
const map = new Map<number, Suggestion>()
for (const entry of arr) {
const id = Number(entry?.id)
if (!Number.isFinite(id)) continue
try {
map.set(id, this.normalize(entry))
} catch {
// item hỏng -> để trống, caller sẽ fallback rule-based
}
}
return map
}
/**

View File

@ -1,6 +1,8 @@
import xlsx from 'xlsx'
import ProductService from '#services/product_service'
import LogService from '#services/log_service'
import NotificationService from '#services/notification_service'
import type { NotificationType } from '#models/notification'
export interface ImportRowResult {
row: number
@ -76,6 +78,30 @@ export default class ImportService {
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
})
// Thông báo cho người dùng: cảnh báo nếu import trùng / có dòng lỗi, ngược lại báo thành công.
const type: NotificationType =
summary.failed > 0 ? 'error' : summary.updated > 0 ? 'warning' : 'success'
const parts: string[] = []
if (summary.created > 0) parts.push(`${summary.created} sản phẩm mới`)
if (summary.updated > 0) parts.push(`${summary.updated} sản phẩm đã tồn tại (import trùng, đã cập nhật)`)
if (summary.failed > 0) parts.push(`${summary.failed} dòng lỗi`)
await NotificationService.notify({
type,
title:
summary.updated > 0
? 'Import trùng sản phẩm đã có'
: summary.failed > 0
? 'Import Excel có lỗi'
: 'Import Excel thành công',
message: `Đã xử lý ${summary.total} dòng: ${parts.join(', ') || 'không có thay đổi'}.`,
meta: {
total: summary.total,
created: summary.created,
updated: summary.updated,
failed: summary.failed,
},
})
return summary
}

View File

@ -0,0 +1,84 @@
import Notification, { type NotificationType } from '#models/notification'
interface NotifyInput {
type: NotificationType
title: string
message?: string | null
meta?: Record<string, any> | null
}
interface ListParams {
page?: number
perPage?: number
type?: NotificationType
/** Lọc theo trạng thái đọc: true = chỉ đã đọc, false = chỉ chưa đọc. */
isRead?: boolean
}
/**
* Thông báo hướng người dùng (sản phẩm mới khi sync, import trùng, kết quả thao tác).
* Tương tự LogService nhưng trạng thái đc/chưa đc hiển thị trên UI.
*/
export default class NotificationService {
static async notify(input: NotifyInput): Promise<Notification> {
return Notification.create({
type: input.type,
title: input.title,
message: input.message ?? null,
meta: input.meta ?? null,
isRead: false,
})
}
/** Tạo nhiều thông báo một lượt. */
static async notifyMany(inputs: NotifyInput[]): Promise<void> {
if (!inputs.length) return
await Notification.createMany(
inputs.map((i) => ({
type: i.type,
title: i.title,
message: i.message ?? null,
meta: i.meta ?? null,
isRead: false,
}))
)
}
static async list(params: ListParams) {
const page = params.page ?? 1
const perPage = params.perPage ?? 25
const query = Notification.query().orderBy('created_at', 'desc')
if (params.type) query.where('type', params.type)
if (params.isRead !== undefined) query.where('is_read', params.isRead)
return query.paginate(page, perPage)
}
/** Số thông báo chưa đọc (cho badge). */
static async unreadCount(): Promise<number> {
const row = await Notification.query().where('is_read', false).count('* as total').first()
return Number((row as any)?.$extras?.total ?? 0)
}
/** Đánh dấu 1 thông báo là đã đọc. */
static async markRead(id: number): Promise<Notification> {
const notification = await Notification.findOrFail(id)
notification.isRead = true
await notification.save()
return notification
}
/** Đánh dấu tất cả (hoặc theo loại) là đã đọc. Trả về số bản ghi cập nhật. */
static async markAllRead(type?: NotificationType): Promise<number> {
const query = Notification.query().where('is_read', false)
if (type) query.where('type', type)
const result = await query.update({ is_read: true })
return Array.isArray(result) ? Number(result[0]) : Number(result)
}
static async destroy(id: number): Promise<void> {
const notification = await Notification.findOrFail(id)
await notification.delete()
}
}

View File

@ -2,7 +2,7 @@ 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 AiService, { mapWithConcurrency } from '#services/ai_service'
import type { Suggestion } from '#services/ai_service'
import HistoryService from '#services/history_service'
import LogService from '#services/log_service'
@ -47,6 +47,77 @@ export default class PricingService {
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
return this.persist({ product, dataSources, dataEbay, suggestion, username, forceApply })
}
/**
* Gợi ý giá cho NHIỀU product một lượt (batch).
*
* Tối ưu tốc đ khi nhiều sản phẩm:
* 1. Lấy dữ liệu supplier + eBay của các product song song (giới hạn concurrency
* eBay/scrape nặng).
* 2. Gọi AI theo nhiều product chung 1 request GPT (xem `AiService.suggestBatch`).
* 3. Lưu history/price cho từng product (song song giới hạn).
*
* Trả về mảng kết quả cùng thứ tự với các product tải đưc (product không tồn
* tại sẽ bị bỏ qua).
*/
static async suggestForProducts(
productIds: number[],
username: string,
forceApply = false
): Promise<SuggestResult[]> {
if (!productIds.length) return []
// Bỏ qua sản phẩm rác đã gắn cờ noListing.
const products = await Product.query().whereIn('id', productIds).where('no_listing', false)
if (!products.length) return []
// 1. Thu thập dữ liệu đầu vào (song song, giới hạn concurrency).
const inputs = await mapWithConcurrency(products, 5, async (product) => {
const [dataSources, dataEbay] = await Promise.all([
ErpService.getSupplierPricing(product),
EbayService.getMarketData(product),
])
return { product, dataSources, dataEbay }
})
// 2. Gọi AI theo lô (gộp nhiều product / 1 request GPT).
const suggestions = await AiService.suggestBatch(inputs)
// 3. Lưu kết quả cho từng product (song song, giới hạn concurrency).
return mapWithConcurrency(inputs, 5, (input, idx) =>
this.persist({
product: input.product,
dataSources: input.dataSources,
dataEbay: input.dataEbay,
suggestion: suggestions[idx],
username,
forceApply,
})
)
}
/**
* Lưu snapshot history + áp giá hybrid + ghi log cho 1 product đã suggestion.
* Tách riêng đ dùng chung giữa luồng single (`suggestForProduct`) batch
* (`suggestForProducts`).
*/
private static async persist({
product,
dataSources,
dataEbay,
suggestion,
username,
forceApply,
}: {
product: Product
dataSources: Awaited<ReturnType<typeof ErpService.getSupplierPricing>>
dataEbay: Awaited<ReturnType<typeof EbayService.getMarketData>>
suggestion: Suggestion
username: string
forceApply: boolean
}): Promise<SuggestResult> {
const history = await HistoryService.record({
username,
productId: product.id,
@ -111,9 +182,9 @@ export default class PricingService {
return product
}
/** Lấy danh sách id product để chạy batch (mặc định toàn bộ). */
/** Lấy danh sách id product để chạy batch (mặc định toàn bộ, trừ sản phẩm noListing). */
static async productIdsForBatch(): Promise<number[]> {
const rows = await Product.query().select('id')
const rows = await Product.query().where('no_listing', false).select('id')
return rows.map((r) => r.id)
}
}

View File

@ -1,3 +1,4 @@
import { DateTime } from 'luxon'
import Product from '#models/product'
import LogService from '#services/log_service'
@ -21,6 +22,7 @@ interface ProductData {
type?: string | null
erpId?: string | null
warehouse?: string | null
noListing?: boolean
}
/**
@ -73,6 +75,31 @@ export default class ProductService {
return product
}
/**
* Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox).
* Sản phẩm noListing sẽ bị bỏ qua khi chạy gợi ý giá.
*/
static async setNoListing(
ids: number[],
noListing: boolean,
username: string
): Promise<{ updated: number }> {
if (!ids.length) return { updated: 0 }
const updated = await Product.query()
.whereIn('id', ids)
.update({ no_listing: noListing, updated_at: DateTime.now().toSQL() })
await LogService.record({
username,
actionName: noListing ? 'Gắn cờ không gợi ý giá' : 'Bỏ cờ không gợi ý giá',
action: 'update',
meta: { ids, noListing },
})
return { updated: Array.isArray(updated) ? Number(updated[0]) : Number(updated) }
}
static async destroy(id: number, username: string): Promise<void> {
const product = await Product.findOrFail(id)
const snapshot = product.serialize()

View File

@ -85,11 +85,23 @@ export async function enqueuePricingSuggest(productId: number, username: string)
return pricingQueue().add('suggest', { productId, username })
}
/** Đẩy nhiều job gợi ý giá (batch). */
/** Số product gộp trong 1 job batch (mỗi job xử lý 1 request GPT gộp). */
export const PRICING_BATCH_CHUNK_SIZE = 25
/**
* Đy job gợi ý giá hàng loạt: chia `productIds` thành các
* `PRICING_BATCH_CHUNK_SIZE`, mỗi 1 job `suggestBatch` (gộp 1 request GPT/).
* Giảm số job & số round-trip GPT từ N -> ceil(N/chunk).
*/
export async function enqueuePricingBatch(productIds: number[], username: string) {
return pricingQueue().addBulk(
productIds.map((productId) => ({ name: 'suggest', data: { productId, username } }))
)
const jobs: Array<{ name: string; data: { productIds: number[]; username: string } }> = []
for (let i = 0; i < productIds.length; i += PRICING_BATCH_CHUNK_SIZE) {
jobs.push({
name: 'suggestBatch',
data: { productIds: productIds.slice(i, i + PRICING_BATCH_CHUNK_SIZE), username },
})
}
return pricingQueue().addBulk(jobs)
}
/** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */

View File

@ -1,9 +1,9 @@
import logger from '@adonisjs/core/services/logger'
import ProductService from '#services/product_service'
import LogService from '#services/log_service'
import NotificationService from '#services/notification_service'
import ErpService, { type ErpProductItem } from '#services/erp_service'
import { enqueueProductUpserts } from '#services/queue_service'
import { convertCondition } from '#helpers/condition'
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. */
@ -114,7 +114,23 @@ export default class SyncService {
* 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)
const { product, created } = await ProductService.upsert(item)
// Sản phẩm MỚI từ ERP -> báo cho người dùng xem có cần gắn cờ noListing (bỏ qua gợi ý giá) không.
if (created) {
await NotificationService.notify({
type: 'news',
title: 'Sản phẩm mới từ ERP',
message: `SKU ${product.sku} (${product.condition}) vừa được đồng bộ. Kiểm tra xem có cần gắn cờ "Không gợi ý giá" cho sản phẩm này không.`,
meta: {
productId: product.id,
sku: product.sku,
condition: product.condition,
warehouse: product.warehouse,
},
})
}
return { sku: item.sku, condition: item.condition, created }
}
}

View File

@ -15,6 +15,8 @@ const base = {
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
type: vine.string().trim().maxLength(100).optional().nullable(),
erpId: vine.string().trim().maxLength(191).optional().nullable(),
warehouse: vine.string().trim().maxLength(100).optional().nullable(),
noListing: vine.boolean().optional(),
}
export const createProductValidator = vine.compile(vine.object(base))
@ -35,6 +37,18 @@ export const updateProductValidator = vine.compile(
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
type: vine.string().trim().maxLength(100).optional().nullable(),
erpId: vine.string().trim().maxLength(191).optional().nullable(),
warehouse: vine.string().trim().maxLength(100).optional().nullable(),
noListing: vine.boolean().optional(),
})
)
/**
* Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox).
*/
export const setNoListingValidator = vine.compile(
vine.object({
ids: vine.array(vine.number().min(1)).minLength(1),
noListing: vine.boolean(),
})
)
@ -42,11 +56,12 @@ 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(),
sku: vine.string().trim().optional(),
condition: vine.string().trim().optional(),
warehouse: vine.string().trim().optional(),
type: vine.string().trim().optional(),
order: vine.string().trim().optional(),
direction: vine.string().trim().optional(),
noListing: vine.boolean().optional(),
})
)

View File

@ -54,7 +54,7 @@ export default class AiSuggest extends BaseCommand {
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: AiService, mapWithConcurrency } = await import('#services/ai_service')
const { default: ErpService } = await import('#services/erp_service')
const { default: EbayService } = await import('#services/ebay_service')
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
@ -89,51 +89,53 @@ export default class AiSuggest extends BaseCommand {
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)
try {
if (this.dryRun) {
// Dry-run: chạy luồng AI theo LÔ (gộp request GPT) nhưng KHÔNG ghi DB.
const products = await Product.query().whereIn('id', ids)
const inputs = await mapWithConcurrency(products, 5, async (product: any) => {
const [dataSources, dataEbay] = await Promise.all([
ErpService.getSupplierPricing(product),
EbayService.getMarketData(product),
])
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
return { product, dataSources, dataEbay }
})
const suggestions = await AiService.suggestBatch(inputs)
inputs.forEach((input, idx) => {
const suggestion = suggestions[idx]
rows.push([
String(id),
product.sku,
`$${Number(product.price)}`,
String(input.product.id),
input.product.sku,
`$${Number(input.product.price)}`,
`$${suggestion.suggestedPrice}`,
'—',
suggestion.engine,
])
} else {
const result = await PricingService.suggestForProduct(id, this.username, this.force)
ok++
})
fail = ids.length - inputs.length
} else {
// Non-dry-run: gợi ý + lưu DB theo LÔ.
const results = await PricingService.suggestForProducts(ids, this.username, this.force)
const prods = await Product.query().whereIn('id', ids).select('id', 'sku')
const skuMap = new Map<number, string>()
prods.forEach((p: any) => skuMap.set(p.id, p.sku))
for (const result of results) {
rows.push([
String(id),
'', // SKU điền sau (suggestForProduct không trả sku)
String(result.productId),
skuMap.get(result.productId) ?? '',
`$${result.oldPrice}`,
`$${result.suggestion.suggestedPrice}`,
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
result.suggestion.engine,
])
ok++
}
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)!
fail = ids.length - results.length
}
} catch (error) {
this.logger.error(`Lỗi khi chạy batch: ${(error as Error).message}`)
fail = ids.length - ok
}
const table = this.ui.table()

View File

@ -33,6 +33,11 @@ export default class QueueWork extends BaseCommand {
const { productId, username } = job.data
return PricingService.suggestForProduct(productId, username)
}
// Batch: 1 job = 1 lô product = 1 request GPT gộp (xem enqueuePricingBatch).
if (job.name === 'suggestBatch') {
const { productIds, username } = job.data
return PricingService.suggestForProducts(productIds, username)
}
},
{ connection: redisConnection, concurrency }
)

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'products'
async up() {
this.schema.alterTable(this.tableName, (table) => {
// Đánh dấu sản phẩm rác / không cần gợi ý giá. true = bỏ qua khi gợi ý giá.
table.boolean('no_listing').notNullable().defaultTo(false)
// Lọc nhanh danh sách cần/không cần gợi ý giá.
table.index(['no_listing'], 'products_no_listing_index')
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropIndex(['no_listing'], 'products_no_listing_index')
table.dropColumn('no_listing')
})
}
}

View File

@ -0,0 +1,29 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'notifications'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
// Loại thông báo: warning | error | success | news.
table.string('type', 20).notNullable().defaultTo('news')
table.string('title', 191).notNullable()
table.text('message').nullable()
// Đã đọc hay chưa (false = unread).
table.boolean('is_read').notNullable().defaultTo(false)
// Dữ liệu kèm theo (vd productId, sku) để frontend điều hướng / hành động.
table.json('meta').nullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').notNullable()
table.index(['is_read'])
table.index(['type'])
table.index(['created_at'])
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -7,6 +7,7 @@ const ImportsController = () => import('#controllers/imports_controller')
const PricingController = () => import('#controllers/pricing_controller')
const LogsController = () => import('#controllers/logs_controller')
const HistoriesController = () => import('#controllers/histories_controller')
const NotificationsController = () => import('#controllers/notifications_controller')
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
router.get('/api/health', async () => ({ ok: true }))
@ -27,6 +28,8 @@ router
// --- Products (CRUD: manual + sync) ---
router.post('/products', [ProductsController, 'store'])
// Gắn/bỏ cờ noListing hàng loạt — khai báo TRƯỚC '/products/:id' để không bị nuốt bởi route động.
router.patch('/products/no-listing', [ProductsController, 'setNoListing'])
router.get('/products/:id', [ProductsController, 'show'])
router.patch('/products/:id', [ProductsController, 'update'])
router.delete('/products/:id', [ProductsController, 'destroy'])
@ -43,6 +46,13 @@ router
router.get('/logs', [LogsController, 'index'])
router.get('/histories', [HistoriesController, 'index'])
router.get('/histories/:id', [HistoriesController, 'show'])
// --- Notifications (thông báo cho người dùng) ---
router.get('/notifications', [NotificationsController, 'index'])
router.get('/notifications/unread-count', [NotificationsController, 'unreadCount'])
router.patch('/notifications/read-all', [NotificationsController, 'markAllRead'])
router.patch('/notifications/:id/read', [NotificationsController, 'markRead'])
router.delete('/notifications/:id', [NotificationsController, 'destroy'])
})
.use(middleware.auth())
})

View File

@ -8,8 +8,10 @@
"name": "suggestprice-web",
"version": "0.1.0",
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.3.0",
"recharts": "^2.12.7"
},
"devDependencies": {
@ -749,6 +751,32 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@ -1106,6 +1134,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1221,6 +1261,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@ -1583,6 +1629,16 @@
"node": ">=6.9.0"
}
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@ -1781,6 +1837,29 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@ -1855,6 +1934,27 @@
"decimal.js-light": "^2.4.1"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@ -1966,6 +2066,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-redux": "^9.3.0",
"recharts": "^2.12.7"
},
"devDependencies": {

View File

@ -1,167 +1,135 @@
import { useState } from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import Tabs from './components/Tabs.jsx';
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',
};
import LoginForm from './components/LoginForm.jsx';
import { logout } from './store/authSlice';
import { fetchProducts, saveProduct } from './store/productsSlice';
import {
fetchNotifications,
markNotificationRead,
markAllNotificationsRead,
} from './store/notificationsSlice';
import { setActiveTab, updateForm, startAdd, startEdit, resetForm } from './store/uiSlice';
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);
const dispatch = useDispatch();
const { user, token } = useSelector((state) => state.auth);
const { erp, manual, saving, saveError } = useSelector((state) => state.products);
const notifications = useSelector((state) => state.notifications);
const { activeTab, editingId, form } = useSelector((state) => state.ui);
function resetForm() {
setFormMode('add');
setForm(initialForm);
// Np d liu khi đã đăng nhp.
useEffect(() => {
if (!token) return;
dispatch(fetchProducts('ERP'));
dispatch(fetchProducts('MANUAL'));
dispatch(fetchNotifications());
}, [dispatch, token]);
if (!token || !user) {
return <LoginForm />;
}
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,
]);
}
const unread = notifications.items.filter((n) => !n.isRead).length;
const tabs = [
{ key: 'erp', label: 'Product ERP' },
{ key: 'manual', label: 'Manual' },
{ key: 'form', label: editingId ? 'Edit Product' : 'Add Product' },
{ key: 'feed', label: 'New Feed', badge: unread },
];
function handleChange(event) {
const { name, value } = event.target;
setForm((prev) => ({ ...prev, [name]: value }));
dispatch(updateForm({ name, value }));
}
function handleSubmit(event) {
event.preventDefault();
const values = {
sku: form.sku,
condition: form.condition,
qty: Number(form.qty) || 0,
price: Number(form.price) || 0,
warehouse: form.warehouse || null,
};
// Sn phm to tay thuc tab Manual.
if (!editingId) values.type = 'MANUAL';
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();
dispatch(saveProduct({ id: editingId, values })).then((action) => {
if (!action.error) {
dispatch(resetForm());
dispatch(setActiveTab('manual'));
}
});
}
const displayName =
[user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
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>
)}
<span className="user-pill">Hi, {displayName}</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>
)}
<button type="button" onClick={() => dispatch(startAdd())}>
+ Add Product
</button>
<button type="button" className="secondary" onClick={() => dispatch(logout())}>
Logout
</button>
</div>
</header>
<main className="dashboard-grid">
<ProductTablePanel
title="Product ERP"
badge="Source"
products={erpProducts}
onSelect={(product) => handleSelectProduct(product, 'erp')}
/>
<Tabs tabs={tabs} active={activeTab} onChange={(key) => dispatch(setActiveTab(key))} />
<ProductTablePanel
title="Product Manual"
badge="Local"
products={manualProducts}
onSelect={(product) => handleSelectProduct(product, 'manual')}
/>
<main className="tab-content">
{activeTab === 'erp' && (
<ProductTablePanel
title="Product ERP"
badge="type = ERP"
bucket={erp}
onSelect={(product) => dispatch(startEdit(product))}
/>
)}
<ProductFormPanel
formMode={formMode}
form={form}
onChange={handleChange}
onSubmit={handleSubmit}
onImport={handleImportFromErp}
onClear={resetForm}
/>
{activeTab === 'manual' && (
<ProductTablePanel
title="Product Manual"
badge="type = MANUAL"
bucket={manual}
onSelect={(product) => dispatch(startEdit(product))}
/>
)}
<FeedPanel entries={feedEntries} />
{activeTab === 'form' && (
<ProductFormPanel
formMode={editingId ? 'edit' : 'add'}
form={form}
saving={saving}
error={saveError}
onChange={handleChange}
onSubmit={handleSubmit}
onClear={() => dispatch(resetForm())}
/>
)}
{activeTab === 'feed' && (
<FeedPanel
items={notifications.items}
loading={notifications.loading}
error={notifications.error}
onMarkRead={(id) => dispatch(markNotificationRead(id))}
onMarkAllRead={() => dispatch(markAllNotificationsRead())}
/>
)}
</main>
</div>
);

View File

@ -0,0 +1,51 @@
// Helper gọi API backend. Vite proxy chuyển '/api' -> http://localhost:8386.
// Tự gắn Bearer token (lưu ở localStorage) cho các endpoint yêu cầu đăng nhập.
function buildQuery(params) {
const usp = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
usp.append(key, value);
}
});
const qs = usp.toString();
return qs ? `?${qs}` : '';
}
export async function apiFetch(path, options = {}) {
const { method = 'GET', body, params, auth = true } = options;
const headers = { 'Content-Type': 'application/json' };
if (auth) {
const token = localStorage.getItem('token');
if (token) headers.Authorization = `Bearer ${token}`;
}
const url = `/api${path}${params ? buildQuery(params) : ''}`;
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
// 204 No Content
if (response.status === 204) return null;
const data = await response.json().catch(() => null);
if (!response.ok) {
const message =
data?.message ||
data?.errors?.[0]?.message ||
`Yêu cầu thất bại (${response.status})`;
throw new Error(message);
}
return data;
}
// Lucid paginate trả về { meta, data }; endpoint khác trả về mảng trực tiếp.
export function unwrapList(payload) {
if (payload && Array.isArray(payload.data)) return payload.data;
return Array.isArray(payload) ? payload : [];
}

View File

@ -1,18 +1,44 @@
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>
)
}
export default function FeedPanel({ items, loading, error, onMarkRead, onMarkAllRead }) {
const unread = items.filter((n) => !n.isRead).length;
return (
<section className="panel">
<div className="panel-header">
<h2>New Feed</h2>
<div className="panel-header-actions">
<span className="panel-badge">{unread} chưa đọc</span>
{unread > 0 && (
<button type="button" className="secondary" onClick={onMarkAllRead}>
Đọc tất cả
</button>
)}
</div>
</div>
{loading && <p className="panel-hint">Đang tải</p>}
{error && <p className="panel-error">{error}</p>}
{!loading && !error && (
<div className="feed-list">
{items.length === 0 ? (
<p className="panel-hint">Chưa thông báo</p>
) : (
items.map((entry) => (
<div
key={entry.id}
className={`feed-item ${entry.type}${entry.isRead ? ' read' : ''}`}
onClick={() => !entry.isRead && onMarkRead(entry.id)}
>
<span className="feed-dot" />
<div className="feed-body">
<strong>{entry.title}</strong>
{entry.message && <span>{entry.message}</span>}
</div>
</div>
))
)}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,38 @@
import { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { login } from '../store/authSlice';
export default function LoginForm() {
const dispatch = useDispatch();
const { loading, error } = useSelector((state) => state.auth);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
function handleSubmit(event) {
event.preventDefault();
dispatch(login({ username, password }));
}
return (
<div className="login-shell">
<form className="login-card" onSubmit={handleSubmit}>
<h1>Listing - Suggest Price</h1>
<p className="login-sub">Đăng nhập bằng tài khoản ERP</p>
{error && <p className="panel-error">{error}</p>}
<label>
Email
<input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="you@company.com" required />
</label>
<label>
Mật khẩu
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit" disabled={loading || !username.trim()}>
{loading ? 'Đang đăng nhập…' : 'Đăng nhập'}
</button>
</form>
</div>
);
}

View File

@ -1,48 +1,56 @@
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>
)
}
const CONDITIONS = [
{ value: 'NEW', label: 'New' },
{ value: 'REF', label: 'Refurbished' },
{ value: 'USED', label: 'Used' },
];
export default function ProductFormPanel({ formMode, form, saving, error, onChange, onSubmit, onClear }) {
return (
<section className="panel form-panel">
<div className="panel-header">
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
<span className="panel-badge">{formMode === 'add' ? 'Manual' : `#${form.sku}`}</span>
</div>
{error && <p className="panel-error">{error}</p>}
<form className="product-form" onSubmit={onSubmit}>
<label>
SKU
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" required />
</label>
<label>
Condition
<select name="condition" value={form.condition} onChange={onChange}>
{CONDITIONS.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</label>
<label>
Qty
<input name="qty" type="number" min="0" value={form.qty} onChange={onChange} placeholder="Qty" />
</label>
<label>
Price
<input name="price" type="number" min="0" step="0.01" value={form.price} onChange={onChange} placeholder="Price" />
</label>
<label>
WH (Warehouse)
<input name="warehouse" value={form.warehouse} onChange={onChange} placeholder="Warehouse" />
</label>
<div className="form-actions">
<button type="submit" disabled={saving}>
{saving ? 'Đang lưu…' : formMode === 'add' ? 'Add' : 'Save'}
</button>
<button type="button" className="secondary" onClick={onClear} disabled={saving}>
Clear
</button>
</div>
</form>
</section>
);
}

View File

@ -1,32 +1,48 @@
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>
)
}
export default function ProductTablePanel({ title, badge, bucket, onSelect }) {
const { items, loading, error } = bucket;
return (
<section className="panel">
<div className="panel-header">
<h2>{title}</h2>
<span className="panel-badge">{badge}</span>
</div>
{loading && <p className="panel-hint">Đang tải</p>}
{error && <p className="panel-error">{error}</p>}
{!loading && !error && (
<table className="data-table">
<thead>
<tr>
<th>SKU</th>
<th>Condition</th>
<th>Qty</th>
<th>Price</th>
<th>WH</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<tr>
<td colSpan={5} className="empty-row">
Chưa sản phẩm
</td>
</tr>
) : (
items.map((product) => (
<tr key={product.id} onClick={() => onSelect(product)}>
<td>{product.sku}</td>
<td>{product.condition}</td>
<td>{product.qty}</td>
<td>${product.price}</td>
<td>{product.warehouse || '—'}</td>
</tr>
))
)}
</tbody>
</table>
)}
</section>
);
}

View File

@ -0,0 +1,17 @@
export default function Tabs({ tabs, active, onChange }) {
return (
<nav className="tabs">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
className={`tab${active === tab.key ? ' active' : ''}`}
onClick={() => onChange(tab.key)}
>
{tab.label}
{tab.badge > 0 && <span className="tab-badge">{tab.badge}</span>}
</button>
))}
</nav>
);
}

View File

@ -1,10 +1,14 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import store from './store/index.js';
import App from './App.jsx';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);

View File

@ -0,0 +1,54 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiFetch } from '../api/client';
const savedToken = localStorage.getItem('token');
const savedUser = localStorage.getItem('user');
export const login = createAsyncThunk('auth/login', async ({ username, password }, { rejectWithValue }) => {
try {
// POST /api/auth/login -> { user, token }
return await apiFetch('/auth/login', { method: 'POST', body: { username, password }, auth: false });
} catch (error) {
return rejectWithValue(error.message);
}
});
const authSlice = createSlice({
name: 'auth',
initialState: {
token: savedToken || null,
user: savedUser ? JSON.parse(savedUser) : null,
loading: false,
error: null,
},
reducers: {
logout(state) {
state.token = null;
state.user = null;
state.error = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
},
},
extraReducers: (builder) => {
builder
.addCase(login.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(login.fulfilled, (state, action) => {
state.loading = false;
state.token = action.payload.token;
state.user = action.payload.user;
localStorage.setItem('token', action.payload.token);
localStorage.setItem('user', JSON.stringify(action.payload.user));
})
.addCase(login.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Đăng nhập thất bại';
});
},
});
export const { logout } = authSlice.actions;
export default authSlice.reducer;

View File

@ -0,0 +1,16 @@
import { configureStore } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import productsReducer from './productsSlice';
import notificationsReducer from './notificationsSlice';
import uiReducer from './uiSlice';
const store = configureStore({
reducer: {
auth: authReducer,
products: productsReducer,
notifications: notificationsReducer,
ui: uiReducer,
},
});
export default store;

View File

@ -0,0 +1,61 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiFetch, unwrapList } from '../api/client';
// GET /api/notifications (yêu cầu đăng nhập).
export const fetchNotifications = createAsyncThunk(
'notifications/fetch',
async (_, { rejectWithValue }) => {
try {
const payload = await apiFetch('/notifications', { params: { perPage: 100 } });
return unwrapList(payload);
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const markNotificationRead = createAsyncThunk('notifications/markRead', async (id) => {
await apiFetch(`/notifications/${id}/read`, { method: 'PATCH' });
return id;
});
export const markAllNotificationsRead = createAsyncThunk('notifications/markAllRead', async () => {
await apiFetch('/notifications/read-all', { method: 'PATCH' });
return true;
});
const notificationsSlice = createSlice({
name: 'notifications',
initialState: {
items: [],
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchNotifications.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchNotifications.rejected, (state, action) => {
state.loading = false;
state.error = action.payload || 'Không tải được thông báo';
})
.addCase(markNotificationRead.fulfilled, (state, action) => {
const item = state.items.find((n) => n.id === action.payload);
if (item) item.isRead = true;
})
.addCase(markAllNotificationsRead.fulfilled, (state) => {
state.items.forEach((n) => {
n.isRead = true;
});
});
},
});
export default notificationsSlice.reducer;

View File

@ -0,0 +1,81 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { apiFetch, unwrapList } from '../api/client';
// type='ERP' -> bucket 'erp'; type='MANUAL' -> bucket 'manual'.
function bucketOf(type) {
return type === 'ERP' ? 'erp' : 'manual';
}
// Lấy danh sách product theo type (ERP | MANUAL). Endpoint /api/products là public.
export const fetchProducts = createAsyncThunk('products/fetch', async (type) => {
const payload = await apiFetch('/products', { auth: false, params: { type, perPage: 200 } });
return { type, items: unwrapList(payload) };
});
// Tạo mới (không id) hoặc cập nhật (có id) — yêu cầu đăng nhập.
export const saveProduct = createAsyncThunk(
'products/save',
async ({ id, values }, { dispatch, rejectWithValue }) => {
try {
const product = id
? await apiFetch(`/products/${id}`, { method: 'PATCH', body: values })
: await apiFetch('/products', { method: 'POST', body: values });
// Nạp lại 2 danh sách để phản ánh thay đổi.
dispatch(fetchProducts('ERP'));
dispatch(fetchProducts('MANUAL'));
return product;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
const emptyBucket = () => ({ items: [], loading: false, error: null });
const productsSlice = createSlice({
name: 'products',
initialState: {
erp: emptyBucket(),
manual: emptyBucket(),
saving: false,
saveError: null,
},
reducers: {
clearSaveError(state) {
state.saveError = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchProducts.pending, (state, action) => {
const bucket = state[bucketOf(action.meta.arg)];
bucket.loading = true;
bucket.error = null;
})
.addCase(fetchProducts.fulfilled, (state, action) => {
const bucket = state[bucketOf(action.payload.type)];
bucket.loading = false;
bucket.items = action.payload.items;
})
.addCase(fetchProducts.rejected, (state, action) => {
const bucket = state[bucketOf(action.meta.arg)];
bucket.loading = false;
bucket.error = action.error.message || 'Không tải được danh sách';
})
.addCase(saveProduct.pending, (state) => {
state.saving = true;
state.saveError = null;
})
.addCase(saveProduct.fulfilled, (state) => {
state.saving = false;
})
.addCase(saveProduct.rejected, (state, action) => {
state.saving = false;
state.saveError = action.payload || 'Lưu sản phẩm thất bại';
});
},
});
export const { clearSaveError } = productsSlice.actions;
export default productsSlice.reducer;

View File

@ -0,0 +1,52 @@
import { createSlice } from '@reduxjs/toolkit';
// Form chỉ dùng các trường hiển thị theo yêu cầu: sku, condition, qty, price, warehouse.
export const emptyForm = {
sku: '',
condition: 'NEW',
qty: 0,
price: 0,
warehouse: '',
};
const uiSlice = createSlice({
name: 'ui',
initialState: {
activeTab: 'erp', // erp | manual | form | feed
editingId: null, // null = thêm mới, số = đang sửa
form: { ...emptyForm },
},
reducers: {
setActiveTab(state, action) {
state.activeTab = action.payload;
},
updateForm(state, action) {
const { name, value } = action.payload;
state.form[name] = value;
},
startAdd(state) {
state.editingId = null;
state.form = { ...emptyForm };
state.activeTab = 'form';
},
startEdit(state, action) {
const p = action.payload;
state.editingId = p.id;
state.form = {
sku: p.sku ?? '',
condition: p.condition ?? 'NEW',
qty: p.qty ?? 0,
price: p.price ?? 0,
warehouse: p.warehouse ?? '',
};
state.activeTab = 'form';
},
resetForm(state) {
state.editingId = null;
state.form = { ...emptyForm };
},
},
});
export const { setActiveTab, updateForm, startAdd, startEdit, resetForm } = uiSlice.actions;
export default uiSlice.reducer;

View File

@ -83,6 +83,85 @@ button.secondary {
align-items: stretch;
}
.topbar-actions {
gap: 10px;
}
/* --- Tabs --- */
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab {
background: white;
color: #374151;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
display: inline-flex;
align-items: center;
gap: 8px;
}
.tab.active {
background: #2563eb;
color: white;
}
.tab-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #ef4444;
color: white;
font-size: 11px;
font-weight: 700;
}
.tab-content {
display: block;
}
.tab-content .panel {
height: auto;
min-height: 60vh;
}
.tab-content .form-panel {
max-width: 520px;
}
.panel-header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.panel-hint {
color: #6b7280;
font-size: 14px;
}
.panel-error {
color: #b91c1c;
background: #fef2f2;
border-radius: 8px;
padding: 8px 12px;
font-size: 13px;
margin: 0 0 12px;
}
.empty-row {
text-align: center;
color: #9ca3af;
padding: 20px 0;
}
.panel {
background: white;
border-radius: 16px;
@ -182,16 +261,95 @@ button.secondary {
color: #166534;
}
.feed-item.info {
.feed-item.info,
.feed-item.news {
background: #eff6ff;
color: #1d4ed8;
}
.feed-item.error {
background: #fef2f2;
color: #b91c1c;
}
.feed-item.read {
opacity: 0.55;
}
.feed-item {
cursor: pointer;
align-items: flex-start;
}
.feed-body {
display: flex;
flex-direction: column;
gap: 2px;
}
.feed-body strong {
font-size: 14px;
}
.feed-body span {
font-size: 13px;
opacity: 0.85;
}
.feed-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
margin-top: 6px;
flex-shrink: 0;
}
/* --- Login --- */
.login-shell {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
background: white;
border-radius: 16px;
padding: 28px;
width: 100%;
max-width: 380px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.1);
display: grid;
gap: 14px;
}
.login-card h1 {
margin: 0;
font-size: 20px;
}
.login-sub {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.login-card label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #374151;
}
.login-card input {
border: 1px solid #d1d5db;
border-radius: 8px;
padding: 10px;
font-size: 14px;
}
@media (max-width: 1200px) {