diff --git a/backend/app/controllers/notifications_controller.ts b/backend/app/controllers/notifications_controller.ts new file mode 100644 index 0000000..d320e78 --- /dev/null +++ b/backend/app/controllers/notifications_controller.ts @@ -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() + } +} diff --git a/backend/app/controllers/products_controller.ts b/backend/app/controllers/products_controller.ts index 124fd9d..b0412aa 100644 --- a/backend/app/controllers/products_controller.ts +++ b/backend/app/controllers/products_controller.ts @@ -5,6 +5,7 @@ import { createProductValidator, updateProductValidator, listProductValidator, + setNoListingValidator, } from '#validators/product' export default class ProductsController { @@ -13,14 +14,15 @@ export default class ProductsController { const params = await request.validateUsing(listProductValidator) const page = params.page ?? 1 const perPage = params.perPage ?? 25 - const order = params.order ?? 'created_at' - const direction: 'asc' | 'desc' = params.direction === 'asc' ? 'asc' : 'desc' + const order = params.order ?? 'id' + const direction: 'asc' | 'desc' = params.direction === 'desc' ? 'desc' : 'asc' const query = Product.query() if (params.condition) query.where('condition', params.condition) if (params.type) query.where('type', params.type) if (params.warehouse) query.where('warehouse', params.warehouse) + if (params.noListing !== undefined) query.where('no_listing', params.noListing) if (params.sku) { // FULLTEXT khi có, fallback LIKE query.where((b) => { @@ -49,6 +51,16 @@ export default class ProductsController { return ProductService.update(params.id, data, auth.getUserOrFail().username) } + /** + * PATCH /api/products/no-listing + * body: { ids: number[], noListing: boolean } + * Gắn / bỏ cờ noListing hàng loạt (bỏ qua khi gợi ý giá). + */ + async setNoListing({ request, auth }: HttpContext) { + const { ids, noListing } = await request.validateUsing(setNoListingValidator) + return ProductService.setNoListing(ids, noListing, auth.getUserOrFail().username) + } + /** DELETE /api/products/:id */ async destroy({ params, response, auth }: HttpContext) { await ProductService.destroy(params.id, auth.getUserOrFail().username) diff --git a/backend/app/models/notification.ts b/backend/app/models/notification.ts new file mode 100644 index 0000000..38522b5 --- /dev/null +++ b/backend/app/models/notification.ts @@ -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 | null + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime +} diff --git a/backend/app/models/product.ts b/backend/app/models/product.ts index b3c1d32..b42e6bc 100644 --- a/backend/app/models/product.ts +++ b/backend/app/models/product.ts @@ -65,6 +65,10 @@ export default class Product extends BaseModel { @column() declare warehouse: string | null + /** true = sản phẩm rác / không cần gợi ý giá (bỏ qua khi chạy gợi ý giá). */ + @column() + declare noListing: boolean + /** true nếu sản phẩm được sync từ ERP. */ get isSynced() { return this.erpId !== null && this.erpId !== undefined diff --git a/backend/app/services/ai_service.ts b/backend/app/services/ai_service.ts index badcf73..dca94be 100644 --- a/backend/app/services/ai_service.ts +++ b/backend/app/services/ai_service.ts @@ -25,6 +25,29 @@ const median = (arr: number[]) => { return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2 } +/** Số sản phẩm gộp trong 1 request GPT (batch). */ +export const AI_BATCH_CHUNK_SIZE = 25 +/** Số điểm giá tối đa (mới nhất) gửi kèm mỗi nguồn để tiết kiệm token. */ +const MAX_POINTS_PER_SOURCE = 25 + +/** Chạy `fn` trên `items` với concurrency giới hạn, giữ nguyên thứ tự kết quả. */ +export async function mapWithConcurrency( + items: T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results = new Array(items.length) + let cursor = 0 + const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, async () => { + while (cursor < items.length) { + const index = cursor++ + results[index] = await fn(items[index], index) + } + }) + await Promise.all(workers) + return results +} + /** * Engine gợi ý giá: dùng OpenAI khi cấu hình, ngược lại fallback rule-based * (port từ heuristic của prototype 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 { + 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(inputs.length) + await mapWithConcurrency(chunks, 5, async (chunk) => { + let byId: Map + try { + byId = await this.openAiBatch(chunk.items) + } catch (error) { + logger.error({ err: error }, 'OpenAI batch suggest lỗi — fallback rule-based cả lô') + byId = new Map() + } + chunk.items.forEach((input, idx) => { + results[chunk.start + idx] = byId.get(input.product.id) ?? this.ruleBased(input) + }) + }) + return results + } + // --- Rule-based --- private static ruleBased({ product, dataSources, dataEbay }: SuggestionInput): Suggestion { const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) @@ -103,25 +158,82 @@ export default class AiService { ].join('\n') } - private static async openAi({ product, dataSources, dataEbay }: SuggestionInput): Promise { - const floorMarkup = Number(env.get('PRICING_FLOOR_MARKUP', 1.25)) - const payload = { + /** + * System prompt cho engine AI ở chế độ BATCH: định giá cho một mảng sản phẩm + * trong cùng một request. Giữ nguyên quy tắc định giá như bản single, chỉ khác + * ở chỗ đầu vào 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>): Array<{ price: number; date?: string }> { + if (!Array.isArray(points)) return [] + return points + .map((p) => ({ + price: Number(p?.price), + date: typeof p?.date === 'string' ? p.date : undefined, + })) + .filter((p) => Number.isFinite(p.price) && p.price > 0) + .sort((a, b) => (b.date ?? '').localeCompare(a.date ?? '')) + .slice(0, MAX_POINTS_PER_SOURCE) + } + + /** Payload gọn cho 1 sản phẩm (dùng chung cho cả single & batch). */ + private static buildItemPayload({ product, dataSources, dataEbay }: SuggestionInput) { + return { + id: product.id, sku: product.sku, condition: product.condition, cost: product.costs?.find((entry) => entry.currency === 'USD')?.price ?? product.costs?.[0]?.price ?? null, - supplier: dataSources, - ebaySold: dataEbay.sold, - ebaySale: dataEbay.sale, + supplier: this.compactPoints(dataSources), + ebaySold: this.compactPoints(dataEbay?.sold), + ebaySale: this.compactPoints(dataEbay?.sale), } + } + + /** Gọi ERP proxy -> GPT, trả về data thô để normalize. */ + private static async callGpt(systemPrompt: string, userPayload: unknown): Promise { const gptPayload = { model: process.env.OPENAI_MODEL, response_format: { type: 'json_object' }, messages: [ - { role: 'system', content: this.buildSystemPrompt(floorMarkup) }, - { role: 'user', content: JSON.stringify(payload) }, + { role: 'system', content: systemPrompt }, + { role: 'user', content: JSON.stringify(userPayload) }, ], } - const externalApiUrl = process.env.ERP_API_URL; + const externalApiUrl = process.env.ERP_API_URL const remoteResp = await axios.post( externalApiUrl + '/api/transferPostData', @@ -138,7 +250,59 @@ export default class AiService { if (!remoteResp.data?.Status || remoteResp.data?.Status !== 'OK') { throw new Error('OpenAI suggest lỗi: ' + JSON.stringify(remoteResp.data)) } - return this.normalize(remoteResp.data?.data) + return remoteResp.data?.data + } + + private static async openAi(input: SuggestionInput): Promise { + 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> { + 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 { + 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() + 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 } /** diff --git a/backend/app/services/import_service.ts b/backend/app/services/import_service.ts index f00d8ec..3e0566a 100644 --- a/backend/app/services/import_service.ts +++ b/backend/app/services/import_service.ts @@ -1,6 +1,8 @@ import xlsx from 'xlsx' import ProductService from '#services/product_service' import LogService from '#services/log_service' +import NotificationService from '#services/notification_service' +import type { NotificationType } from '#models/notification' export interface ImportRowResult { row: number @@ -76,6 +78,30 @@ export default class ImportService { meta: { total: summary.total, created: summary.created, updated: summary.updated, failed: summary.failed }, }) + // Thông báo cho người dùng: cảnh báo nếu import trùng / có dòng lỗi, ngược lại báo thành công. + const type: NotificationType = + summary.failed > 0 ? 'error' : summary.updated > 0 ? 'warning' : 'success' + const parts: string[] = [] + if (summary.created > 0) parts.push(`${summary.created} sản phẩm mới`) + if (summary.updated > 0) parts.push(`${summary.updated} sản phẩm đã tồn tại (import trùng, đã cập nhật)`) + if (summary.failed > 0) parts.push(`${summary.failed} dòng lỗi`) + await NotificationService.notify({ + type, + title: + summary.updated > 0 + ? 'Import trùng sản phẩm đã có' + : summary.failed > 0 + ? 'Import Excel có lỗi' + : 'Import Excel thành công', + message: `Đã xử lý ${summary.total} dòng: ${parts.join(', ') || 'không có thay đổi'}.`, + meta: { + total: summary.total, + created: summary.created, + updated: summary.updated, + failed: summary.failed, + }, + }) + return summary } diff --git a/backend/app/services/notification_service.ts b/backend/app/services/notification_service.ts new file mode 100644 index 0000000..b8e88a0 --- /dev/null +++ b/backend/app/services/notification_service.ts @@ -0,0 +1,84 @@ +import Notification, { type NotificationType } from '#models/notification' + +interface NotifyInput { + type: NotificationType + title: string + message?: string | null + meta?: Record | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const notification = await Notification.findOrFail(id) + await notification.delete() + } +} diff --git a/backend/app/services/pricing_service.ts b/backend/app/services/pricing_service.ts index d8ceb48..9062220 100644 --- a/backend/app/services/pricing_service.ts +++ b/backend/app/services/pricing_service.ts @@ -2,7 +2,7 @@ import env from '#start/env' import Product from '#models/product' import ErpService from '#services/erp_service' import EbayService from '#services/ebay_service' -import AiService from '#services/ai_service' +import AiService, { mapWithConcurrency } from '#services/ai_service' import type { Suggestion } from '#services/ai_service' import HistoryService from '#services/history_service' import LogService from '#services/log_service' @@ -47,6 +47,77 @@ export default class PricingService { const suggestion = await AiService.suggest({ product, dataSources, dataEbay }) + return this.persist({ product, dataSources, dataEbay, suggestion, username, forceApply }) + } + + /** + * Gợi ý giá cho NHIỀU product một lượt (batch). + * + * Tối ưu tốc độ khi 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 { + 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> + dataEbay: Awaited> + suggestion: Suggestion + username: string + forceApply: boolean + }): Promise { const history = await HistoryService.record({ username, productId: product.id, @@ -111,9 +182,9 @@ export default class PricingService { return product } - /** Lấy danh sách id product để chạy batch (mặc định toàn bộ). */ + /** Lấy danh sách id product để chạy batch (mặc định toàn bộ, trừ sản phẩm noListing). */ static async productIdsForBatch(): Promise { - const rows = await Product.query().select('id') + const rows = await Product.query().where('no_listing', false).select('id') return rows.map((r) => r.id) } } diff --git a/backend/app/services/product_service.ts b/backend/app/services/product_service.ts index 18ed2ed..c0565d8 100644 --- a/backend/app/services/product_service.ts +++ b/backend/app/services/product_service.ts @@ -1,3 +1,4 @@ +import { DateTime } from 'luxon' import Product from '#models/product' import LogService from '#services/log_service' @@ -21,6 +22,7 @@ interface ProductData { type?: string | null erpId?: string | null warehouse?: string | null + noListing?: boolean } /** @@ -73,6 +75,31 @@ export default class ProductService { return product } + /** + * Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox). + * Sản phẩm noListing sẽ bị bỏ qua khi chạy gợi ý giá. + */ + static async setNoListing( + ids: number[], + noListing: boolean, + username: string + ): Promise<{ updated: number }> { + if (!ids.length) return { updated: 0 } + + const updated = await Product.query() + .whereIn('id', ids) + .update({ no_listing: noListing, updated_at: DateTime.now().toSQL() }) + + await LogService.record({ + username, + actionName: noListing ? 'Gắn cờ không gợi ý giá' : 'Bỏ cờ không gợi ý giá', + action: 'update', + meta: { ids, noListing }, + }) + + return { updated: Array.isArray(updated) ? Number(updated[0]) : Number(updated) } + } + static async destroy(id: number, username: string): Promise { const product = await Product.findOrFail(id) const snapshot = product.serialize() diff --git a/backend/app/services/queue_service.ts b/backend/app/services/queue_service.ts index 7e48b81..dfe9a95 100644 --- a/backend/app/services/queue_service.ts +++ b/backend/app/services/queue_service.ts @@ -85,11 +85,23 @@ export async function enqueuePricingSuggest(productId: number, username: string) return pricingQueue().add('suggest', { productId, username }) } -/** Đẩy nhiều job gợi ý giá (batch). */ +/** Số product gộp trong 1 job batch (mỗi job xử lý 1 request GPT gộp). */ +export const PRICING_BATCH_CHUNK_SIZE = 25 + +/** + * Đẩy job gợi ý giá hàng loạt: chia `productIds` thành các 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) { - return pricingQueue().addBulk( - productIds.map((productId) => ({ name: 'suggest', data: { productId, username } })) - ) + const jobs: Array<{ name: string; data: { productIds: number[]; username: string } }> = [] + for (let i = 0; i < productIds.length; i += PRICING_BATCH_CHUNK_SIZE) { + jobs.push({ + name: 'suggestBatch', + data: { productIds: productIds.slice(i, i + PRICING_BATCH_CHUNK_SIZE), username }, + }) + } + return pricingQueue().addBulk(jobs) } /** Đẩy job orchestrator đồng bộ ERP (job này sẽ tự fan-out các job upsert). */ diff --git a/backend/app/services/sync_service.ts b/backend/app/services/sync_service.ts index 7ebc4ce..2099355 100644 --- a/backend/app/services/sync_service.ts +++ b/backend/app/services/sync_service.ts @@ -1,9 +1,9 @@ import logger from '@adonisjs/core/services/logger' import ProductService from '#services/product_service' import LogService from '#services/log_service' +import NotificationService from '#services/notification_service' import ErpService, { type ErpProductItem } from '#services/erp_service' import { enqueueProductUpserts } from '#services/queue_service' -import { convertCondition } from '#helpers/condition' export interface SyncSummary { /** Tổng số bản ghi ERP tự báo cáo (field `total`) — CHỈ tham khảo, không tin cậy. */ @@ -114,7 +114,23 @@ export default class SyncService { * Cố tình ném lỗi khi thất bại để BullMQ tự retry job. */ static async upsertProduct(item: ErpProductItem): Promise<{ sku: string; condition: string; created: boolean }> { - const { created } = await ProductService.upsert(item) + const { product, created } = await ProductService.upsert(item) + + // Sản phẩm MỚI từ ERP -> báo cho người dùng xem có cần gắn cờ noListing (bỏ qua gợi ý giá) không. + if (created) { + await NotificationService.notify({ + type: 'news', + title: 'Sản phẩm mới từ ERP', + message: `SKU ${product.sku} (${product.condition}) vừa được đồng bộ. Kiểm tra xem có cần gắn cờ "Không gợi ý giá" cho sản phẩm này không.`, + meta: { + productId: product.id, + sku: product.sku, + condition: product.condition, + warehouse: product.warehouse, + }, + }) + } + return { sku: item.sku, condition: item.condition, created } } } diff --git a/backend/app/validators/product.ts b/backend/app/validators/product.ts index 80f7d7a..b4ffd54 100644 --- a/backend/app/validators/product.ts +++ b/backend/app/validators/product.ts @@ -15,6 +15,8 @@ const base = { packageContain: vine.string().trim().maxLength(100).optional().nullable(), type: vine.string().trim().maxLength(100).optional().nullable(), erpId: vine.string().trim().maxLength(191).optional().nullable(), + warehouse: vine.string().trim().maxLength(100).optional().nullable(), + noListing: vine.boolean().optional(), } export const createProductValidator = vine.compile(vine.object(base)) @@ -35,6 +37,18 @@ export const updateProductValidator = vine.compile( packageContain: vine.string().trim().maxLength(100).optional().nullable(), type: vine.string().trim().maxLength(100).optional().nullable(), erpId: vine.string().trim().maxLength(191).optional().nullable(), + warehouse: vine.string().trim().maxLength(100).optional().nullable(), + noListing: vine.boolean().optional(), + }) +) + +/** + * Gắn / bỏ cờ noListing hàng loạt (frontend chọn nhiều sản phẩm rồi tick checkbox). + */ +export const setNoListingValidator = vine.compile( + vine.object({ + ids: vine.array(vine.number().min(1)).minLength(1), + noListing: vine.boolean(), }) ) @@ -42,11 +56,12 @@ export const listProductValidator = vine.compile( vine.object({ page: vine.number().min(1).optional(), perPage: vine.number().min(1).max(200).optional(), - sku: vine.string().trim(), - condition: vine.string().trim(), + sku: vine.string().trim().optional(), + condition: vine.string().trim().optional(), warehouse: vine.string().trim().optional(), type: vine.string().trim().optional(), order: vine.string().trim().optional(), direction: vine.string().trim().optional(), + noListing: vine.boolean().optional(), }) ) diff --git a/backend/commands/ai_suggest.ts b/backend/commands/ai_suggest.ts index cd580cf..0336ff3 100644 --- a/backend/commands/ai_suggest.ts +++ b/backend/commands/ai_suggest.ts @@ -54,7 +54,7 @@ export default class AiSuggest extends BaseCommand { async run() { const { default: Product } = await import('#models/product') const { default: PricingService } = await import('#services/pricing_service') - const { default: AiService } = await import('#services/ai_service') + const { default: AiService, mapWithConcurrency } = await import('#services/ai_service') const { default: ErpService } = await import('#services/erp_service') const { default: EbayService } = await import('#services/ebay_service') const { default: EbayScraperService } = await import('#services/ebay_scraper_service') @@ -89,51 +89,53 @@ export default class AiSuggest extends BaseCommand { let ok = 0 let fail = 0 - for (const id of ids) { - try { - if (this.dryRun) { - // Dry-run: chạy y hệt luồng AI nhưng không ghi DB. - const product = await Product.findOrFail(id) + try { + if (this.dryRun) { + // Dry-run: chạy luồng AI theo LÔ (gộp request GPT) nhưng KHÔNG ghi DB. + const products = await Product.query().whereIn('id', ids) + const inputs = await mapWithConcurrency(products, 5, async (product: any) => { const [dataSources, dataEbay] = await Promise.all([ ErpService.getSupplierPricing(product), EbayService.getMarketData(product), ]) - const suggestion = await AiService.suggest({ product, dataSources, dataEbay }) + return { product, dataSources, dataEbay } + }) + const suggestions = await AiService.suggestBatch(inputs) + inputs.forEach((input, idx) => { + const suggestion = suggestions[idx] rows.push([ - String(id), - product.sku, - `$${Number(product.price)}`, + String(input.product.id), + input.product.sku, + `$${Number(input.product.price)}`, `$${suggestion.suggestedPrice}`, '—', suggestion.engine, ]) - } else { - const result = await PricingService.suggestForProduct(id, this.username, this.force) + ok++ + }) + fail = ids.length - inputs.length + } else { + // Non-dry-run: gợi ý + lưu DB theo LÔ. + const results = await PricingService.suggestForProducts(ids, this.username, this.force) + const prods = await Product.query().whereIn('id', ids).select('id', 'sku') + const skuMap = new Map() + prods.forEach((p: any) => skuMap.set(p.id, p.sku)) + for (const result of results) { rows.push([ - String(id), - '', // SKU điền sau (suggestForProduct không trả sku) + String(result.productId), + skuMap.get(result.productId) ?? '', `$${result.oldPrice}`, `$${result.suggestion.suggestedPrice}`, result.applied ? `áp -> $${result.newPrice}` : 'chờ duyệt', result.suggestion.engine, ]) + ok++ } - ok++ - } catch (error) { - fail++ - rows.push([String(id), '—', '—', '—', `LỖI: ${(error as Error).message}`, '—']) - } - } - - // SKU cho nhánh non-dry-run (suggestForProduct không trả sku) — nạp 1 lần. - const skuMap = new Map() - if (!this.dryRun) { - const prods = await Product.query().whereIn('id', ids).select('id', 'sku') - prods.forEach((p: any) => skuMap.set(p.id, p.sku)) - for (const r of rows) { - const idNum = Number(r[0]) - if (skuMap.has(idNum)) r[1] = skuMap.get(idNum)! + fail = ids.length - results.length } + } catch (error) { + this.logger.error(`Lỗi khi chạy batch: ${(error as Error).message}`) + fail = ids.length - ok } const table = this.ui.table() diff --git a/backend/commands/queue_work.ts b/backend/commands/queue_work.ts index 6d77724..495893a 100644 --- a/backend/commands/queue_work.ts +++ b/backend/commands/queue_work.ts @@ -33,6 +33,11 @@ export default class QueueWork extends BaseCommand { const { productId, username } = job.data return PricingService.suggestForProduct(productId, username) } + // Batch: 1 job = 1 lô product = 1 request GPT gộp (xem enqueuePricingBatch). + if (job.name === 'suggestBatch') { + const { productIds, username } = job.data + return PricingService.suggestForProducts(productIds, username) + } }, { connection: redisConnection, concurrency } ) diff --git a/backend/database/migrations/1790000000001_add_no_listing_to_products_table.ts b/backend/database/migrations/1790000000001_add_no_listing_to_products_table.ts new file mode 100644 index 0000000..8c8f101 --- /dev/null +++ b/backend/database/migrations/1790000000001_add_no_listing_to_products_table.ts @@ -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') + }) + } +} diff --git a/backend/database/migrations/1790000000002_create_notifications_table.ts b/backend/database/migrations/1790000000002_create_notifications_table.ts new file mode 100644 index 0000000..9ddfc09 --- /dev/null +++ b/backend/database/migrations/1790000000002_create_notifications_table.ts @@ -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) + } +} diff --git a/backend/start/routes.ts b/backend/start/routes.ts index 46e033b..2ce4b04 100644 --- a/backend/start/routes.ts +++ b/backend/start/routes.ts @@ -7,6 +7,7 @@ const ImportsController = () => import('#controllers/imports_controller') const PricingController = () => import('#controllers/pricing_controller') const LogsController = () => import('#controllers/logs_controller') const HistoriesController = () => import('#controllers/histories_controller') +const NotificationsController = () => import('#controllers/notifications_controller') router.get('/', async () => ({ service: 'suggestprice-api', status: 'ok' })) router.get('/api/health', async () => ({ ok: true })) @@ -27,6 +28,8 @@ router // --- Products (CRUD: manual + sync) --- router.post('/products', [ProductsController, 'store']) + // Gắn/bỏ cờ noListing hàng loạt — khai báo TRƯỚC '/products/:id' để không bị nuốt bởi route động. + router.patch('/products/no-listing', [ProductsController, 'setNoListing']) router.get('/products/:id', [ProductsController, 'show']) router.patch('/products/:id', [ProductsController, 'update']) router.delete('/products/:id', [ProductsController, 'destroy']) @@ -43,6 +46,13 @@ router router.get('/logs', [LogsController, 'index']) router.get('/histories', [HistoriesController, 'index']) router.get('/histories/:id', [HistoriesController, 'show']) + + // --- Notifications (thông báo cho người dùng) --- + router.get('/notifications', [NotificationsController, 'index']) + router.get('/notifications/unread-count', [NotificationsController, 'unreadCount']) + router.patch('/notifications/read-all', [NotificationsController, 'markAllRead']) + router.patch('/notifications/:id/read', [NotificationsController, 'markRead']) + router.delete('/notifications/:id', [NotificationsController, 'destroy']) }) .use(middleware.auth()) }) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0a6bc14..ceb3fb1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,10 @@ "name": "suggestprice-web", "version": "0.1.0", "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.3.0", "recharts": "^2.12.7" }, "devDependencies": { @@ -749,6 +751,32 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1106,6 +1134,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1221,6 +1261,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1583,6 +1629,16 @@ "node": ">=6.9.0" } }, + "node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/internmap": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -1781,6 +1837,29 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1855,6 +1934,27 @@ "decimal.js-light": "^2.4.1" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", + "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "license": "MIT" + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1966,6 +2066,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index b605c63..a36b881 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,10 @@ "preview": "vite preview" }, "dependencies": { + "@reduxjs/toolkit": "^2.12.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-redux": "^9.3.0", "recharts": "^2.12.7" }, "devDependencies": { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e73ea29..9eeced3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,167 +1,135 @@ -import { useState } from 'react'; +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Tabs from './components/Tabs.jsx'; import ProductTablePanel from './components/ProductTablePanel.jsx'; import ProductFormPanel from './components/ProductFormPanel.jsx'; import FeedPanel from './components/FeedPanel.jsx'; - -const initialErpProducts = [ - { id: 1, sku: 'ERP-001', title: 'Apple iPhone 15', category: 'Phone', price: 999, status: 'Active' }, - { id: 2, sku: 'ERP-002', title: 'Samsung Galaxy S24', category: 'Phone', price: 899, status: 'Active' }, - { id: 3, sku: 'ERP-003', title: 'Sony WH-1000XM5', category: 'Audio', price: 349, status: 'Draft' }, -]; - -const initialManualProducts = [ - { id: 10, sku: 'MAN-001', title: 'Dell XPS 13', category: 'Laptop', price: 1299, status: 'Listed' }, - { id: 11, sku: 'MAN-002', title: 'Logitech MX Master 3', category: 'Accessory', price: 99, status: 'Draft' }, -]; - -const initialFeed = [ - { id: 1, message: 'Add sản phẩm tên ABCD bị trùng', type: 'warning' }, - { id: 2, message: 'List thành công sản phẩm XYZ', type: 'success' }, -]; - -const initialForm = { - sku: '', - title: '', - category: '', - price: '', - status: 'Draft', -}; +import LoginForm from './components/LoginForm.jsx'; +import { logout } from './store/authSlice'; +import { fetchProducts, saveProduct } from './store/productsSlice'; +import { + fetchNotifications, + markNotificationRead, + markAllNotificationsRead, +} from './store/notificationsSlice'; +import { setActiveTab, updateForm, startAdd, startEdit, resetForm } from './store/uiSlice'; export default function App() { - const [isLoggedIn, setIsLoggedIn] = useState(true); - const [currentUser, setCurrentUser] = useState('Nguyễn Văn A'); - const [erpProducts, setErpProducts] = useState(initialErpProducts); - const [manualProducts, setManualProducts] = useState(initialManualProducts); - const [feedEntries, setFeedEntries] = useState(initialFeed); - const [formMode, setFormMode] = useState('add'); - const [form, setForm] = useState(initialForm); + const dispatch = useDispatch(); + const { user, token } = useSelector((state) => state.auth); + const { erp, manual, saving, saveError } = useSelector((state) => state.products); + const notifications = useSelector((state) => state.notifications); + const { activeTab, editingId, form } = useSelector((state) => state.ui); - function resetForm() { - setFormMode('add'); - setForm(initialForm); + // Nạp dữ liệu khi đã đăng nhập. + useEffect(() => { + if (!token) return; + dispatch(fetchProducts('ERP')); + dispatch(fetchProducts('MANUAL')); + dispatch(fetchNotifications()); + }, [dispatch, token]); + + if (!token || !user) { + return ; } - function handleSelectProduct(product, source) { - setFormMode('edit'); - setForm({ - sku: product.sku, - title: product.title, - category: product.category, - price: product.price, - status: product.status, - }); - - if (source === 'erp') { - setFeedEntries((prev) => [ - { id: Date.now(), message: `Đã chọn ERP product ${product.title} để chỉnh sửa`, type: 'info' }, - ...prev, - ]); - } - } - - function handleImportFromErp() { - const selected = erpProducts[0]; - if (!selected) return; - setForm({ - sku: selected.sku, - title: selected.title, - category: selected.category, - price: selected.price, - status: 'Draft', - }); - setFeedEntries((prev) => [ - { id: Date.now(), message: `Import sản phẩm ${selected.title} từ ERP`, type: 'info' }, - ...prev, - ]); - } + const unread = notifications.items.filter((n) => !n.isRead).length; + const tabs = [ + { key: 'erp', label: 'Product ERP' }, + { key: 'manual', label: 'Manual' }, + { key: 'form', label: editingId ? 'Edit Product' : 'Add Product' }, + { key: 'feed', label: 'New Feed', badge: unread }, + ]; function handleChange(event) { const { name, value } = event.target; - setForm((prev) => ({ ...prev, [name]: value })); + dispatch(updateForm({ name, value })); } function handleSubmit(event) { event.preventDefault(); + const values = { + sku: form.sku, + condition: form.condition, + qty: Number(form.qty) || 0, + price: Number(form.price) || 0, + warehouse: form.warehouse || null, + }; + // Sản phẩm tạo tay thuộc tab Manual. + if (!editingId) values.type = 'MANUAL'; - if (formMode === 'add') { - const newProduct = { - id: Date.now(), - sku: form.sku || `MAN-${Date.now()}`, - title: form.title, - category: form.category, - price: Number(form.price) || 0, - status: form.status, - }; - - setManualProducts((prev) => [newProduct, ...prev]); - setFeedEntries((prev) => [ - { id: Date.now(), message: `Add sản phẩm ${newProduct.title} thành công`, type: 'success' }, - ...prev, - ]); - } else { - setManualProducts((prev) => - prev.map((item) => (item.sku === form.sku ? { ...item, ...form, price: Number(form.price) || 0 } : item)) - ); - setFeedEntries((prev) => [ - { id: Date.now(), message: `Cập nhật sản phẩm ${form.title} thành công`, type: 'success' }, - ...prev, - ]); - } - - resetForm(); + dispatch(saveProduct({ id: editingId, values })).then((action) => { + if (!action.error) { + dispatch(resetForm()); + dispatch(setActiveTab('manual')); + } + }); } + const displayName = + [user.firstName, user.lastName].filter(Boolean).join(' ') || user.username; + return (
- {isLoggedIn ? ( - Hi, {currentUser} - ) : ( - Chưa đăng nhập - )} + Hi, {displayName}
Listing - Suggest Price
- {isLoggedIn ? ( - - ) : ( - - )} + +
-
- handleSelectProduct(product, 'erp')} - /> + dispatch(setActiveTab(key))} /> - handleSelectProduct(product, 'manual')} - /> +
+ {activeTab === 'erp' && ( + dispatch(startEdit(product))} + /> + )} - + {activeTab === 'manual' && ( + dispatch(startEdit(product))} + /> + )} - + {activeTab === 'form' && ( + dispatch(resetForm())} + /> + )} + + {activeTab === 'feed' && ( + dispatch(markNotificationRead(id))} + onMarkAllRead={() => dispatch(markAllNotificationsRead())} + /> + )}
); diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 0000000..38984af --- /dev/null +++ b/frontend/src/api/client.js @@ -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 : []; +} diff --git a/frontend/src/components/FeedPanel.jsx b/frontend/src/components/FeedPanel.jsx index 3aea528..62ab4ab 100644 --- a/frontend/src/components/FeedPanel.jsx +++ b/frontend/src/components/FeedPanel.jsx @@ -1,18 +1,44 @@ -export default function FeedPanel({ entries }) { - return ( -
-
-

New Feed

- Activity -
-
- {entries.map((entry) => ( -
- - {entry.message} -
- ))} -
-
- ) -} +export default function FeedPanel({ items, loading, error, onMarkRead, onMarkAllRead }) { + const unread = items.filter((n) => !n.isRead).length; + + return ( +
+
+

New Feed

+
+ {unread} chưa đọc + {unread > 0 && ( + + )} +
+
+ + {loading &&

Đang tải…

} + {error &&

{error}

} + + {!loading && !error && ( +
+ {items.length === 0 ? ( +

Chưa có thông báo

+ ) : ( + items.map((entry) => ( +
!entry.isRead && onMarkRead(entry.id)} + > + +
+ {entry.title} + {entry.message && {entry.message}} +
+
+ )) + )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/LoginForm.jsx b/frontend/src/components/LoginForm.jsx new file mode 100644 index 0000000..b466922 --- /dev/null +++ b/frontend/src/components/LoginForm.jsx @@ -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 ( +
+
+

Listing - Suggest Price

+

Đăng nhập bằng tài khoản ERP

+ + {error &&

{error}

} + + + + +
+
+ ); +} diff --git a/frontend/src/components/ProductFormPanel.jsx b/frontend/src/components/ProductFormPanel.jsx index 3cf260c..a70b45e 100644 --- a/frontend/src/components/ProductFormPanel.jsx +++ b/frontend/src/components/ProductFormPanel.jsx @@ -1,48 +1,56 @@ -export default function ProductFormPanel({ formMode, form, onChange, onSubmit, onImport, onClear }) { - return ( -
-
-

{formMode === 'add' ? 'Add Product' : 'Edit Product'}

- {formMode === 'add' && ( - - )} -
- -
- - - - - - -
- - -
-
-
- ) -} +const CONDITIONS = [ + { value: 'NEW', label: 'New' }, + { value: 'REF', label: 'Refurbished' }, + { value: 'USED', label: 'Used' }, +]; + +export default function ProductFormPanel({ formMode, form, saving, error, onChange, onSubmit, onClear }) { + return ( +
+
+

{formMode === 'add' ? 'Add Product' : 'Edit Product'}

+ {formMode === 'add' ? 'Manual' : `#${form.sku}`} +
+ + {error &&

{error}

} + +
+ + + + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/ProductTablePanel.jsx b/frontend/src/components/ProductTablePanel.jsx index 89375b3..3bf15fd 100644 --- a/frontend/src/components/ProductTablePanel.jsx +++ b/frontend/src/components/ProductTablePanel.jsx @@ -1,32 +1,48 @@ -export default function ProductTablePanel({ title, badge, products, onSelect }) { - return ( -
-
-

{title}

- {badge} -
- - - - - - - - - - - - {products.map((product) => ( - onSelect(product)}> - - - - - - - ))} - -
SKUTênCategoryPriceStatus
{product.sku}{product.title}{product.category}${product.price}{product.status}
-
- ) -} +export default function ProductTablePanel({ title, badge, bucket, onSelect }) { + const { items, loading, error } = bucket; + + return ( +
+
+

{title}

+ {badge} +
+ + {loading &&

Đang tải…

} + {error &&

{error}

} + + {!loading && !error && ( + + + + + + + + + + + + {items.length === 0 ? ( + + + + ) : ( + items.map((product) => ( + onSelect(product)}> + + + + + + + )) + )} + +
SKUConditionQtyPriceWH
+ Chưa có sản phẩm +
{product.sku}{product.condition}{product.qty}${product.price}{product.warehouse || '—'}
+ )} +
+ ); +} diff --git a/frontend/src/components/Tabs.jsx b/frontend/src/components/Tabs.jsx new file mode 100644 index 0000000..69db11e --- /dev/null +++ b/frontend/src/components/Tabs.jsx @@ -0,0 +1,17 @@ +export default function Tabs({ tabs, active, onChange }) { + return ( + + ); +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index d78ecd4..c612673 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import store from './store/index.js'; import App from './App.jsx'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')).render( - + + + ); diff --git a/frontend/src/store/authSlice.js b/frontend/src/store/authSlice.js new file mode 100644 index 0000000..2994673 --- /dev/null +++ b/frontend/src/store/authSlice.js @@ -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; diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js new file mode 100644 index 0000000..fabc33e --- /dev/null +++ b/frontend/src/store/index.js @@ -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; diff --git a/frontend/src/store/notificationsSlice.js b/frontend/src/store/notificationsSlice.js new file mode 100644 index 0000000..b7f53dd --- /dev/null +++ b/frontend/src/store/notificationsSlice.js @@ -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; diff --git a/frontend/src/store/productsSlice.js b/frontend/src/store/productsSlice.js new file mode 100644 index 0000000..45e47f3 --- /dev/null +++ b/frontend/src/store/productsSlice.js @@ -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; diff --git a/frontend/src/store/uiSlice.js b/frontend/src/store/uiSlice.js new file mode 100644 index 0000000..c0c8437 --- /dev/null +++ b/frontend/src/store/uiSlice.js @@ -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; diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4a8cd85..d3148de 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -83,6 +83,85 @@ button.secondary { align-items: stretch; } +.topbar-actions { + gap: 10px; +} + +/* --- Tabs --- */ +.tabs { + display: flex; + gap: 8px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.tab { + background: white; + color: #374151; + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05); + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tab.active { + background: #2563eb; + color: white; +} + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + background: #ef4444; + color: white; + font-size: 11px; + font-weight: 700; +} + +.tab-content { + display: block; +} + +.tab-content .panel { + height: auto; + min-height: 60vh; +} + +.tab-content .form-panel { + max-width: 520px; +} + +.panel-header-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.panel-hint { + color: #6b7280; + font-size: 14px; +} + +.panel-error { + color: #b91c1c; + background: #fef2f2; + border-radius: 8px; + padding: 8px 12px; + font-size: 13px; + margin: 0 0 12px; +} + +.empty-row { + text-align: center; + color: #9ca3af; + padding: 20px 0; +} + .panel { background: white; border-radius: 16px; @@ -182,16 +261,95 @@ button.secondary { color: #166534; } -.feed-item.info { +.feed-item.info, +.feed-item.news { background: #eff6ff; color: #1d4ed8; } +.feed-item.error { + background: #fef2f2; + color: #b91c1c; +} + +.feed-item.read { + opacity: 0.55; +} + +.feed-item { + cursor: pointer; + align-items: flex-start; +} + +.feed-body { + display: flex; + flex-direction: column; + gap: 2px; +} + +.feed-body strong { + font-size: 14px; +} + +.feed-body span { + font-size: 13px; + opacity: 0.85; +} + .feed-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; + margin-top: 6px; + flex-shrink: 0; +} + +/* --- Login --- */ +.login-shell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.login-card { + background: white; + border-radius: 16px; + padding: 28px; + width: 100%; + max-width: 380px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.1); + display: grid; + gap: 14px; +} + +.login-card h1 { + margin: 0; + font-size: 20px; +} + +.login-sub { + margin: 0; + color: #6b7280; + font-size: 14px; +} + +.login-card label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: #374151; +} + +.login-card input { + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 10px; + font-size: 14px; } @media (max-width: 1200px) {