Commit cb12e735 by tdgiang

Add spec and implementation plan for Sổ Bán Lẻ auto-order feature

Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KPzWwuTEeX2vXGXvyGn4q
parent 798b419d
# Chức năng: Tạo đơn hàng tự động (tích hợp Sổ Bán Lẻ)
## 1. Mục tiêu
Khi admin tạo giao dịch thanh toán mới (trang `admin/transactions/new`), thay vì
dùng thẳng số tiền admin nhập, hệ thống tự động:
1. Tạo trước một đơn hàng bên hệ thống **Sổ Bán Lẻ** (sobanle.com, POS của
cửa hàng Hải Yến) — chọn ngẫu nhiên tổ hợp sản phẩm có sẵn trong kho sao
cho tổng tiền đơn hàng gần bằng số tiền admin nhập.
2. Dùng **tổng tiền thật** của đơn hàng vừa tạo (có thể thấp hơn số tiền
nhập tối đa 50.000đ) để tạo giao dịch thanh toán (payment-gate) như luồng
hiện tại.
3. Khi giao dịch thanh toán được xác nhận thành công (IPN từ provider), gọi
thêm API cập nhật trạng thái đơn hàng bên Sổ Bán Lẻ thành "đã thanh toán".
Nguồn: `docs/readme.md` (yêu cầu gốc) + phần brainstorm làm rõ trong hội thoại.
## 2. Luồng dữ liệu
```
Admin nhập tên/SĐT/địa chỉ/số tiền dự kiến (form admin/transactions/new)
→ POST admin/transactions (createTransaction, admin.server.controller.js)
1. SobanleClient.getToken()
- lấy JWT bằng API login, cache token (memory hoặc Redis sẵn có)
- tự login lại khi token hết hạn (theo `exp` trong JWT) hoặc gặp 401
2. SobanleClient.getProducts()
- lấy danh sách sản phẩm còn active trong kho
3. pickProductCombo(products, targetAmount)
- mỗi sản phẩm có thuế suất riêng (`tax.rate`, %); tổng dùng để so
khớp target là tổng ĐÃ GỒM thuế của từng dòng
(`line_total = qty * unit_price * (1 + tax.rate/100)`, làm tròn
về đơn vị đồng)
- chọn ngẫu nhiên tổ hợp sản phẩm, tổng (đã gồm thuế) nằm trong
[targetAmount - 50000, targetAmount] (không được vượt targetAmount)
- không tìm được tổ hợp phù hợp sau N lần thử → trả lỗi
4. SobanleClient.createSale(customerData, listProduct, sale_status: 2)
- customerData lấy từ tên/SĐT/địa chỉ admin vừa nhập; các field
không có dữ liệu tương ứng (tax_no, id_card_number,
passport_number, email) để trống/null
- warehouse_id / biller_id / account_id / currency_id: hằng số cố
định lấy từ config (một cửa hàng Hải Yến duy nhất)
- bất kỳ lỗi nào ở bước 1-4 → dừng lại, KHÔNG tạo AdminTransaction,
trả lỗi cho admin (không tạo giao dịch thanh toán mồ côi không
gắn với đơn hàng gốc)
5. Lấy orderTotal (tổng tiền thật) + sobanleOrderId từ kết quả bước 4
6. AdminTransaction.create({ ..., amount: orderTotal, sobanleOrderId })
- giữ nguyên logic tạo merTrxId/transCode/merchantToken hiện có,
chỉ đổi nguồn `amount` từ input người dùng sang orderTotal thật
7. Trả paymentUrl như luồng hiện tại
epayIPN (đã có sẵn, admin.server.controller.js dòng 144-198)
→ khi cập nhật status thành "success" (sau updateOne):
SobanleClient.changeSaleStatus(tx.sobanleOrderId, sale_status: 4)
- lỗi ở bước này: chỉ log, KHÔNG rollback giao dịch thanh toán (tiền
đã về tài khoản) — xem mục Rủi ro/Ngoài phạm vi
```
## 3. Thuật toán chọn sản phẩm (`pickProductCombo`)
- Mỗi sản phẩm trả về từ API có object `tax: { id, name, rate, is_active,
... }``rate` là % thuế (vd `5` = VAT 5%). Đơn giá sản phẩm được coi là
giá CHƯA thuế; dòng hàng tính:
`line_total = round(qty * unit_price * (1 + tax.rate / 100))`.
- Bỏ qua sản phẩm có `qty` (tồn kho) `<= 0`.
- Shuffle ngẫu nhiên danh sách sản phẩm.
- Duyệt qua danh sách đã shuffle, với mỗi sản phẩm chọn số lượng ngẫu nhiên
nhỏ, không vượt quá tồn kho (`qty` của sản phẩm), ví dụ
`min(1-3 ngẫu nhiên, product.qty)`, cộng dồn `line_total` (đã gồm thuế)
vào tổng nếu không vượt quá `targetAmount`.
- Dừng khi tổng (đã gồm thuế) đạt khoảng `[targetAmount - 50000,
targetAmount]`.
- Nếu duyệt hết danh sách mà chưa đạt khoảng cho phép → thử lại (shuffle lại
từ đầu), tối đa N lần (đề xuất N = 20).
- Hết N lần vẫn không đạt → trả lỗi `NO_PRODUCT_COMBO_MATCH`, dừng toàn bộ
luồng tạo giao dịch.
- `orderTotal` dùng để tạo giao dịch thanh toán (mục 2, bước 5) = tổng đã
gồm thuế của tổ hợp được chọn.
## 4. API Sổ Bán Lẻ sử dụng
Toàn bộ base URL: `https://haiyen.sobanle.com/api/jwt`. Token JWT lấy 1 lần
từ API login, dùng lại (cache) cho cả 3 API còn lại — không cần login riêng
cho từng API. Các response shape dưới đây đã verify thật (không phải suy
đoán).
| Việc | Method | Path | Request | Response (field dùng) |
|---|---|---|---|---|
| Đăng nhập lấy token | POST | `/login` | `{ login, password }` | `access_token` (top-level, không nằm trong `data`), `expires_in` (giây, TTL cache token — không cần decode JWT) |
| Lấy danh sách sản phẩm | GET | `/products?page=1&is_active=true&per_page=1000` | header `Authorization: Bearer <token>` | `data`: mảng sản phẩm thẳng (không lồng thêm cấp); field dùng: `id`, `price` (giá CHƯA thuế — verify: `price` = `original_price` cộng biên lợi nhuận, tách biệt thuế), `qty` (tồn kho — KHÔNG được chọn số lượng vượt quá field này), `is_active`, `tax: { id, name, rate }` |
| Tạo đơn hàng | POST | `/sales` | `{ warehouse_id, biller_id, account_id, currency_id, exchange_rate: "1", reference_no: null, is_internal_api: true, sale_status: 2, list_product: [{product_id, qty}], customer_data {...}, payment_receiver, payment_note, sale_note, staff_note }` | `data.id` (orderId), `data.grand_total` (orderTotal thật) |
| Cập nhật trạng thái đơn hàng | PATCH | `/sales/change-sale-status/{id}` | `{ sale_status: 4 }` (4 = đã thanh toán/hoàn tất) | `message` — gọi sau khi giao dịch thanh toán bên payment-gate xác nhận thành công |
Ghi chú bảo mật: các ví dụ curl trong `docs/readme.md` chứa token/cookie
sống thật — không copy trực tiếp vào code hay commit. Client mới phải tự
login lấy token qua API, không dùng token/cookie đã bị lộ trong tài liệu.
## 5. Thay đổi file/module
- **Mới** `app/libs/SobanleClient.js` — login/cache token, `getProducts`,
`createSale`, `changeSaleStatus`. Dùng `ApiRequest` sẵn có trong
`app/libs/ApiRequest.js` theo pattern các provider khác trong repo (không
tạo HTTP client mới).
- **Mới** `app/libs/productComboPicker.js` — thuật toán chọn tổ hợp sản
phẩm (mục 3), tách riêng khỏi phần gọi mạng để dễ test độc lập.
- **Sửa** `app/libs/ApiRequest.js` — bổ sung `getOtherUrlWithHeader`
`patchOtherUrlWithHeader` (thư viện hiện có chỉ hỗ trợ POST kèm header
tuỳ chỉnh qua `postOtherUrlWithHeader`, thiếu biến thể GET/PATCH cần cho
lấy sản phẩm có Bearer token và cập nhật trạng thái đơn hàng).
- **Sửa** `config/env/all.js` — thêm block:
```js
sobanle: {
base_url: "https://haiyen.sobanle.com/api/jwt",
username: process.env.SOBANLE_USERNAME,
password: process.env.SOBANLE_PASSWORD,
warehouse_id: "1",
biller_id: "1",
account_id: "1",
currency_id: "1",
}
```
- **Sửa** `app/models/AdminTransaction.js` — thêm field
`sobanleOrderId: { type: String, default: null }`.
- **Sửa** `app/controllers/admin.server.controller.js`:
- `createTransaction` — chèn bước gọi `SobanleClient` trước khi
`AdminTransaction.create`, đổi `amount` thành `orderTotal`.
- `epayIPN` — sau khi `updateOne` set `status: "success"` thành công, gọi
`SobanleClient.changeSaleStatus`.
## 6. Xử lý lỗi
| Tình huống | Xử lý |
|---|---|
| Login sổ bán lẻ lỗi / token không lấy được | Dừng, trả lỗi cho admin, không tạo giao dịch |
| API lấy sản phẩm lỗi | Dừng, trả lỗi cho admin |
| Không tìm được tổ hợp sản phẩm khớp sai số | Dừng, trả lỗi cho admin (`NO_PRODUCT_COMBO_MATCH`) |
| API tạo đơn hàng lỗi | Dừng, trả lỗi cho admin |
| API cập nhật trạng thái đơn hàng lỗi (sau khi đã thanh toán thành công) | Chỉ log lỗi, không rollback giao dịch — tiền đã về tài khoản |
## 7. Rủi ro / Ngoài phạm vi (chưa xử lý ở bước này)
- Nếu bước cập nhật trạng thái đơn hàng (mục 6, dòng cuối) thất bại, đơn
hàng bên Sổ Bán Lẻ có thể bị kẹt ở trạng thái "chờ xử lý" dù khách đã
thanh toán xong — cần cơ chế retry/queue trong bước triển khai sau, chưa
thiết kế ở tài liệu này.
- Danh sách sản phẩm (`getProducts`) gọi lại mỗi lần tạo giao dịch, chưa có
cache — cần đánh giá hiệu năng nếu tần suất tạo giao dịch cao.
- warehouse_id/biller_id/account_id/currency_id hiện cố định 1 cửa hàng duy
nhất; nếu sau này multi-tenant theo subdomain (`checkSubDomain` middleware
đã có trong app) cần thiết kế lại thành cấu hình theo tenant.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment