Update
This commit is contained in:
parent
e14c504d01
commit
53e2f33064
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
createProductValidator,
|
createProductValidator,
|
||||||
updateProductValidator,
|
updateProductValidator,
|
||||||
listProductValidator,
|
listProductValidator,
|
||||||
|
setNoListingValidator,
|
||||||
} from '#validators/product'
|
} from '#validators/product'
|
||||||
|
|
||||||
export default class ProductsController {
|
export default class ProductsController {
|
||||||
|
|
@ -13,14 +14,15 @@ export default class ProductsController {
|
||||||
const params = await request.validateUsing(listProductValidator)
|
const params = await request.validateUsing(listProductValidator)
|
||||||
const page = params.page ?? 1
|
const page = params.page ?? 1
|
||||||
const perPage = params.perPage ?? 25
|
const perPage = params.perPage ?? 25
|
||||||
const order = params.order ?? 'created_at'
|
const order = params.order ?? 'id'
|
||||||
const direction: 'asc' | 'desc' = params.direction === 'asc' ? 'asc' : 'desc'
|
const direction: 'asc' | 'desc' = params.direction === 'desc' ? 'desc' : 'asc'
|
||||||
|
|
||||||
const query = Product.query()
|
const query = Product.query()
|
||||||
|
|
||||||
if (params.condition) query.where('condition', params.condition)
|
if (params.condition) query.where('condition', params.condition)
|
||||||
if (params.type) query.where('type', params.type)
|
if (params.type) query.where('type', params.type)
|
||||||
if (params.warehouse) query.where('warehouse', params.warehouse)
|
if (params.warehouse) query.where('warehouse', params.warehouse)
|
||||||
|
if (params.noListing !== undefined) query.where('no_listing', params.noListing)
|
||||||
if (params.sku) {
|
if (params.sku) {
|
||||||
// FULLTEXT khi có, fallback LIKE
|
// FULLTEXT khi có, fallback LIKE
|
||||||
query.where((b) => {
|
query.where((b) => {
|
||||||
|
|
@ -49,6 +51,16 @@ export default class ProductsController {
|
||||||
return ProductService.update(params.id, data, auth.getUserOrFail().username)
|
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 */
|
/** DELETE /api/products/:id */
|
||||||
async destroy({ params, response, auth }: HttpContext) {
|
async destroy({ params, response, auth }: HttpContext) {
|
||||||
await ProductService.destroy(params.id, auth.getUserOrFail().username)
|
await ProductService.destroy(params.id, auth.getUserOrFail().username)
|
||||||
|
|
|
||||||
|
|
@ -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...
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,10 @@ export default class Product extends BaseModel {
|
||||||
@column()
|
@column()
|
||||||
declare warehouse: string | null
|
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. */
|
/** true nếu sản phẩm được sync từ ERP. */
|
||||||
get isSynced() {
|
get isSynced() {
|
||||||
return this.erpId !== null && this.erpId !== undefined
|
return this.erpId !== null && this.erpId !== undefined
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,29 @@ const median = (arr: number[]) => {
|
||||||
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2
|
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
|
* Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based
|
||||||
* (port từ heuristic của prototype cũ).
|
* (port từ heuristic của prototype cũ).
|
||||||
|
|
@ -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 lô `AI_BATCH_CHUNK_SIZE`
|
||||||
|
* sản phẩm vào 1 request GPT (giảm số round-trip từ N -> N/chunk), các lô
|
||||||
|
* chạy song song có giới hạn. Trả về mảng cùng thứ tự với `inputs`.
|
||||||
|
*
|
||||||
|
* Item nào không có trong phản hồi AI (hoặc cả lô 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 ---
|
// --- Rule-based ---
|
||||||
private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion {
|
private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion {
|
||||||
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
||||||
|
|
@ -103,25 +158,82 @@ export default class AiService {
|
||||||
].join('\n')
|
].join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise<Suggestion> {
|
/**
|
||||||
const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25))
|
* System prompt cho engine AI ở chế độ BATCH: định giá cho một mảng sản phẩm
|
||||||
const payload = {
|
* 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 là mảng `items` và đầu ra là 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 và 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,
|
sku: product.sku,
|
||||||
condition: product.condition,
|
condition: product.condition,
|
||||||
cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null,
|
cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null,
|
||||||
supplier: dataSources,
|
supplier: this.compactPoints(dataSources),
|
||||||
ebaySold: dataEbay.sold,
|
ebaySold: this.compactPoints(dataEbay?.sold),
|
||||||
ebaySale: dataEbay.sale,
|
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 = {
|
const gptPayload = {
|
||||||
model: process.env.OPENAI_MODEL,
|
model: process.env.OPENAI_MODEL,
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: this.buildSystemPrompt(floorMarkup) },
|
{ role: 'system', content: systemPrompt },
|
||||||
{ role: 'user', content: JSON.stringify(payload) },
|
{ 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(
|
const remoteResp = await axios.post(
|
||||||
externalApiUrl + '/api/transferPostData',
|
externalApiUrl + '/api/transferPostData',
|
||||||
|
|
@ -138,7 +250,59 @@ export default class AiService {
|
||||||
if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') {
|
if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') {
|
||||||
throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data))
|
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 có 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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import xlsx from 'xlsx'
|
import xlsx from 'xlsx'
|
||||||
import ProductService from '#services/product_service'
|
import ProductService from '#services/product_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
import NotificationService from '#services/notification_service'
|
||||||
|
import type { NotificationType } from '#models/notification'
|
||||||
|
|
||||||
export interface ImportRowResult {
|
export interface ImportRowResult {
|
||||||
row: number
|
row: number
|
||||||
|
|
@ -76,6 +78,30 @@ export default class ImportService {
|
||||||
meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed },
|
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
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 có trạng thái đọc/chưa đọc và 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ import env from '#start/env'
|
||||||
import Product from '#models/product'
|
import Product from '#models/product'
|
||||||
import ErpService from '#services/erp_service'
|
import ErpService from '#services/erp_service'
|
||||||
import EbayService from '#services/ebay_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 type { Suggestion } from '#services/ai_service'
|
||||||
import HistoryService from '#services/history_service'
|
import HistoryService from '#services/history_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
|
@ -47,6 +47,77 @@ export default class PricingService {
|
||||||
|
|
||||||
const suggestion = await AiService.suggest({ product, dataSources, dataEbay })
|
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 có 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
|
||||||
|
* vì eBay/scrape nặng).
|
||||||
|
* 2. Gọi AI theo LÔ — nhiều product chung 1 request GPT (xem `AiService.suggestBatch`).
|
||||||
|
* 3. Lưu history/price cho từng product (song song có 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 đã có suggestion.
|
||||||
|
* Tách riêng để dùng chung giữa luồng single (`suggestForProduct`) và 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({
|
const history = await HistoryService.record({
|
||||||
username,
|
username,
|
||||||
productId: product.id,
|
productId: product.id,
|
||||||
|
|
@ -111,9 +182,9 @@ export default class PricingService {
|
||||||
return product
|
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[]> {
|
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)
|
return rows.map((r) => r.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { DateTime } from 'luxon'
|
||||||
import Product from '#models/product'
|
import Product from '#models/product'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
|
||||||
|
|
@ -21,6 +22,7 @@ interface ProductData {
|
||||||
type?: string | null
|
type?: string | null
|
||||||
erpId?: string | null
|
erpId?: string | null
|
||||||
warehouse?: string | null
|
warehouse?: string | null
|
||||||
|
noListing?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -73,6 +75,31 @@ export default class ProductService {
|
||||||
return product
|
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> {
|
static async destroy(id: number, username: string): Promise<void> {
|
||||||
const product = await Product.findOrFail(id)
|
const product = await Product.findOrFail(id)
|
||||||
const snapshot = product.serialize()
|
const snapshot = product.serialize()
|
||||||
|
|
|
||||||
|
|
@ -85,11 +85,23 @@ export async function enqueuePricingSuggest(productId: number, username: string)
|
||||||
return pricingQueue().add('suggest', { productId, username })
|
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 lô
|
||||||
|
* `PRICING_BATCH_CHUNK_SIZE`, mỗi lô 1 job `suggestBatch` (gộp 1 request GPT/lô).
|
||||||
|
* Giảm số job & số round-trip GPT từ N -> ceil(N/chunk).
|
||||||
|
*/
|
||||||
export async function enqueuePricingBatch(productIds: number[], username: string) {
|
export async function enqueuePricingBatch(productIds: number[], username: string) {
|
||||||
return pricingQueue().addBulk(
|
const jobs: Array<{ name: string; data: { productIds: number[]; username: string } }> = []
|
||||||
productIds.map((productId) => ({ name: 'suggest', data: { productId, username } }))
|
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). */
|
/** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import logger from '@adonisjs/core/services/logger'
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import ProductService from '#services/product_service'
|
import ProductService from '#services/product_service'
|
||||||
import LogService from '#services/log_service'
|
import LogService from '#services/log_service'
|
||||||
|
import NotificationService from '#services/notification_service'
|
||||||
import ErpService, { type ErpProductItem } from '#services/erp_service'
|
import ErpService, { type ErpProductItem } from '#services/erp_service'
|
||||||
import { enqueueProductUpserts } from '#services/queue_service'
|
import { enqueueProductUpserts } from '#services/queue_service'
|
||||||
import { convertCondition } from '#helpers/condition'
|
|
||||||
|
|
||||||
export interface SyncSummary {
|
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. */
|
/** 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.
|
* 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 }> {
|
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 }
|
return { sku: item.sku, condition: item.condition, created }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ const base = {
|
||||||
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
erpId: vine.string().trim().maxLength(191).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))
|
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(),
|
packageContain: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
type: vine.string().trim().maxLength(100).optional().nullable(),
|
type: vine.string().trim().maxLength(100).optional().nullable(),
|
||||||
erpId: vine.string().trim().maxLength(191).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({
|
vine.object({
|
||||||
page: vine.number().min(1).optional(),
|
page: vine.number().min(1).optional(),
|
||||||
perPage: vine.number().min(1).max(200).optional(),
|
perPage: vine.number().min(1).max(200).optional(),
|
||||||
sku: vine.string().trim(),
|
sku: vine.string().trim().optional(),
|
||||||
condition: vine.string().trim(),
|
condition: vine.string().trim().optional(),
|
||||||
warehouse: vine.string().trim().optional(),
|
warehouse: vine.string().trim().optional(),
|
||||||
type: vine.string().trim().optional(),
|
type: vine.string().trim().optional(),
|
||||||
order: vine.string().trim().optional(),
|
order: vine.string().trim().optional(),
|
||||||
direction: vine.string().trim().optional(),
|
direction: vine.string().trim().optional(),
|
||||||
|
noListing: vine.boolean().optional(),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ export default class AiSuggest extends BaseCommand {
|
||||||
async run() {
|
async run() {
|
||||||
const { default: Product } = await import('#models/product')
|
const { default: Product } = await import('#models/product')
|
||||||
const { default: PricingService } = await import('#services/pricing_service')
|
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: ErpService } = await import('#services/erp_service')
|
||||||
const { default: EbayService } = await import('#services/ebay_service')
|
const { default: EbayService } = await import('#services/ebay_service')
|
||||||
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
const { default: EbayScraperService } = await import('#services/ebay_scraper_service')
|
||||||
|
|
@ -89,51 +89,53 @@ export default class AiSuggest extends BaseCommand {
|
||||||
let ok = 0
|
let ok = 0
|
||||||
let fail = 0
|
let fail = 0
|
||||||
|
|
||||||
for (const id of ids) {
|
try {
|
||||||
try {
|
if (this.dryRun) {
|
||||||
if (this.dryRun) {
|
// Dry-run: chạy luồng AI theo LÔ (gộp request GPT) nhưng KHÔNG ghi DB.
|
||||||
// Dry-run: chạy y hệt luồng AI nhưng không ghi DB.
|
const products = await Product.query().whereIn('id', ids)
|
||||||
const product = await Product.findOrFail(id)
|
const inputs = await mapWithConcurrency(products, 5, async (product: any) => {
|
||||||
const [dataSources, dataEbay] = await Promise.all([
|
const [dataSources, dataEbay] = await Promise.all([
|
||||||
ErpService.getSupplierPricing(product),
|
ErpService.getSupplierPricing(product),
|
||||||
EbayService.getMarketData(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([
|
rows.push([
|
||||||
String(id),
|
String(input.product.id),
|
||||||
product.sku,
|
input.product.sku,
|
||||||
`$${Number(product.price)}`,
|
`$${Number(input.product.price)}`,
|
||||||
`$${suggestion.suggestedPrice}`,
|
`$${suggestion.suggestedPrice}`,
|
||||||
'—',
|
'—',
|
||||||
suggestion.engine,
|
suggestion.engine,
|
||||||
])
|
])
|
||||||
} else {
|
ok++
|
||||||
const result = await PricingService.suggestForProduct(id, this.username, this.force)
|
})
|
||||||
|
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([
|
rows.push([
|
||||||
String(id),
|
String(result.productId),
|
||||||
'', // SKU điền sau (suggestForProduct không trả sku)
|
skuMap.get(result.productId) ?? '',
|
||||||
`$${result.oldPrice}`,
|
`$${result.oldPrice}`,
|
||||||
`$${result.suggestion.suggestedPrice}`,
|
`$${result.suggestion.suggestedPrice}`,
|
||||||
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
|
result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt',
|
||||||
result.suggestion.engine,
|
result.suggestion.engine,
|
||||||
])
|
])
|
||||||
|
ok++
|
||||||
}
|
}
|
||||||
ok++
|
fail = ids.length - results.length
|
||||||
} 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)!
|
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Lỗi khi chạy batch: ${(error as Error).message}`)
|
||||||
|
fail = ids.length - ok
|
||||||
}
|
}
|
||||||
|
|
||||||
const table = this.ui.table()
|
const table = this.ui.table()
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,11 @@ export default class QueueWork extends BaseCommand {
|
||||||
const { productId, username } = job.data
|
const { productId, username } = job.data
|
||||||
return PricingService.suggestForProduct(productId, username)
|
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 }
|
{ connection: redisConnection, concurrency }
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ const ImportsController = () => import('#controllers/imports_controller')
|
||||||
const PricingController = () => import('#controllers/pricing_controller')
|
const PricingController = () => import('#controllers/pricing_controller')
|
||||||
const LogsController = () => import('#controllers/logs_controller')
|
const LogsController = () => import('#controllers/logs_controller')
|
||||||
const HistoriesController = () => import('#controllers/histories_controller')
|
const HistoriesController = () => import('#controllers/histories_controller')
|
||||||
|
const NotificationsController = () => import('#controllers/notifications_controller')
|
||||||
|
|
||||||
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
|
router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' }))
|
||||||
router.get('/api/health', async () => ({ ok: true }))
|
router.get('/api/health', async () => ({ ok: true }))
|
||||||
|
|
@ -27,6 +28,8 @@ router
|
||||||
// --- Products (CRUD: manual + sync) ---
|
// --- Products (CRUD: manual + sync) ---
|
||||||
|
|
||||||
router.post('/products', [ProductsController, 'store'])
|
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.get('/products/:id', [ProductsController, 'show'])
|
||||||
router.patch('/products/:id', [ProductsController, 'update'])
|
router.patch('/products/:id', [ProductsController, 'update'])
|
||||||
router.delete('/products/:id', [ProductsController, 'destroy'])
|
router.delete('/products/:id', [ProductsController, 'destroy'])
|
||||||
|
|
@ -43,6 +46,13 @@ router
|
||||||
router.get('/logs', [LogsController, 'index'])
|
router.get('/logs', [LogsController, 'index'])
|
||||||
router.get('/histories', [HistoriesController, 'index'])
|
router.get('/histories', [HistoriesController, 'index'])
|
||||||
router.get('/histories/:id', [HistoriesController, 'show'])
|
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())
|
.use(middleware.auth())
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,10 @@
|
||||||
"name": "suggestprice-web",
|
"name": "suggestprice-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.3.0",
|
||||||
"recharts": "^2.12.7"
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -749,6 +751,32 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.27",
|
"version": "1.0.0-beta.27",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||||
|
|
@ -1106,6 +1134,18 @@
|
||||||
"win32"
|
"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": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
|
|
@ -1221,6 +1261,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
|
|
@ -1583,6 +1629,16 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/internmap": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
|
@ -1781,6 +1837,29 @@
|
||||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|
@ -1855,6 +1934,27 @@
|
||||||
"decimal.js-light": "^2.4.1"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.62.2",
|
"version": "4.62.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||||
|
|
@ -1966,6 +2066,15 @@
|
||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/victory-vendor": {
|
||||||
"version": "36.9.2",
|
"version": "36.9.2",
|
||||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^2.12.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-redux": "^9.3.0",
|
||||||
"recharts": "^2.12.7"
|
"recharts": "^2.12.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -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 ProductTablePanel from './components/ProductTablePanel.jsx';
|
||||||
import ProductFormPanel from './components/ProductFormPanel.jsx';
|
import ProductFormPanel from './components/ProductFormPanel.jsx';
|
||||||
import FeedPanel from './components/FeedPanel.jsx';
|
import FeedPanel from './components/FeedPanel.jsx';
|
||||||
|
import LoginForm from './components/LoginForm.jsx';
|
||||||
const initialErpProducts = [
|
import { logout } from './store/authSlice';
|
||||||
{ id: 1, sku: 'ERP-001', title: 'Apple iPhone 15', category: 'Phone', price: 999, status: 'Active' },
|
import { fetchProducts, saveProduct } from './store/productsSlice';
|
||||||
{ id: 2, sku: 'ERP-002', title: 'Samsung Galaxy S24', category: 'Phone', price: 899, status: 'Active' },
|
import {
|
||||||
{ id: 3, sku: 'ERP-003', title: 'Sony WH-1000XM5', category: 'Audio', price: 349, status: 'Draft' },
|
fetchNotifications,
|
||||||
];
|
markNotificationRead,
|
||||||
|
markAllNotificationsRead,
|
||||||
const initialManualProducts = [
|
} from './store/notificationsSlice';
|
||||||
{ id: 10, sku: 'MAN-001', title: 'Dell XPS 13', category: 'Laptop', price: 1299, status: 'Listed' },
|
import { setActiveTab, updateForm, startAdd, startEdit, resetForm } from './store/uiSlice';
|
||||||
{ 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() {
|
export default function App() {
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(true);
|
const dispatch = useDispatch();
|
||||||
const [currentUser, setCurrentUser] = useState('Nguyễn Văn A');
|
const { user, token } = useSelector((state) => state.auth);
|
||||||
const [erpProducts, setErpProducts] = useState(initialErpProducts);
|
const { erp, manual, saving, saveError } = useSelector((state) => state.products);
|
||||||
const [manualProducts, setManualProducts] = useState(initialManualProducts);
|
const notifications = useSelector((state) => state.notifications);
|
||||||
const [feedEntries, setFeedEntries] = useState(initialFeed);
|
const { activeTab, editingId, form } = useSelector((state) => state.ui);
|
||||||
const [formMode, setFormMode] = useState('add');
|
|
||||||
const [form, setForm] = useState(initialForm);
|
|
||||||
|
|
||||||
function resetForm() {
|
// Nạp dữ liệu khi đã đăng nhập.
|
||||||
setFormMode('add');
|
useEffect(() => {
|
||||||
setForm(initialForm);
|
if (!token) return;
|
||||||
|
dispatch(fetchProducts('ERP'));
|
||||||
|
dispatch(fetchProducts('MANUAL'));
|
||||||
|
dispatch(fetchNotifications());
|
||||||
|
}, [dispatch, token]);
|
||||||
|
|
||||||
|
if (!token || !user) {
|
||||||
|
return <LoginForm />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectProduct(product, source) {
|
const unread = notifications.items.filter((n) => !n.isRead).length;
|
||||||
setFormMode('edit');
|
const tabs = [
|
||||||
setForm({
|
{ key: 'erp', label: 'Product ERP' },
|
||||||
sku: product.sku,
|
{ key: 'manual', label: 'Manual' },
|
||||||
title: product.title,
|
{ key: 'form', label: editingId ? 'Edit Product' : 'Add Product' },
|
||||||
category: product.category,
|
{ key: 'feed', label: 'New Feed', badge: unread },
|
||||||
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) {
|
function handleChange(event) {
|
||||||
const { name, value } = event.target;
|
const { name, value } = event.target;
|
||||||
setForm((prev) => ({ ...prev, [name]: value }));
|
dispatch(updateForm({ name, value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event) {
|
function handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
const values = {
|
||||||
|
sku: form.sku,
|
||||||
|
condition: form.condition,
|
||||||
|
qty: Number(form.qty) || 0,
|
||||||
|
price: Number(form.price) || 0,
|
||||||
|
warehouse: form.warehouse || null,
|
||||||
|
};
|
||||||
|
// Sản phẩm tạo tay thuộc tab Manual.
|
||||||
|
if (!editingId) values.type = 'MANUAL';
|
||||||
|
|
||||||
if (formMode === 'add') {
|
dispatch(saveProduct({ id: editingId, values })).then((action) => {
|
||||||
const newProduct = {
|
if (!action.error) {
|
||||||
id: Date.now(),
|
dispatch(resetForm());
|
||||||
sku: form.sku || `MAN-${Date.now()}`,
|
dispatch(setActiveTab('manual'));
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
[user.firstName, user.lastName].filter(Boolean).join(' ') || user.username;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-left">
|
<div className="topbar-left">
|
||||||
{isLoggedIn ? (
|
<span className="user-pill">Hi, {displayName}</span>
|
||||||
<span className="user-pill">Hi, {currentUser}</span>
|
|
||||||
) : (
|
|
||||||
<span className="user-pill">Chưa đăng nhập</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="page-title">Listing - Suggest Price</div>
|
<div className="page-title">Listing - Suggest Price</div>
|
||||||
|
|
||||||
<div className="topbar-actions">
|
<div className="topbar-actions">
|
||||||
{isLoggedIn ? (
|
<button type="button" onClick={() => dispatch(startAdd())}>
|
||||||
<button type="button" className="secondary" onClick={() => setIsLoggedIn(false)}>
|
+ Add Product
|
||||||
Logout
|
</button>
|
||||||
</button>
|
<button type="button" className="secondary" onClick={() => dispatch(logout())}>
|
||||||
) : (
|
Logout
|
||||||
<button type="button" onClick={() => setIsLoggedIn(true)}>
|
</button>
|
||||||
Login
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="dashboard-grid">
|
<Tabs tabs={tabs} active={activeTab} onChange={(key) => dispatch(setActiveTab(key))} />
|
||||||
<ProductTablePanel
|
|
||||||
title="Product ERP"
|
|
||||||
badge="Source"
|
|
||||||
products={erpProducts}
|
|
||||||
onSelect={(product) => handleSelectProduct(product, 'erp')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ProductTablePanel
|
<main className="tab-content">
|
||||||
title="Product Manual"
|
{activeTab === 'erp' && (
|
||||||
badge="Local"
|
<ProductTablePanel
|
||||||
products={manualProducts}
|
title="Product ERP"
|
||||||
onSelect={(product) => handleSelectProduct(product, 'manual')}
|
badge="type = ERP"
|
||||||
/>
|
bucket={erp}
|
||||||
|
onSelect={(product) => dispatch(startEdit(product))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<ProductFormPanel
|
{activeTab === 'manual' && (
|
||||||
formMode={formMode}
|
<ProductTablePanel
|
||||||
form={form}
|
title="Product Manual"
|
||||||
onChange={handleChange}
|
badge="type = MANUAL"
|
||||||
onSubmit={handleSubmit}
|
bucket={manual}
|
||||||
onImport={handleImportFromErp}
|
onSelect={(product) => dispatch(startEdit(product))}
|
||||||
onClear={resetForm}
|
/>
|
||||||
/>
|
)}
|
||||||
|
|
||||||
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 : [];
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,44 @@
|
||||||
export default function FeedPanel({ entries }) {
|
export default function FeedPanel({ items, loading, error, onMarkRead, onMarkAllRead }) {
|
||||||
return (
|
const unread = items.filter((n) => !n.isRead).length;
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-header">
|
return (
|
||||||
<h2>New Feed</h2>
|
<section className="panel">
|
||||||
<span className="panel-badge">Activity</span>
|
<div className="panel-header">
|
||||||
</div>
|
<h2>New Feed</h2>
|
||||||
<div className="feed-list">
|
<div className="panel-header-actions">
|
||||||
{entries.map((entry) => (
|
<span className="panel-badge">{unread} chưa đọc</span>
|
||||||
<div key={entry.id} className={`feed-item ${entry.type}`}>
|
{unread > 0 && (
|
||||||
<span className="feed-dot" />
|
<button type="button" className="secondary" onClick={onMarkAllRead}>
|
||||||
<span>{entry.message}</span>
|
Đọc tất cả
|
||||||
</div>
|
</button>
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 có 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,48 +1,56 @@
|
||||||
export default function ProductFormPanel({ formMode, form, onChange, onSubmit, onImport, onClear }) {
|
const CONDITIONS = [
|
||||||
return (
|
{ value: 'NEW', label: 'New' },
|
||||||
<section className="panel form-panel">
|
{ value: 'REF', label: 'Refurbished' },
|
||||||
<div className="panel-header">
|
{ value: 'USED', label: 'Used' },
|
||||||
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
|
];
|
||||||
{formMode === 'add' && (
|
|
||||||
<button type="button" className="secondary" onClick={onImport}>
|
export default function ProductFormPanel({ formMode, form, saving, error, onChange, onSubmit, onClear }) {
|
||||||
Import
|
return (
|
||||||
</button>
|
<section className="panel form-panel">
|
||||||
)}
|
<div className="panel-header">
|
||||||
</div>
|
<h2>{formMode === 'add' ? 'Add Product' : 'Edit Product'}</h2>
|
||||||
|
<span className="panel-badge">{formMode === 'add' ? 'Manual' : `#${form.sku}`}</span>
|
||||||
<form className="product-form" onSubmit={onSubmit}>
|
</div>
|
||||||
<label>
|
|
||||||
SKU
|
{error && <p className="panel-error">{error}</p>}
|
||||||
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" />
|
|
||||||
</label>
|
<form className="product-form" onSubmit={onSubmit}>
|
||||||
<label>
|
<label>
|
||||||
Tên sản phẩm
|
SKU
|
||||||
<input name="title" value={form.title} onChange={onChange} placeholder="Tên sản phẩm" />
|
<input name="sku" value={form.sku} onChange={onChange} placeholder="SKU" required />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Category
|
Condition
|
||||||
<input name="category" value={form.category} onChange={onChange} placeholder="Category" />
|
<select name="condition" value={form.condition} onChange={onChange}>
|
||||||
</label>
|
{CONDITIONS.map((c) => (
|
||||||
<label>
|
<option key={c.value} value={c.value}>
|
||||||
Price
|
{c.label}
|
||||||
<input name="price" type="number" value={form.price} onChange={onChange} placeholder="Price" />
|
</option>
|
||||||
</label>
|
))}
|
||||||
<label>
|
</select>
|
||||||
Status
|
</label>
|
||||||
<select name="status" value={form.status} onChange={onChange}>
|
<label>
|
||||||
<option value="Draft">Draft</option>
|
Qty
|
||||||
<option value="Listed">Listed</option>
|
<input name="qty" type="number" min="0" value={form.qty} onChange={onChange} placeholder="Qty" />
|
||||||
<option value="Active">Active</option>
|
</label>
|
||||||
</select>
|
<label>
|
||||||
</label>
|
Price
|
||||||
|
<input name="price" type="number" min="0" step="0.01" value={form.price} onChange={onChange} placeholder="Price" />
|
||||||
<div className="form-actions">
|
</label>
|
||||||
<button type="submit">{formMode === 'add' ? 'Add' : 'Save'}</button>
|
<label>
|
||||||
<button type="button" className="secondary" onClick={onClear}>
|
WH (Warehouse)
|
||||||
Clear
|
<input name="warehouse" value={form.warehouse} onChange={onChange} placeholder="Warehouse" />
|
||||||
</button>
|
</label>
|
||||||
</div>
|
|
||||||
</form>
|
<div className="form-actions">
|
||||||
</section>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,48 @@
|
||||||
export default function ProductTablePanel({ title, badge, products, onSelect }) {
|
export default function ProductTablePanel({ title, badge, bucket, onSelect }) {
|
||||||
return (
|
const { items, loading, error } = bucket;
|
||||||
<section className="panel">
|
|
||||||
<div className="panel-header">
|
return (
|
||||||
<h2>{title}</h2>
|
<section className="panel">
|
||||||
<span className="panel-badge">{badge}</span>
|
<div className="panel-header">
|
||||||
</div>
|
<h2>{title}</h2>
|
||||||
<table className="data-table">
|
<span className="panel-badge">{badge}</span>
|
||||||
<thead>
|
</div>
|
||||||
<tr>
|
|
||||||
<th>SKU</th>
|
{loading && <p className="panel-hint">Đang tải…</p>}
|
||||||
<th>Tên</th>
|
{error && <p className="panel-error">{error}</p>}
|
||||||
<th>Category</th>
|
|
||||||
<th>Price</th>
|
{!loading && !error && (
|
||||||
<th>Status</th>
|
<table className="data-table">
|
||||||
</tr>
|
<thead>
|
||||||
</thead>
|
<tr>
|
||||||
<tbody>
|
<th>SKU</th>
|
||||||
{products.map((product) => (
|
<th>Condition</th>
|
||||||
<tr key={product.id} onClick={() => onSelect(product)}>
|
<th>Qty</th>
|
||||||
<td>{product.sku}</td>
|
<th>Price</th>
|
||||||
<td>{product.title}</td>
|
<th>WH</th>
|
||||||
<td>{product.category}</td>
|
</tr>
|
||||||
<td>${product.price}</td>
|
</thead>
|
||||||
<td>{product.status}</td>
|
<tbody>
|
||||||
</tr>
|
{items.length === 0 ? (
|
||||||
))}
|
<tr>
|
||||||
</tbody>
|
<td colSpan={5} className="empty-row">
|
||||||
</table>
|
Chưa có sản phẩm
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import store from './store/index.js';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<Provider store={store}>
|
||||||
|
<App />
|
||||||
|
</Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -83,6 +83,85 @@ button.secondary {
|
||||||
align-items: stretch;
|
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 {
|
.panel {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
|
|
@ -182,16 +261,95 @@ button.secondary {
|
||||||
color: #166534;
|
color: #166534;
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-item.info {
|
.feed-item.info,
|
||||||
|
.feed-item.news {
|
||||||
background: #eff6ff;
|
background: #eff6ff;
|
||||||
color: #1d4ed8;
|
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 {
|
.feed-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: currentColor;
|
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) {
|
@media (max-width: 1200px) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue