尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
Hubsell — Phần mềm quản lý bán hàng đa kênh Nền tảng quản lý bán hàng đa kênh (Shopee, Lazada, TikTok, Offline) — mô hình tương tự Salework. Dự án đã hoàn thành xuất sắc trọn vẹn 4 giai đoạn cốt lõi, tạo nên một sản phẩm MVP (Minimum Viable Product) chạy được end-to-end: từ đăng nhập bảo mật, quản lý kho, kết nối sàn, đồng bộ đơn tự động, đến báo cáo tài chính có biểu đồ và phân quyền nhân viên — khoác trên giao diện SaaS hiện đại (Sidebar dọc). Toàn bộ mã nguồn đã được kiểm thử end-to-end trên trình duyệt và đóng gói tại commit hubsell-v1.0-mvp-completed.查看完整 README ↗
Định vị từ 15/10/2026 (anh Trung chốt): Hubsell là trợ lý quảng cáo đa sàn cho nhà bán trên Shopee, Lazada, TikTok Shop — không còn là "phần mềm quản lý bán hàng đa kênh" cạnh tranh với Sapo, Nhanh, Haravan, Salework. Đơn hàng, kho, đối soát, lãi lỗ từng đơn, hóa đơn vẫn có đủ và vẫn là nền dữ liệu để trợ lý tính ROAS hòa vốn, nhưng không còn là câu định vị. Phương án đầy đủ và các quyết định: docs/marketing/DINH-VI-LAI-HUBSELL-QUANG-CAO.md. Phần dưới đây là lịch sử kỹ thuật của hệ thống, giữ nguyên để tra cứu.
🏆 Phiên bản v1.0 — MVP hoàn chỉnh (18/07/2026)
Dự án đã hoàn thành xuất sắc trọn vẹn 4 giai đoạn cốt lõi, tạo nên một sản phẩm MVP (Minimum Viable Product) chạy được end-to-end: từ đăng nhập bảo mật, quản lý kho, kết nối sàn, đồng bộ đơn tự động, đến báo cáo tài chính có biểu đồ và phân quyền nhân viên — khoác trên giao diện SaaS hiện đại (Sidebar dọc).
Toàn bộ mã nguồn đã được kiểm thử end-to-end trên trình duyệt và đóng gói tại commit
hubsell-v1.0-mvp-completed.
📋 Xem TODO.md để biết nhật ký tiến độ chi tiết, việc đang làm dở và kế hoạch tiếp theo.
| Giai đoạn | Nội dung | Trạng thái |
|---|---|---|
| 1 | Nền móng: Monorepo, Next.js + Express + Prisma + PostgreSQL, Dashboard | ✅ Xong |
| 2 | Đăng nhập/Đăng ký (JWT + bcrypt), Quản lý Sản phẩm & Kho hàng thủ công | ✅ Xong |
| 3 | Đồng bộ đơn hàng đa kênh & Mapping sản phẩm (webhook giả lập tự trừ kho) | ✅ Xong |
| 4 | Quản lý đơn hàng tập trung, Báo cáo tài chính (Recharts), Phân quyền Admin/Staff | ✅ Xong |
| ✨ | Tái cấu trúc giao diện chuẩn SaaS (Sidebar dọc + Header mỏng + Card đổ bóng) | ✅ Xong |
| ✨ | Nhập/Xuất Excel: import sản phẩm (upsert) + export Sản phẩm & Đơn hàng (SheetJS/xlsx) | ✅ Xong |
| ✨ | Chi phí hoạt động (OperatingExpense) + Lợi nhuận thuần (Net Profit) trên Dashboard | ✅ Xong |
| ✨ | Onboarding Guard (bắt buộc kết nối gian hàng) + Phân quyền nhân viên theo Gian hàng (Multi-store) | ✅ Xong |
| ✨ | Hubsell Finance — Module tài chính chuyên sâu: Báo cáo dòng tiền, Chi phí vận hành (FIXED/VARIABLE), Cảnh báo đơn lỗ | ✅ Xong |
ℹ️ Thư viện
xlsxđược cài từ CDN chính thức của SheetJS (cdn.sheetjs.com/xlsx-0.20.3) — bản đã vá lỗ hổng bảo mật, vì bản trên npm đã ngừng cập nhật.
postgresql-x64-17)Luật đặt tệp (giữ cây gọn để sửa về sau):
| Thứ cần thêm | Đặt ở | Không đặt ở |
|---|---|---|
Endpoint /api/* mới | backend/src/routes/<nhóm>.ts (một tệp = một nhóm), mount trong app.ts | trong index.ts |
| Nghiệp vụ gọi từ nhiều route / worker | backend/src/services/ | trong route |
| Tiện ích thuần, công thức, không gọi DB | backend/src/lib/ | trong services |
| Tiến trình chạy theo nhịp | backend/src/workers/<tên>.ts + đăng ký trong workers/index.ts; KHÔNG giữ trạng thái nghiệp vụ ở biến cấp module (test no-module-state chặn) | setInterval rải trong route/service |
| Gọi API sàn / nhà cung cấp | backend/src/integrations/<sàn>/ | trong services |
| Hằng số cấu hình tĩnh, registry | backend/src/config/ | rải trong mã |
| Test | __tests__/ cạnh thư mục của mã được test (*.test.ts; test DB đặt tên *-db.test.ts) | cạnh tệp nguồn |
| Script chạy một lần (backfill, probe, bench) | backend/scripts/ | backend/prisma/ (chỉ schema + migrations + seed) |
| Đổi schema | backend/prisma/migrations/<YYYYMMDDHHMMSS>_<tên>/migration.sql + schema.prisma; SQL áp tay cũ giữ ở prisma/manual-migrations/ | ALTER tay không có migration |
| Trang web mới | frontend/src/app/<nhóm>/<trang>/page.tsx | tệp lẻ ngoài app/; component/helper của trang đặt ở components/<nhóm>/, KHÔNG cạnh page.tsx (21/10/2026 đã dời 12 tệp HQ) |
| Component | theo TÍNH NĂNG frontend/src/components/<nhóm>/; dùng chung → shared/; mount một lần → shell/ | trong app/ |
| Hàm thuần phía web | frontend/src/lib/ | trong component; lib/ KHÔNG import ngược từ app/ hay components/ |
| Tài liệu thiết kế / quyết định |
hubsell/
├── README.md · PROGRESS.md · TODO.md # Kiến trúc · nhật ký phiên · checklist
├── render.yaml · docker-compose.yml # Render (web + worker SG) · Postgres local tùy chọn
├── start-backend.bat · start-frontend.bat · start-https.bat # Bấm đúp chạy local
├── .easignore # EAS Build đóng gói từ GỐC kho git: chỉ cho hubsell-mobile/ lên máy build Expo
├── scripts/ # gen-certs.sh, dev-https.sh (HTTPS local)
├── tools/ # Công cụ ngoài app: mail-digest/ (tóm tắt thư 07:30), phap-ly/ (sinh hồ sơ PDF), do-hoa/ (ảnh store + bìa Facebook)
├── docs/ # 45+ tài liệu — chỉ mục theo chủ đề: docs/README.md
│ ├── marketing/ · store/ · archive/ # Định vị, video, bài FB · hồ sơ App Store / Google Play, thư trả Apple · nhật ký phiên cũ
│ ├── landing-persona/ · bang-thong-worker-do-2026-10-12/ # Tư liệu đo đạc
│ └── *.md # KIEN-TRUC-QUY-MO-TRIEU-DON, HANG-DOI-BEN, SO-CAI-DON, DONG-BO-GIAI-DOAN-3,
│ # GOI-NGU-DONG-GIAN-HET-HAN, HQ-SUC-KHOE, DEPLOY-RENDER-SUPABASE, ...
│
├── frontend/ # Next.js — app.hubsell.vn (Vercel)
│ └── src/
│ ├── app/ # Route theo nhóm nghiệp vụ
│ │ ├── page.tsx · login/ · reset-password/ · guide/
│ │ ├── orders/ · products/{new,putaway,receive,stocktake} · channels/ · mappings/ · staff/ # products/new = Tạo sản phẩm mới (soạn + đăng lên gian, docs/SAO-CHEP-SAN-PHAM.md mục 30)
│ │ ├── warehouse/{returns,shipping-alerts,sync}
│ │ ├── finance/{analytics,expenses,cost-prices,fee-audit,loss-orders,realized-pnl,shipping-alerts}
│ │ ├── ads/{shopee,lazada,tiktok} # Trợ lý quảng cáo đa sàn
│ │ ├── operations-assistant/{chat,reviews,ai-rules,loss-orders,copy-listing} # reviews có tab Cấu hình tự động (29/10/2026 gộp từ ai-rules — route cũ chỉ chuyển hướng); copy-listing = Sao chép sản phẩm lên nhiều gian (components/operations/copy-listing/ 16 tệp phẳng: new-copy-step, source/target-picker, batch-view, source/target-editor, image-grid, primitives, shared…)
│ │ ├── invoicing/{connect,history,tax-settings}
│ │ ├── settings/{general,plan,tax,other} · affiliate/ · koc-marketing/ (ẩn)
│ │ └── admin/{customers,finance,health,marketing,plans,system} # HQ điều hành nền tảng
│ ├── components/ # Theo TÍNH NĂNG: admin/ (HQ) ads/ assistant/ auth/ channels/ dashboard/ finance/
│ │ │ # invoicing/ koc/ mappings/ operations/ orders/ products/
│ │ │ # settings/ support/ warehouse/
│ │ ├── shell/ # Mount MỘT lần: app-shell, query-provider, command-palette,
│ │ │ # notification-bell, plan-quota-guard (cưỡng chế gói)
│ │ ├── shared/ · tour/ · data-table/ · ui/ (shadcn)
│ └── lib/ # api.ts (gọi API + token), format.ts, excel.ts, permissions.ts,
│ # guide-tours.ts, query-keys.ts, use-api-query.ts, pnl-mappers.ts, hq-expense-categories.ts, ...
│
├── backend/ # Express + Prisma — Render SG: web (API) + worker (nền)
│ ├── prisma/
│ │ ├── schema.prisma · seed.ts
│ │ ├── migrations/ # Tự áp khi deploy (migrate deploy)
│ │ └── manual-migrations/ # SQL áp tay (supabase-schema.sql, ads 08/2026, bật RLS 09/2026, dọn pg-boss evt.order 10/2026) — tư liệu
│ ├── scripts/ # 45 script chạy tay: seed-*, probe-* (probe-listing-adapter = dò adapter đăng SP trên 1 gian), bench-*, ledger-backfill, shadow-enroll (rà 21/10/2026: script một lần đã chạy xong thì xóa, git giữ lịch sử)
│ └── src/
│ ├── index.ts # Khởi động theo HUBSELL_ROLE=all|web|worker
│ ├── app.ts # Express app: mount 36 route + middleware gác quyền/gói
│ ├── middleware/auth.ts # JWT, requireAuth, requirePermission, requirePlatformPermission
│ ├── config/ # permission-registry, platform-permission-registry, tax-config,
│ │ # ads-cadence, capacity-plan, marketplace-read (lớp đọc sàn Pha B)
│ ├── lib/ # Thuần + hạ tầng: prisma, queue (pg-boss), mailer, secret-box,
│ │ # date-range, finance-definitions, pnl-formula, cost-price, image-fetch (tải ảnh về buffer),
│ │ # image-normalize (sharp theo luật sàn), ttl-cache (đệm TTL có trần), stale-cache (trả đệm, làm mới nền), paginate, tree,
│ │ # giá vốn gian lớn (03/11): sku-catalog-page (chia trang + tìm ở DB), cost-import-load/-apply
│ │ # (Excel đọc theo mã, ghi một câu), cost-mapping-catalog (tab Mapping gộp ở DB),
│ │ # cost-search-apply (áp giá cho mã vừa tìm); request-ledger + memory-watch (rào route nặng, canh RSS), ...
│ ├── services/ # Nghiệp vụ dùng chung: subscription-service, plan-enforcement,
│ │ # plan-hibernation, order-ledger, order-watermark, sync-schedule,
│ │ # notifications, platform-health, fulfillment/, listing-copy/ (sao chép SP: source · job-edit ·
│ │ # job-view · job-submit · job-worker · batch · manual · images · review-check — docs/SAO-CHEP-SAN-PHAM.md mục 29),
│ │ # cursor-reader + marketplace-gateway + sync-lanes (lớp đọc sàn Pha B,
│ │ # chưa nối worker — docs/CUNG-HOA-LOP-GOI-SAN.md mục 5b), ...
│ ├── routes/ # 36 nhóm endpoint /api/*: auth, orders, products, finance, tax,
│ │ # subscription, ads, tiktok-ads, webhooks, admin*, support, ...
│ ├── workers/ # 28 tiến trình nền, đăng ký trong workers/index.ts:
│ │ # order-auto-sync (lịch từng gian), event-queue (webhook pg-boss),
│ │ # stock-queue, stock-reconcile, order-ledger, token-refresh,
│ │ # plan-hibernate, subscription-reminder, invoice-*, review-auto-reply,
│ │ # read-lanes (làn đọc sàn mới — đọc bóng đơn theo gian ghi danh), ...
│ ├── integrations/ # Mỗi đối tác một thư mục: shopee/ lazada/ tiktok/ tiktok-ads/
│ │ # hubsell-ads/ invoice/ (MISA) payos/ apple-iap/ ai/
│ │ # (shopee|tiktok|lazada)/listing-api.ts = API ĐĂNG sản phẩm (danh mục, thuộc tính, ảnh, tạo SP)
│ │ # + lõi dùng chung: order-sync-write, sync-contract, stock-push-worker,
│ │ # order-read-adapters / order-write-adapters (3 adapter đọc bóng / ghi đơn cho máy đọc con trỏ)
│ └── marketplace/ # Lớp trừu tượng sàn + adapter (chiều ĐỌC) + mockMarketplace (giả lập)
│ └── listing/ # Chiều GHI — đăng SP lên sàn: types (ListingAdapter), rules, variant-map,
│ # registry (gian có token → adapter thật), {shopee,tiktok,lazada,mock}-listing-adapter, __tests__/
│ # error-text (dịch lỗi sàn → câu tiếng Việt cho người bán), attribute-names-vi (từ điển tên thuộc tính)
│
├── hubsell-mobile/ # Expo — một bản dựng iOS/Android (Google Play đã phát hành)
│ └── src/{app,screens,components,api,auth,plan,theme,lib,types}
│
└── hubsell-marketing/ # Làm marketing, KHÔNG có mã app (luật riêng: hubsell-marketing/CLAUDE.md)
├── .agents/product-marketing.md # Bối cảnh sản phẩm mọi skill đọc
├── .claude/skills/ # 50 skill marketing (coreyhaines31/marketingskills, MIT)
└── so-lieu/ · chien-luoc/ · kenh/ · khach-hang/
Không nằm trong repo: hubsell-landing/ (repo riêng, ignore), certs/ (cert local), .claude/worktrees/ (phiên phụ của app).
backend/src/app.ts (rà 21/10/2026)Bảng cũ (07/2026, liệt kê từng endpoint thời còn "giả lập") đã bỏ vì lệch mã thật. Bảng này
chỉ ghi nhóm đường dẫn + lớp gác đúng như app.ts; endpoint lẻ bên trong đọc router
tương ứng ở backend/src/routes/<tên>.ts (mỗi router tự siết đúng lá quyền của route mình).
Ký hiệu cột Gác (middleware trong backend/src/middleware/auth.ts):
JWT = requireAuth · quyền X = requirePermission("X") (chủ shop toàn quyền, nhân viên cần lá X trong cây config/permission-registry.ts; nhiều lá = có MỘT là qua) · chủ shop = adminOnly · gói mở = requirePlanUnlocked (khóa tầng giá trị gia tăng khi vượt trần đơn / hết gói quá ân hạn) · có gian = requireChannel (shop chưa nối gian → 409 NO_CHANNEL) · HQ = requirePlatformPermission("hq") (cây quyền nền tảng config/platform-permission-registry.ts).
| Nhóm đường dẫn | Router | Gác | Ghi chú |
|---|---|---|---|
/api/auth/hubsell-ads | hubsell-ads (callback) | công khai | Shopee redirect về sau khi ủy quyền app Hubsell Ads; mount trước /api/auth |
/api/auth/tiktok-ads | tiktok-ads (public) | công khai | FE /ads/tiktok/callback gọi vào; danh tính nằm trong state đã ký |
/api/auth | auth | công khai | đăng ký, đăng nhập, Google / Apple, đặt lại mật khẩu, /me, xóa tài khoản |
/api/webhooks · /api/webhook · /v1/webhooks | webhooks | chữ ký trên body | TikTok, Shopee (Push), Lazada, MISA meInvoice, payOS → ghi hàng đợi bền rồi ack |
/api/public | public | rate-limit + honeypot | lead "Đăng ký tư vấn" + đếm sự kiện từ landing |
/api/dashboard · /api/analytics | dashboard, analytics | JWT · quyền dashboard · có gian | Tổng quan + biểu đồ |
/api/expenses | expenses | JWT · quyền finance.expenses · gói mở · có gian | chi phí vận hành |
/api/finance | finance | JWT · quyền finance | warehouse.shipping-alerts | operations.loss-orders · gói mở · có gian | dòng tiền, lãi/lỗ, giá vốn, đối soát phí ship, đơn lỗ (đọc sổ cái đơn) |
/api/products · /api/inventory · /api/stock-locations | products, inventory, stock-locations | JWT · quyền warehouse.products | orders · có gian | hàng hóa, nhập/xuất kho, vị trí chứa hàng |
/api/orders | orders | JWT · quyền orders · có gian |
/warehouse/shipping-alerts)Gom các đơn bị sàn trừ phí ship cao hơn mức đã báo để chủ shop đòi lại tiền.
shippingFeeQuoted (Shopee: sàn báo lúc đặt; TikTok: khách trả) · shippingFeeActual (thực tế bị trừ) · shippingFeeDiff (shop chịu). Giao diện in 3 cột Được phủ (= thực tế − shop chịu, nhất quán 3 sàn) · Cước thực tế · Shop chịulib/shipping-diff-reason.ts BE + FE, docs DOI-SOAT-PHI-SHIP-NGUYEN-NHAN-KHAO-SAT-31-10.md): phân loại lúc đọc từ bản kê TikTok / sao kê Lazada / cột Order Shopee — 3 mã khiếu nại được (Cước thực tế cao hơn cước báo · Phụ phí vận chuyển · Chờ sao kê) và 6 mã chính sách sàn (Đơn hủy sau khi gửi · Khách trả hàng · Shop tự trợ giá ship · Shop chịu cước · Cước vượt mức sàn trợ · Khác). Bộ lọc mặc định Cần đối soát; ?reason=CLAIMABLE|POLICY|<mã>shippingDisputeStatus: CHO_KHIEU_NAI → DANG_KHIEU_NAI → DA_DOI_SOAT (nút "Đổi trạng thái nhanh" chuyển sang bước kế tiếp). TikTok có bản kê thật tự về DA_DOI_SOAT (chính sách)CopyIconButton)CHO_KHIEU_NAI theo bộ lọc gian + nguyên nhân, xuất Excel 9 cột — [Mã đơn hàng] [Mã vận đơn] [Sàn] [Gian hàng] [Nguyên nhân] [Khiếu nại được] [Phí ship đã được phủ] [Phí ship thực tế bị trừ] [Số tiền chênh lệch]⚠️ Quy ước dấu: database lưu
shippingFeeDiffdương (số tiền bị trừ thêm — đồng bộ với các trường phí khác), còn API/giao diện trả số âm màu đỏ theo góc nhìn "shop bị mất tiền".
Kết nối gian hàng TikTok Shop thật qua App "Dịch vụ tùy chỉnh" trên Partner Center (bản API 202309). Các sàn còn lại (Shopee/Lazada/Offline) vẫn ở chế độ giả lập.
Cấu hình (backend/.env) — điền 3 khóa lấy từ Partner Center:
TIKTOK_APP_KEY="" # App Key
TIKTOK_APP_SECRET="" # App Secret
TIKTOK_SERVICE_ID="" # Service ID của app (dựng URL uỷ quyền)
TIKTOK_REDIRECT_URI="https://localhost:3000/channels/tiktok/callback"
Luồng OAuth (nút Kết nối gian hàng → chọn TikTok → Tiếp tục với TikTok):
GET /api/channels/tiktok/auth-url → BE dựng URL trang uỷ quyền + state ngẫu nhiên (lưu sessionStorage chống CSRF)./channels/tiktok/callback?code=…&state=….state → POST /api/channels/tiktok/callback với auth_code.auth_code → access/refresh token, gọi /authorization/202309/shops lấy shop_cipher (bắt buộc cho mọi API sau), rồi lưu vào Channel.Cấu trúc code (mọi thứ "khó" gom trong một module để tái dùng):
| File | Vai trò |
|---|---|
backend/src/integrations/tiktok/config.ts | Đọc env, dựng URL uỷ quyền, expireToDate, endpoint cố định |
backend/src/integrations/tiktok/client.ts | Ký HMAC-SHA256, đổi/refresh token, lấy shop + shop_cipher, fetchOrders() / fetchSettlements() / fetchStatementTransactions() (typed) |
backend/src/integrations/tiktok/service.ts | Tầng nghiệp vụ có DB: getValidAccessToken() (tự refresh), syncTiktokOrders(), syncTiktokSettlements(), processTiktokOrderEvent() (webhook) |
backend/src/routes/channels.ts | 4 route: auth-url + callback + sync-orders + sync-settlements (chỉ Admin) |
backend/src/routes/webhooks.ts | POST /api/webhooks/tiktok — webhook real-time (verify chữ ký + trừ/hoàn kho) |
frontend/src/app/channels/tiktok/callback/page.tsx | Trang nhận callback, đối chiếu state, gọi BE |
Đồng bộ dữ liệu thật (nút trên trang Kênh bán, chỉ hiện với gian TikTok đã uỷ quyền):
getValidAccessToken() được gọi TRƯỚC mọi call API: nếu access_token còn <5 phút là hết hạn thì tự refreshAccessToken() rồi lưu token mới xuống DB; refresh_token hết hạn thì báo lỗi buộc uỷ quyền lại.next_page_token, upsert idempotent theo (channelId, orderCode) (migration thêm unique index). Map trạng thái TikTok → vòng đời Hubsell; snapshot giá vốn qua mapping SKU. Không trừ tồn kho khi đồng bộ lô (tránh sai kho/không idempotent).statements (202309, quét theo cửa sổ 30 ngày statement_time_ge/lt, mốc cuối nới +2 ngày vì sàn bỏ bản kê mới nhất khi lt = now) → statement_transactions bản 202501 có breakdown đặt tên (revenue / shipping_cost / fee_tax) → gom theo order_id (+ adjustment_order_id cho dòng điều chỉnh) rồi GHI ĐÈ: bảng tiktok_order_settlements (số có dấu, mỗi cột = một trường API, tên theo Seller Center "Phân tích quyết toán") + cột gộp GĐ2 của Order. Đơn chưa quyết toán lấy số ước tính của chính sàn từ Get Unsettled Transactions 202507 (chỉ gửi search_time_ge, gửi thêm lt sàn báo 36009003). Nút Đồng bộ đối soát: mặc định 60 ngày, ?full=1 toàn bộ lịch sử, ?mode=estimates|settlements.Webhook real-time (POST /api/webhooks/tiktok, cấu hình URL trong Partner Center):
verifyWebhookSignature() tính HMAC-SHA256(app_key + rawBody, app_secret) so khớp header Authorization (hằng-thời-gian). Body thô lấy từ req.rawBody (giữ lại ở express.json({ verify })) vì serialize lại là sai chữ ký. Thiếu/sai chữ ký → 401.integrations/tiktok/webhook-queue.ts, bảng tiktok_webhook_logs): route chỉ INSERT một dòng rồi ack 200; worker nền (khởi động ở workers/index.ts) chạy 3 làn song song (env TIKTOK_WEBHOOK_LANES), cùng một đơn không chạy 2 làn, claim job bằng UPDATE có điều kiện, retry 3 lần giãn cách 30s→60s, hết lượt → FAILED + cảnh báo InventorySyncAlert; job PROCESSING mồ côi trả về PENDING lúc boot; unique bodyHash chặn bản gửi lại y nguyên. Mỗi job gọi getOrderDetail() lấy trạng thái mới nhất (không tin trạng thái trong payload) → processTiktokOrderEvent() upsert đơn + tác động tồn kho trong một transaction → enqueueStockPush đẩy tồn mới sang gian khác. Type 5 → processTiktokAuthorizationEvent() gọi lại sàn kiểm token, mất → gian DISCONNECTED.stockDeductedAt, decrement nguyên tử, cho phép âm để phơi bày bán vượt kho); đơn CANCELLED → hoàn kho một lần (mốc stockRestoredAt, mirror luồng hủy đơn thủ công). Webhook đẩy lại nhiều lần cũng không trừ/hoàn double.Ngang hàng Shopee/Lazada (rà soát toàn cây code 16/09/2026, shop nhà and.not.or đã ủy quyền):
workers/order-auto-sync.ts): tầng nhanh quét đơn theo update_time 2 ngày + đơn hoàn (tiktok/returns-sync.ts, Return & Refund API: giải pháp hoàn, tiền hoàn, SKU trả, tracking chiều hoàn — không bịa giá) + cứu đơn giao thất bại (tiktok/delivery-fail.ts, tracking API, cùng hàng đợi vé DeliveryTrackingTask với Shopee; auto-chat ghi SKIPPED vì chưa nối chat); tầng giờ đối soát bản kê 7 ngày + sổ cái ví (tiktok/wallet.ts: GET /finance/202309/withdrawals → TiktokWalletTxn, số dư = cộng dồn SETTLE − WITHDRAW − PAYMENT, chủ shop Đối chiếu một lần với Seller Center; dòng rút/sàn chuyển → WalletWithdrawal). Không có tầng ads.marketplace/adapters/tiktok-adapter.ts (products/search, mỗi SKU một dòng, externalId = productId-skuId, tồn Σ kho, kho giữ tồn → channelStockLocationId); PUSHABLE_CHANNELS có TikTok → worker đẩy tồn gọi inventory/update theo kho (thiếu kho thì tra logistics/warehouses một lần), đối soát 6h, so khớp ở Hàng hóa.services/fulfillment/tiktok.ts (handover_time_slots → ship package PICKUP/DROP_OFF → tracking → shipping_documents A6 PDF, xin bản gộp SHIPPING_LABEL_AND_PACKING_SLIP có danh sách sản phẩm như Seller Center, sàn từ chối mới lùi về tem trần), dùng chung hộp thoại/route với hai sàn kia.workers/token-refresh.ts), dọn tiktok_webhook_logs, sức khỏe HQ + nhật ký webhook HQ có nguồn tiktok, kiểm toán phí rổ #3, tra mã vạch tự chữa lành, quét đơn hoàn kho. Log [TikTok] Hình dạng … in tên trường payload thật lần đầu mỗi lượt (tắt TIKTOK_SHAPE_LOG=0).Bảo mật: Channel thêm refreshToken, shopCipher, accessTokenExpireAt, refreshTokenExpireAt, externalShopName — không bao giờ trả refreshToken/shopCipher ra API (GET /api/channels đã lọc; chỉ phơi cờ apiConnected).
⚠️ Chạy local: Redirect của TikTok là https nên cả frontend lẫn backend phải chạy HTTPS bằng cert tự ký dùng chung — xem 🔒 Chạy HTTPS ở local. App ở trạng thái Draft chỉ uỷ quyền được bằng tài khoản shop test/của chính bạn.
✅ Đã đối chiếu payload thật (16/09/2026, shop and.not.or): bản kê 202501 khớp từng đồng với màn "Phân tích quyết toán" Seller Center (đơn 585964373854029014: hoa hồng, phí giao dịch, phí xử lý đơn 3.000đ, Voucher Xtra, GTGT 1%, TNCN 0,5%, tổng quyết toán). GTGT về dưới
vat_amount(không phảilocal_vat_amountnhư docs). Tra bản kê thô theo đơn:GET /api/admin/tiktok/settlement-raw?orderCode=(platform admin).🔜 Chưa làm: lịch tự động đồng bộ (cron) thay vì bấm tay; bóc tách chi tiết từng loại phí đối soát (hiện dồn vào
serviceFee).
Kết nối gian hàng Shopee thật qua app "Seller In House System". Cấu trúc mirror TikTok, gói trong backend/src/integrations/shopee/.
Cấu hình (backend/.env):
SHOPEE_PARTNER_ID="" # Partner ID
SHOPEE_PARTNER_KEY="" # Partner Key (bí mật ký HMAC)
SHOPEE_ENV="sandbox" # sandbox | production → chọn host API
SHOPEE_REDIRECT_URI="http://localhost:4000/api/auth/shopee/callback"
APP_FRONTEND_URL="https://localhost:3000"
Ký chữ ký (2 kiểu, HMAC-SHA256 với partner_key):
base = partner_id + api_path + timestampbase = partner_id + api_path + timestamp + access_token + shop_idLuồng OAuth:
GET /api/channels/shopee/auth-url → BE ký + dựng URL .../api/v2/shop/auth_partner, nhét ownerId vào state (JWT 10 phút) rồi kèm vào redirect.GET /api/auth/shopee/callback?code=&shop_id=&state= (route backend công khai).state → khôi phục ownerId → đổi code+shop_id lấy access_token/refresh_token (/api/v2/auth/token/get) → lấy tên gian (get_shop_info) → upsert Channel (idempotent theo userId+SHOPEE+shop_id) → redirect trình duyệt về /channels?shopee=connected.getValidShopeeAccessToken() tự refresh access_token (<5 phút hết hạn) qua /api/v2/auth/access_token/get; refresh_token sống 30 ngày.Cấu trúc file: shopee/config.ts (env + host + paths), shopee/client.ts (ký + token + get_shop_info), shopee/service.ts (state JWT + refresh + handleShopeeCallback).
⚠️ Redirect & domain: Shopee chỉ chấp nhận redirect thuộc domain đã đăng ký Console (
http://hubsell.tech, tức cổng 80 http). Test local: maphubsell.tech → 127.0.0.1bằng file hosts, và đặtSHOPEE_CALLBACK_HTTP_PORT="80"để backend mở thêm listener HTTP cổng 80 nhận callback (server chính vẫn HTTPS :4000 phục vụ frontend — không mixed-content). Sandbox host mặc địnhpartner.test-stable.shopeemobile.com(đặtSHOPEE_API_BASEđể ghi đè nếu Console cấp host khác).🔜 Chưa làm: đồng bộ đơn/đối soát Shopee (mới có OAuth + lưu token); chưa test end-to-end (cần domain hubsell.tech reachable).
Giá vốn chuẩn kế toán: bảng OrderItem lưu costPriceAtSale — snapshot giá vốn tại đúng thời điểm phát sinh đơn. Sau này chủ shop đổi giá vốn sản phẩm, báo cáo lãi/lỗ của đơn cũ vẫn chính xác tuyệt đối. (Đơn phát sinh trước khi có bảng này sẽ fallback qua InventoryLog × giá vốn hiện tại.)
Giải quyết bài toán "chi phí ẩn": sàn liên tục đổi biểu phí nên số tạm tính luôn lệch số thực nhận.
| Giai đoạn | Khi nào | Số liệu dùng |
|---|---|---|
| 1. Tạm tính | Đơn Chờ xử lý / Đang giao | platformFee = Doanh thu × Channel.feeRate (mặc định Shopee 12% · TikTok 11% · Lazada 10% · Offline 0%) |
| 2. Quyết toán | Đơn chuyển sang Đã giao | Bóc tách số thực tế sàn trả về: fixedFee (phí cố định) + serviceFee (phí dịch vụ) + paymentFee (phí thanh toán) − platformSubsidy (trợ giá) → ghi actualPayout (tiền thực nhận) |
Báo cáo dòng tiền luôn ưu tiên số quyết toán; đơn chưa quyết toán mới dùng số tạm tính. Hàm mockSettlement() trong mockMarketplace.ts mô phỏng dữ liệu đối soát — khi tích hợp API thật chỉ cần thay hàm này.
Công thức:
warning: "Chưa nhập giá vốn" (số liệu chưa đáng tin)Khu vực chỉ số đầu trang được cấu trúc thành 4 thẻ lớn, mỗi thẻ có danh sách chi tiết kèm % và tooltip giải thích công thức (hover vào icon ❓):
| Cột | Số lớn | Chi tiết bên dưới |
|---|---|---|
| 1. Tổng giá trị sản phẩm | Doanh số gốc (chưa trừ gì) | Phí nền tảng · Phí tiếp thị liên kết · Voucher trợ giá của shop · Chênh lệch phí vận chuyển · Trợ giá từ sàn (khoản cộng lại) |
| 2. Doanh thu | Sau khi trừ khoản sàn giữ lại | Hoàn thành (đã giải ngân) · Chờ xử lý (tạm tính) · Đã hủy (kèm % tỷ lệ hủy đơn) |
| 3. Chi phí | Giá vốn + chi phí ngoài sàn | Giá vốn sản phẩm · Chi phí quảng cáo · Chi phí vận hành khác |
| 4. Lợi nhuận | Đã trừ toàn bộ chi phí | Lợi nhuận thực tế (đơn đã quyết toán) · Lợi nhuận dự kiến (đơn đang chờ) — % là biên lợi nhuận |
Phạm vi tính: đơn Đã giao + Đang giao (không tính đơn đã hủy). Đơn đã quyết toán dùng phí thực tế, đơn đang đi đường dùng phí tạm tính. Kiểm chứng: Cột 1 − Tổng khấu trừ = Cột 2 (số liệu khớp tuyệt đối).
Bóc tách lý do đơn lỗ (trang Cảnh báo đơn lỗ): mỗi đơn âm tiền có nhãn rõ nguyên nhân
Cấu hình Giá vốn (/finance/cost-prices): lọc SKU theo sàn (Tất cả / Shopee / TikTok / Lazada / Offline), nhập giá vốn vào ô input — tự động lưu khi rời ô (onBlur) kèm toast xác nhận. SKU sàn lấy từ bảng liên kết ProductMapping; sản phẩm chưa liên kết sàn nào được xếp vào nhóm Offline.
Bộ lọc nâng cao (dành cho shop hàng nghìn SKU): ô tìm kiếm real-time theo tên sản phẩm / mã SKU / tên phân loại — bỏ dấu tiếng Việt (gõ "ao thun" ra "Áo thun") kèm nút ✕ xoá nhanh; dropdown lọc trạng thái giá vốn (Tất cả / Chưa nhập / Đã nhập); bộ đếm "Hiển thị X/Y SKU". Lọc chạy client-side nên kết quả hiện ngay khi đang gõ. Khi lọc "Chưa nhập giá vốn" mà không còn SKU nào thiếu → hiện màn hình chúc mừng thay cho bảng.
Đồng bộ sản phẩm từ sàn: nút "Đồng bộ từ sàn" (góc phải thanh lọc) gọi POST /api/finance/sync-products để quét danh mục sản phẩm (SKU, tên biến thể, giá bán, ảnh) từ mọi gian hàng đang ACTIVE rồi upsert:
costPrice (dữ liệu chủ shop tự nhập). Chạy lại nhiều lần không tạo bản ghi trùng.Nhờ vậy chủ shop vừa đăng sản phẩm mới lên sàn là bấm quét về nhập giá vốn ngay, không cần chờ phát sinh đơn hàng.
Chi phí vận hành phân loại 2 chiều: type (FIXED cố định / VARIABLE biến đổi) và category (Mặt bằng, Nhân viên, Đóng gói, Quảng cáo, Khác). Chi phí VARIABLE còn có thể gắn vào 1 mã SKU cụ thể qua appliedSku (tiền Ads, book KOC…) — khi thêm chi phí chọn "Biến đổi" thì giao diện hiện thêm ô chọn SKU.
Bảng nằm dưới lưới 4 cột ở /finance/analytics, cho biết mã nào là "gà đẻ trứng vàng", mã nào đang gánh lỗ.
Cột: Ảnh + Tên SKU · Đã bán · Doanh thu thuần · Giá vốn · Phí sàn & ship · Chi phí marketing · Lợi nhuận · Biên LN %. Sắp xếp lợi nhuận giảm dần, mã lãi cao nhất gắn 👑.
Nguyên tắc phân bổ chi phí:
| Loại chi phí | Cách xử lý |
|---|---|
| Giá vốn | Lấy trực tiếp từ OrderItem.costPriceAtSale (snapshot lúc bán) |
| Phí sàn & ship của đơn | Phân bổ cho từng SKU theo tỷ trọng doanh thu dòng hàng trong đơn |
Chi phí VARIABLE có appliedSku | Cộng thẳng vào đúng SKU đó |
| Chi phí FIXED | Không phân bổ vào SKU — trừ vào lợi nhuận cuối cùng toàn shop |
Cuối bảng có phần đối chiếu: Tổng LN các SKU − Chi phí cố định = Lợi nhuận cuối cùng của shop. SKU đã bán mà chưa nhập giá vốn được gắn nhãn vàng cảnh báo (số liệu chưa đáng tin).
Onboarding Guard: mọi API dữ liệu (products, orders, dashboard, analytics, expenses, inventory, mappings) đi qua middleware requireChannel — nếu shop chưa kết nối gian hàng nào → trả 409 { code: "NO_CHANNEL" }. Frontend bắt mã này để hiện màn hình Onboarding chặn toàn bộ cho đến khi kết nối ít nhất 1 kênh. GET /api/auth/me trả kèm hasChannels.
| POST | /api/auth/staff | Chủ shop tạo tài khoản Nhân viên (dùng chung dữ liệu shop) | 🔒 Chỉ Admin |
Toàn bộ cỡ chữ của Hubsell được khai báo tập trung tại frontend/src/lib/typography.ts. Sửa 1 file này = đổi cỡ chữ toàn hệ thống.
| Hằng số | Dùng cho | Laptop | PC lớn (≥1536px) |
|---|---|---|---|
TEXT_BODY | Số liệu / nội dung trong bảng | 14px | 16px |
TEXT_SUB | Dòng phụ xếp chồng dưới số liệu | 11px | 13px |
TEXT_TABLE_HEAD | Tiêu đề cột (semibold, giãn chữ) | 12px | 14px |
TEXT_CARD_TITLE | Tiêu đề thẻ chỉ số (IN HOA) | 12px | 14px |
TEXT_BIG_NUMBER | Số lớn trên dashboard (bold) | 20px | 24px |
CELL_PADDING | Khoảng đệm ô bảng | py-3 | py-4 |
Cách áp dụng — ở tầng component gốc, không rải class khắp nơi. ui/table.tsx đã gắn sẵn TEXT_TABLE_HEAD/TEXT_BODY/CELL_PADDING vào TableHead và TableCell, nên mọi bảng trong app tự động ăn theo: Đơn hàng, Sản phẩm, Kênh bán, Nhân viên, Mapping, Chi phí vận hành, Cấu hình giá vốn, Đơn lỗ, Chênh lệch ship, SKU P&L. Tương tự với ui/input.tsx, ui/native-select.tsx (form + ô tìm SKU), dashboard/stat-card.tsx, finance/breakdown-card.tsx, finance/hint-icon.tsx, finance/sku-combobox.tsx.
⚠️ Khi viết bảng mới: đừng đặt text-sm/text-base vào TableCell/TableHead — class đặt tại chỗ sẽ lấn át quy chuẩn và làm bảng đó lệch cỡ so với phần còn lại.
Breakpoint dùng là 2xl (≥1536px) chứ không phải md (≥768px): laptop phổ biến 1366–1440px đã vượt md từ lâu, nếu dùng md thì laptop sẽ nhảy lên 16px và mất đi độ gọn gàng mong muốn.
Menu nhóm gồm Sản phẩm (/products) và Đối soát đơn hoàn (/warehouse/returns). Đường dẫn /products giữ nguyên — nhóm chỉ là gom ở tầng menu.
Luồng nạp hàng từ sàn về kho:
Kênh bán → kết nối gian hàng, lấy token
Sản phẩm → "Đồng bộ từ sàn": quét danh mục sàn, TỰ TẠO sản phẩm gốc + mapping
Liên kết SP → nối tay SKU sàn ↔ sản phẩm gốc khi đồng bộ chưa khớp
Cấu hình Giá vốn→ nhập giá vốn (cũng có nút Đồng bộ để dùng tại chỗ)
Nút "Đồng bộ từ sàn" dùng chung components/products/sync-products-button.tsx, xuất hiện ở cả hai trang Sản phẩm và Cấu hình Giá vốn. Sửa hành vi thì sửa một chỗ.
/orders)Gom đơn từ mọi sàn về một màn hình để lọc, duyệt và in phiếu hàng loạt.
6 tab theo vòng đời đơn — Tất cả · Chờ xử lý · Đã xử lý · Đang giao · Đã giao thành công · Đơn hủy/Hoàn trả, mỗi tab kèm badge số lượng.
Quy trình kho tách làm 2 bước (enum ShippingStatus):
PENDING --[Xác nhận & chuẩn bị]--> PROCESSED --[Bàn giao]--> SHIPPING --> DELIVERED
↑ in phiếu ở bước này
Tách đôi vì thực tế shop gói hàng buổi sáng nhưng shipper chiều mới tới lấy — gộp một bước thì hàng còn trong kho đã bị hiển thị là đang trên đường giao.
Lọc theo loại đơn — Đơn 1 sản phẩm / Đơn nhiều sản phẩm, để kho gói đơn dễ trước, đơn khó sau. Dựa trên cột Order.itemCount lưu sẵn (Prisma không lọc được theo số lượng bản ghi con). Đơn cũ chưa ghi chi tiết dòng hàng có itemCount = 0 nên cố ý không rơi vào nhóm nào.
⚠️ In phiếu phải đánh dấu SAU khi cửa sổ in mở thành công. /bulk/labels chỉ đọc, /bulk/mark-printed mới ghi. Gộp hai việc vào một endpoint là lúc trình duyệt chặn pop-up, đơn bị ghi "đã in" mà chẳng có tờ phiếu nào ra giấy — đơn rơi khỏi nhóm "Chưa in" nên kho bỏ sót, không có dấu hiệu nào để phát hiện ngoài việc thiếu hàng lúc giao.
Chống in trùng & đóng gói lặp: trong tab "Đã xử lý" có 3 bộ lọc con kèm số đếm — Tất cả / Chưa in phiếu / Đã in phiếu. In phiếu xong đơn tự rơi sang nhóm "Đã in" và bảng gắn nhãn kèm giờ in, nên người sau không in lại đơn người trước đang gói. In lại vẫn được nhưng giữ nguyên mốc lần đầu — cái shop cần biết là phiếu đã ra giấy từ lúc nào, không phải lần in gần nhất.
⚠️ Thêm trạng thái mới phải rà lại finance.ts — "Tiền chờ về" lọc theo shippingStatus in [PENDING, PROCESSED, SHIPPING]. Bỏ sót một trạng thái là cả nhóm đơn đó biến mất khỏi báo cáo dòng tiền mà không có dấu hiệu gì.
Tìm kiếm & lọc — ô tìm đa năng (mã đơn, tên khách, SĐT, mã vận đơn; SĐT tự bỏ dấu cách nên 0901 234 567 khớp 0901234567) + lọc theo Sàn và Đơn vị vận chuyển.
Xử lý hàng loạt — tích chọn đơn → thanh nổi ở đáy màn hình:
packedAt. Bỏ qua có chọn lọc kèm lý do từng đơn, không fail cả mẻ.⚠️ Hai giới hạn do chưa có tích hợp sàn thật:
POST /api/orders/bulk/confirm.Bảng đơn — 4 lớp nền phân biệt: trắng → sọc muted/40 → rê chuột primary/10 → đang chọn primary/15 + vạch màu 3px bên trái.
⚠️ Đừng dùng bg-accent làm màu hover trong dự án này: token accent trùng đúng giá trị với muted (cùng oklch 0.97), nên hover sẽ gần như vô hình trên dòng sọc.
Phân trang: dropdown 20 / 50 / 100 đơn/trang ở chân bảng (trần backend 100). Khối phân trang luôn hiện kể cả khi chỉ có một trang, nếu không thì không có chỗ nào đổi cỡ trang.
Cụm sản phẩm: đơn nhiều SKU chỉ hiện dòng đầu kèm thumbnail, phần còn lại gấp sau nút "+N sản phẩm khác" để chiều cao các dòng gần bằng nhau.
Phân vai giữa hai trang — đừng làm lẫn:
| Trang | Vai trò |
|---|---|
/warehouse/returns (Quản lý Kho) | Mọi hành động vật lý: quét mã, xác nhận nhận hàng, cộng kho, đối soát quá hạn |
/orders → tab Hủy/Hoàn | Chỉ theo dõi: badge trạng thái + bộ lọc, không thao tác |
Đối soát quá hạn: đếm ngày từ Order.returnRequestedAt (mốc sàn báo hoàn). Dưới 7 ngày = bình thường · 7–13 ngày = cảnh báo · từ 14 ngày = "Chưa về tay", dòng nhuộm nền hồng, làm căn cứ khiếu nại bưu cục.
⚠️ Đơn không có returnRequestedAt hiển thị "Chưa rõ", không bị tính quá hạn. Cố ý không lấy createdAt lấp chỗ trống — số ngày chờ là căn cứ đòi tiền bưu cục, bịa mốc ra là đi khiếu nại bằng số liệu sai.
⚠️ routes/warehouse.ts KHÔNG có endpoint cộng kho và không được thêm. Nhận hàng vẫn gọi POST /api/orders/:id/return.
Vòng đời một đơn hoàn:
AWAITING ──quét, hàng nguyên vẹn──▶ RECEIVED_INTACT (cộng lại tồn kho)
└────quét, hàng hỏng/mất──────▶ DAMAGED
├─khiếu nại thắng─▶ CLAIM_SETTLED (Đã đền bù)
└─khiếu nại thua──▶ WRITTEN_OFF (Hao hụt)
⚠️ CLAIM_SETTLED và WRITTEN_OFF đều KHÔNG cộng tồn kho. Hàng hỏng/mất được đền bằng tiền, không quay lại kệ — cộng kho ở đây là tạo hàng ma.
Tiền đền bù: Order.compensationAmount — chỉ > 0 khi CLAIM_SETTLED, WRITTEN_OFF luôn 0. Backend chặn chốt "đã được đền bù" mà không nhập số tiền: một đơn thắng kiện mang 0 đồng nhìn như đòi được nhưng tiền về bằng không, module Tài chính đọc vào sẽ hiểu sai. GET /returns trả kèm totalCompensated để Tài chính hạch toán sau.
Lọc & phân trang: dropdown lọc theo sàn (để xuất danh sách đối soát riêng với bưu cục từng sàn) + phân trang 20/50/100 đồng bộ với module Đơn hàng.
⚠️ Badge tab "Tất cả" cộng dồn từ Object.keys(STATUS_META), đừng liệt kê tay. Đã dính lỗi này: thêm trạng thái Hao hụt mà quên cộng vào tổng, tab hiện 13 trong khi bảng có 14 dòng — lệch âm thầm, không có lỗi nào báo.
Đồng bộ từ sàn: POST /api/warehouse/returns/sync — user có gian THẬT thì gọi API sàn thật: đồng bộ đơn theo trục biến động (update_time Shopee / update_after Lazada) 2 ngày gần nhất, upsert tự gắn cờ AWAITING + returnRequestedAt cho đơn sàn báo hoàn; nút chỉ là "quét ngay khỏi đợi nhịp" vì worker nền 10'/lần cũng quét đúng trục update này (từ 09/08 — trục create_time cũ bỏ sót đơn cũ vừa báo hoàn, phải đồng bộ tay). Tài khoản demo (không gian thật) giữ bản giả lập: bốc vài đơn đang giao rải mốc 2/9/17 ngày.
Quét mã nhận hoàn: ô nhập tự lấy tiêu điểm, bắn máy quét barcode/2D vào là tra ngay. Máy quét hoạt động như bàn phím (gõ chuỗi + Enter) nên một ô input xử lý được cả mã sọc lẫn mã QR, không cần thư viện. Nút Bật camera quét mã dùng html5-qrcode (import động, chỉ tải khi bấm) cho webcam/điện thoại — cần HTTPS hoặc localhost, mở qua IP nội bộ bằng http:// sẽ bị trình duyệt chặn.
Hai nhánh xử lý:
| Chọn | Tồn kho | Trạng thái |
|---|---|---|
| Hàng nguyên vẹn | Cộng ngược từng SKU + ghi InventoryLog | RECEIVED_INTACT |
| Hư hỏng / Mất / Tráo | Không cộng | DAMAGED → badge "Chờ khiếu nại sàn" |
⚠️⚠️ Order.stockRestoredAt là chốt chặn cộng kho trùng — đừng bỏ qua khi viết luồng mới đụng tới tồn kho. Huỷ đơn đã cộng kho sẵn; nếu luồng nhận hoàn cộng thêm lần nữa thì kho phình ảo, shop bán ra hàng không có thật. Mọi đường cộng kho phải kiểm tra mốc này trước.
⚠️ lookup không đoán bừa: mã khớp nhiều đơn thì trả 409 kèm danh sách, không tự lấy đơn đầu tiên — quét nhầm là cộng kho nhầm sản phẩm.
⚠️ Chưa có lọc theo Kho hàng — Hubsell chưa có khái niệm kho (mỗi sản phẩm một con số tồn duy nhất). Sẽ làm thành module đa kho riêng để không chồng chéo logic tồn kho.
/finance/cost-prices)| Tính năng | Cách dùng |
|---|---|
| Tự định dạng số | Gõ 52000 hiện ngay 52.000. Dùng <CurrencyInput> — đừng dùng <input type="number"> cho tiền: không hiện được dấu phân tách, lại dính lỗi lăn chuột làm đổi giá trị rồi tự lưu. |
| Xuất Excel | Xuất đúng những dòng đang lọc. Mẹo: lọc "Chưa nhập giá vốn" rồi xuất → file chỉ chứa mã còn thiếu. |
| Nhập Excel | Đọc Mã SKU + Giá vốn, tùy chọn Gian hàng (+ Kênh bán khi 2 gian trùng tên). Có gian → chỉ gian đó; trống → mọi gian có mã + giá mặc định ở kho (luật ở lib/cost-import.ts). Ô đã đúng giá thì bỏ qua. Tự bỏ dấu chấm người dùng gõ (1.234.000 → 1234000). Dòng lỗi báo rõ số dòng, không chặn dòng hợp lệ. |
| Tìm mã | Gõ mã hoặc tên. Gian lớn tìm ở DB (lib/sku-catalog-page.ts): trùng đúng mã → bắt đầu bằng → chứa → tên; tìm cả mã kho; dòng mẫu hiện mã khớp, ít mã thì tự mở. Ra 0 mà bộ lọc đang che thì nói rõ kèm nút xem. |
| Áp giá cho tất cả mã vừa tìm | Dải dưới ô tìm (components/finance/cost-search-apply.tsx): máy chủ tự tính theo từ khóa + bộ lọc, mặc định chỉ điền ô trống, ghi đè phải tick, chỉ dòng khớp mã, hộp xác nhận ghi giá cũ → mới. Số lệch với lúc xác nhận thì không ghi (409). |
| Bảng phân cấp | Mỗi mẫu hàng gộp thành 1 dòng cha có mũi tên xổ ra các phân loại con (size M/L/XL). Dòng cha có ô "Nhập cho tất cả" — gõ một lần, áp cho toàn bộ phân loại. |
⚠️ Giá vốn theo GIAN (từ 11/10/2026). Giá hiệu lực của một SKU sàn = giá riêng của gian (ChannelProduct.costPrice > 0), không có thì giá MẶC ĐỊNH của sản phẩm kho đã nối (Product.costPrice). Kho chỉ quản số lượng. Một nguồn quy tắc: effectiveSkuCost trong backend/src/lib/cost-price.ts. Mọi ô ở trang này ghi giá riêng của đúng gian đó; xoá trống ô của SKU đã nối kho = về theo giá kho. Trang có ô lọc "Gian hàng"; tab Mapping có ô chọn gian — gộp, đề xuất, áp giá chỉ trong các gian đã chọn.
Phân cấp chỉ ở tầng hiển thị. Giá vốn vẫn lưu riêng cho từng SKU con, nên SKU P&L và Cảnh báo đơn lỗ vẫn lấy đúng số của từng phân loại — không gộp, không tính trung bình. Mẫu chỉ có một mã thì hiển thị phẳng như cũ, không bọc thêm tầng.
⚠️ Trang này gọi load() sau mỗi lần lưu. Đừng tháo bảng ra khi loading — component mất trạng thái thì mọi nhóm đang xổ sẽ tự thu lại, gõ giá xong là nhóm sập xuống. Dùng <Refreshing> để giữ bảng và chỉ làm mờ.
⚠️ Không có trường "mẫu gốc" trong CSDL. Size M/L/XL là các sản phẩm riêng biệt, phân loại nằm trong tên. lib/variant-group.ts suy ra mẫu gốc bằng cách cắt đuôi tên ("Áo thun nam size M" → "Áo thun nam"). Đây là suy đoán, nên nó chỉ dùng để gợi ý và luôn bắt xác nhận trước khi ghi — đoán sai thì cùng lắm nút không hiện, không bao giờ âm thầm sửa nhầm.
<DateRangePicker>Mọi trang báo cáo đều có bộ chọn ngày ở góc phải trên, cạnh nút Làm mới. Dùng chung một component và một quy ước query param, nên module đối soát mới sau này chỉ cần cắm vào là chạy.
Quy ước API: mọi endpoint báo cáo nhận ?from=yyyy-mm-dd&to=yyyy-mm-dd. Backend dùng parseDateRange(req.query) (backend/src/lib/date-range.ts) — trả undefined khi không lọc, truyền thẳng vào Prisma where: { createdAt: range }. Đơn hàng lọc theo createdAt, chi phí theo expenseDate.
Ba cái bẫy đã xử lý sẵn (đừng tự viết lại logic ngày):
to phải lấy đến 23:59:59.999, cắt ở 00:00 là mất trọn ngày cuối.toDateKey() theo lịch địa phương, không dùng toISOString() — ở múi giờ Việt Nam (UTC+7) sẽ lùi mất một ngày.2026-02-31) bị chặn, vì JS tự nhảy sang tháng sau thay vì báo lỗi.Thêm bộ lọc cho trang mới:
const [range, setRange] = useState<DateRange>(defaultRange); // mặc định 30 ngày qua
<DateRangePicker value={range} onChange={setRange} disabled={loading} />
<Refreshing active={loading}>…khối số liệu…</Refreshing>
Nhớ thêm range vào mảng phụ thuộc của useCallback để đổi ngày là tự tải lại.
<Refreshing> làm mờ vùng số liệu trong lúc tính lại, nhưng chỉ khi tải quá 150ms — máy chủ nội bộ trả lời ~100ms, mờ ngay lập tức sẽ thành cái nháy khó chịu. Giữ số cũ mờ đi thay vì dựng khung xương (skeleton) để mắt không mất điểm bám.
⚠️ Khối "Hiện trạng shop" trên trang Tổng quan cố ý không theo bộ lọc — "Sản phẩm: 17" hay "Kênh bán: 4" là trạng thái hiện tại, cắt theo khoảng thời gian sẽ vô nghĩa. Giao diện có ghi rõ điều này để không gây hiểu nhầm.
<DashboardCard>Mọi khối số liệu trên mọi trang đều dùng chung frontend/src/components/dashboard/dashboard-card.tsx. Không viết thẻ số liệu bằng tay nữa.
Đây là màn hình tài chính, không phải trang marketing. Chủ shop nhìn vào để ra quyết định, nên ưu tiên khoảng trống và con số rõ ràng.
KHÔNG dùng thanh ngang (progress bar) ở các dòng chỉ số. Đã thử và loại bỏ: một rừng vạch màu dưới mỗi dòng làm mắt bị nhiễu và hạ thấp tính chuyên nghiệp của ERP. Tỷ lệ % hiển thị bằng text thuần là đủ. Component không còn prop nào để chèn thanh ngang vào thẻ.
Phân cấp chỉ bằng 3 công cụ:
| Loại số liệu | Màu |
|---|---|
| Tiền vào / lãi | xanh lá emerald-600 |
| Tiền ra / chi phí / lỗ | đỏ sắc nét rose-600 (không dùng 700 sẫm xỉn) |
| Trung tính (số lượng, tham chiếu) | đen/xám mặc định |
featured) — chỉ cho khối Lợi nhuận và thẻ cảnh báo tiền bạc cốt lõi. Lãi → bg-emerald-50/40; lỗ → bg-rose-50/40 + viền đỏ nhạt. Dùng tràn lan sẽ mất tác dụng cảnh báo, nên component chặn sẵn: chỉ tone positive/negative mới phủ được nền.| Trang | Thẻ được phủ nền |
|---|---|
| Tổng quan | Lợi nhuận thuần |
| Báo cáo dòng tiền | Lợi nhuận |
| Cảnh báo đơn lỗ | Tổng tiền lỗ |
| Đối soát phí ship | Tổng tiền cần đòi lại |
| Chi phí vận hành | (không có — không phải khối lợi nhuận) |
Sắc thái (tone) thay cho màu tự do — neutral · info · positive · negative · warning · accent. Dùng toneBySign(value) cho chỉ số có thể lãi hoặc lỗ.
Số tổng dùng TEXT_HERO_NUMBER (24px→30px, extrabold). Các lớp bọc tiện dụng: <StatCard> cho thẻ chỉ có tiêu đề + số; <BreakdownCard> cho thẻ có bóc tách chi tiết từ API.
| Vai trò | Được phép | Bị chặn |
|---|---|---|
| ADMIN (Chủ shop) | Toàn quyền tất cả tính năng | — |
| STAFF (Nhân viên) | Đơn hàng, Sản phẩm & Kho, xem danh sách kênh (không thấy token) | Tổng quan/Báo cáo tài chính, Cấu hình kênh, Mapping, Nhân viên — hiện "Bạn không có quyền truy cập" (chặn cả UI lẫn API 403) |
Phân quyền theo Gian hàng (Multi-store): Admin có thể giới hạn mỗi STAFF chỉ được xem & xử lý đơn của một số kênh nhất định (bảng StaffChannel). Không gán kênh nào = xem tất cả (mặc định). Nhân viên bị giới hạn: danh sách kênh + đơn hàng chỉ hiện kênh được gán; sửa đơn của kênh ngoài phạm vi → 403.
Đăng ký tài khoản mới = ADMIN của shop riêng. Admin tạo nhân viên qua POST /api/auth/staff.
Mọi API dữ liệu đều lọc theo user đang đăng nhập (mỗi tài khoản chỉ thấy dữ liệu của mình).
Cách dễ nhất: bấm đúp start-backend.bat, rồi start-frontend.bat, sau đó mở trình duyệt vào http://localhost:3000.
Tài khoản dùng thử:
[email protected] / hubsell123[email protected] / staff123Bằng dòng lệnh:
# Backend
cd backend
npm install
npx prisma migrate dev # tạo/cập nhật bảng trong DB
npm run db:seed # (tuỳ chọn) nạp dữ liệu mẫu — XOÁ dữ liệu cũ!
npm run dev # http://localhost:4000
# Frontend
cd frontend
npm install
npm run dev # http://localhost:3000
Redirect URL của TikTok là https; hơn nữa trang callback https gọi API — nếu API còn http sẽ bị chặn mixed-content. Nên chạy cả frontend và backend qua HTTPS dùng chung một cert tự ký.
Cách nhanh nhất — MỘT lệnh (tự tắt tiến trình cũ ở cổng 4000/3000, tạo cert nếu thiếu, boot cả hai HTTPS, phơi tunnel nếu có, in link):
bash scripts/dev-https.sh # hoặc double-click start-https.bat (cần Git Bash)
Kết thúc bằng Ctrl+C để tắt tất cả. Bỏ tunnel: NO_TUNNEL=1 bash scripts/dev-https.sh.
Cách thủ công (từng phần):
# 1) Tạo cert tự ký cho localhost (chỉ cần chạy MỘT LẦN — cần OpenSSL/Git Bash)
bash scripts/gen-certs.sh # sinh certs/localhost-key.pem + certs/localhost.pem
# 2) Backend — HTTPS bật sẵn (SSL_KEY_FILE/SSL_CERT_FILE trong backend/.env)
cd backend && npm run dev # → https://localhost:4000
# 3) Frontend — HTTPS dùng chung cert (NEXT_PUBLIC_API_URL đã trỏ https)
cd frontend && npm run dev:https # → https://localhost:3000
https://localhost:4000/health và https://localhost:3000 mỗi cái một lần, bấm "vẫn tiếp tục" để trình duyệt chấp nhận cert cho từng cổng — nếu không, fetch cross-origin sẽ bị chặn.SSL_* trong backend/.env và đổi NEXT_PUBLIC_API_URL=http://localhost:4000, chạy npm run dev như thường. Backend tự nhận biết: có cert → HTTPS, không có → HTTP./api/webhooks/tiktok từ máy chủ của họ nên không tới được localhost. dev-https.sh tự phơi cổng nếu tìm thấy cloudflared/ngrok/lt và in luôn URL webhook để dán vào Partner Center. Hiện máy chưa cài tool nào — khuyến nghị cloudflared (không có trang chặn, hợp webhook): tải cloudflared.exe, thêm vào PATH rồi chạy lại dev-https.sh. Chạy tay: cloudflared tunnel --url https://localhost:4000 --no-tls-verify. (Logic webhook đã test cục bộ bằng request ký sẵn.)Các tính năng có thể bổ sung ở những giai đoạn tới:
backend/src/marketplace/mockMarketplace.ts.InventoryLog.JWT_SECRET và mật khẩu database, đưa lên máy chủ/cloud cho nhiều người dùng.docs/<CHU-DE>.md (chữ in hoa, không dấu), chỉ mục ở docs/README.md; marketing → docs/marketing/, store → docs/store/ |
| gốc repo (chỉ README, PROGRESS, TODO) |
| Công cụ ngoài app (chạy trên máy anh) | tools/<tên>/ | docs/ |
| Việc marketing mới (chiến lược, số liệu khách, nội dung kênh, skill marketing) | hubsell-marketing/ theo bảng trong hubsell-marketing/CLAUDE.md | backend/, frontend/, docs/ |
| đơn hàng, chuẩn bị hàng theo mẻ, in vận đơn |
/api/warehouse | warehouse | JWT · quyền warehouse.returns · có gian | hàng hoàn, cảnh báo vận chuyển, đồng bộ kho |
/api/mappings | mappings | JWT · chủ shop · có gian | liên kết SKU sàn ↔ kho |
/api/channels | channels | JWT | xem gian: mọi tài khoản (không gác có gian để onboarding nối được); kết nối / ngắt / danh mục sàn: chủ shop (gác trong router) |
/api/staff | staff | JWT · chủ shop | nhân viên + phân quyền + gian được giao |
/api/subscription | subscription | JWT | gói của tôi, thanh toán payOS / Ví / Apple IAP — nhân viên cũng đọc được trạng thái gói |
/api/referral | referral | JWT · chủ shop | affiliate của nền tảng + Ví Hubsell; không gác có gian / gói (đường tự cứu bằng Ví) |
/api/support-requests | support | JWT | mọi tài khoản, không cần gian hay gói |
/api/notifications | services/notifications | JWT · chủ shop | chuông thông báo; stream SSE đứng trước router, token đi qua query |
/api/assistant | assistant | JWT · chủ shop · gói mở | Trợ lý Hubsell hỏi số liệu (tầng luật + AI) |
/api/command-center | command-center | JWT · chủ shop | Trung tâm điều hành trên Tổng quan |
/api/invoice-config · /api/tax | invoice-config, tax | JWT · quyền invoicing · gói mở | Hóa đơn & Thuế (MISA meInvoice): cấu hình, tờ nháp, phát hành, tờ khai; không gác có gian |
/api/sao-chep-san-pham | listing-copy | JWT · quyền operations.copy-listing · gói mở · có gian | Sao chép sản phẩm lên nhiều gian 3 sàn: nguồn (gian đã nối / kho), đợt N SP × M gian, cây danh mục gian đích, đăng qua hàng đợi listing.copy (tránh chữ ads — adblock) |
/api/test/misa-sandbox | test-misa-sandbox | JWT · chủ shop · env MISA_SANDBOX_TEST_ENABLED=1 trên production | cụm thử sandbox MISA, mặc định 503 trên production |
/api/operations | operations | JWT · quyền operations.* · gói mở · có gian | Trợ lý vận hành: đánh giá (chat đang ẩn) |
/api/koc | koc | JWT · quyền koc · gói mở · có gian | KOC & Affiliate — đang ẩn (25/09) |
/api/quang-cao/tiktok (alias /api/ads/tiktok) | ads-tiktok | JWT · quyền ads.tiktok · gói mở · có gian | Trợ lý quảng cáo TikTok (GMV Max) |
/api/quang-cao (alias /api/ads) | ads | JWT · quyền ads · gói mở · có gian | Trợ lý quảng cáo Shopee / Lazada; tên đường dẫn tránh luật adblock onrender.com/api/ads/ |
/api/hubsell-ads · /api/tiktok-ads | hubsell-ads, tiktok-ads | JWT · chủ shop | ủy quyền app Ads Service (Shopee) / tài khoản TikTok Marketing API cho gian đã nối |
/api/admin | admin, admin-health, admin-plans, admin-ledger, admin-input-invoices | JWT · HQ | Quản trị nền tảng: khách, gói & thuê bao, sức khỏe, sổ cái, hóa đơn đầu vào, marketing; không gác có gian |