41 KiB
section, title, status, version, reviewer_notes
| section | title | status | version | reviewer_notes |
|---|---|---|---|---|
| 06 | Thiết kế luồng xử lý chi tiết | approved | 2 |
6. Thiết kế luồng xử lý chi tiết (Detailed Design)
Đầu vào:
02-phan-tich-yeu-cau.md(FR-01..FR-27),03-kien-truc.md(service boundary, event:OrderPlaced,PaymentConfirmed,OrderDelivered,CommissionCalculated,PayoutScheduled,InventoryReserved,ReviewEligible,LoyaltyPointsEarned,NotificationRequested),04-api-design.md(endpoint theo service, v3),05-thiet-ke-du-lieu.md(entity/bảng, enum trạng thái, v3).Right-sizing: do
profile.scale = large,hasPayment = true,hasPII = truevà mô hình marketplace nhiều bên (Customer, Seller, Admin, CSR, Ops, VNPay/Momo, GHN/GHTK, Ngân hàng), mục này vẽ sequence diagram cho 6 luồng phức tạp/rủi ro cao nhất: (1) Checkout & thanh toán đa seller, (2) Xử lý đơn & vận chuyển, (3) Đổi trả/tranh chấp, (4) Tính hoa hồng & payout có kỳ giữ tiền, (5) Seller onboarding & KYC, (6) Đăng nhập + MFA/OAuth. Các CRUD đơn giản (wishlist, review, quản lý địa chỉ, cấu hình ngôn ngữ/tiền tệ...) không vẽ sequence riêng vì không có rẽ nhánh nghiệp vụ đáng kể.(v2 — revision theo findings mục 8 và đồng bộ mục 4/5 v3): bổ sung tối thiểu vào các luồng hiện có — không vẽ lại toàn bộ sequence/class/state diagram đã duyệt: (a) 6.1.5 KYC — Admin xem
KYCDocumentqua pre-signed URL TTL ngắn; (b) 6.1.4 payout — nêu kênh truyền batch file ngân hàng (giả định); (c) ghi chúaudit_log(mục 5.2.11 v3) tại các hành động nhạy cảm (duyệt/từ chối KYC, cấu hình commission/holdDays, quyết định dispute, retry payout, khoá/mở seller); (d) 6.1.6 đăng nhập — bổ sung nhánh khoá tài khoản theofailed_login_count/locked_until(mục 5.2.1 v3); (e) phản ánh403 ERR_FORBIDDEN_OWNERSHIP, chống replay webhook (timestamp ±5 phút + idempotency theogatewayTransactionRef), và OAuthstate/409 ERR_ACCOUNT_LINK_REQUIRED(mục 4 v3) ở 6.1.1 và 6.1.6.
6.1 Sơ đồ tuần tự (Sequence Diagram)
6.1.1 Checkout & thanh toán đa seller (FR-05, FR-06, FR-07, FR-12, FR-13, FR-18)
sequenceDiagram
actor Customer
participant Web as Web Storefront (Guest/Customer)
participant CartOrder as Cart & Order Service
participant Catalog as Catalog & Inventory Service
participant Payment as Payment Service
participant VNPay as VNPay/Momo
participant MQ as Message Broker
participant Notify as Notification Service
participant Commission as Commission & Payout Service
Customer->>Web: Xem giỏ hàng, bấm "Đặt hàng"
Web->>CartOrder: POST /v1/cart/apply-coupon (nếu có coupon)
CartOrder-->>Web: Cart đã áp giảm giá (FR-13)
Web->>CartOrder: POST /v1/checkout (Idempotency-Key, shippingAddressId, paymentMethod)
CartOrder->>Catalog: Kiểm tra & giữ tồn kho (reserve) từng ProductVariant trong Cart (BR-02)
alt Đủ tồn kho
Catalog-->>CartOrder: reserved OK (InventoryReserved)
CartOrder->>CartOrder: Tách Cart đa seller thành Order (cha) + nhiều OrderSeller theo seller_id (BR-01)
CartOrder->>CartOrder: Lưu Order, OrderSeller, OrderItem (status=pending_payment)
CartOrder-->>Web: 201 { parentOrderId, orders[], paymentRedirectUrl? }
Web->>Payment: POST /v1/payments (orderId, method, Idempotency-Key)
Payment->>VNPay: Khởi tạo giao dịch (redirect URL)
VNPay-->>Payment: paymentRedirectUrl
Payment-->>Web: paymentRedirectUrl
Customer->>VNPay: Thanh toán trên trang gateway
VNPay->>Payment: POST /v1/payments/webhooks/vnpay (IPN, checksum)
Payment->>Payment: Xác thực chữ ký; kiểm tra timestamp lệch <=5 phút so với giờ nhận (chống replay — quá hạn thì từ chối, 400 ERR_VALIDATION, không xử lý); kiểm tra idempotency theo gatewayTransactionRef (đã ghi nhận trước đó → 200 OK, không lặp side-effect); nếu hợp lệ, cập nhật Payment.status=success (v3 — mục 4.1.6)
Payment->>MQ: publish PaymentConfirmed(orderId)
MQ->>CartOrder: consume PaymentConfirmed → Order/OrderSeller.status=confirmed
MQ->>Catalog: consume PaymentConfirmed → chuyển reserved → trừ kho thật (commit)
MQ->>Commission: consume PaymentConfirmed → tạo CommissionTransaction (BR-03, tạm ghi nhận, chưa release)
MQ->>Notify: consume PaymentConfirmed → gửi email/SMS xác nhận đơn hàng (FR-12)
else Không đủ tồn kho
Catalog-->>CartOrder: 409 ERR_CONFLICT (insufficient stock)
CartOrder-->>Web: 409 ERR_CONFLICT — yêu cầu điều chỉnh giỏ hàng
end
Note over Web,Payment: Các endpoint tra cứu sau đó — GET /v1/orders/{orderId}, GET /v1/payments/{paymentId} — đều kiểm tra ownership (customerId trong JWT phải khớp chủ đơn); không khớp → 403 ERR_FORBIDDEN_OWNERSHIP (mục 4.1.1, v3)
6.1.2 Xử lý đơn & vận chuyển (FR-19, FR-26, FR-14 điểm thưởng, FR-22 khởi tạo hold)
sequenceDiagram
actor Seller
actor Ops as Ops/Warehouse
participant SellerPortal as Seller Portal
participant CartOrder as Cart & Order Service
participant Shipping as Shipping & Fulfillment Service
participant GHN as GHN/GHTK
participant MQ as Message Broker
participant Commission as Commission & Payout Service
participant Loyalty as Promotion & Loyalty Service
participant Notify as Notification Service
Seller->>SellerPortal: Xác nhận đơn con của mình
SellerPortal->>CartOrder: PATCH /v1/seller/orders/{orderId}/status (confirmed)
CartOrder->>CartOrder: Ghi OrderStatusHistory, OrderSeller.status=confirmed
CartOrder->>MQ: publish OrderSellerConfirmed
MQ->>Shipping: consume → tạo yêu cầu fulfillment (status=created)
Ops->>Shipping: GET/PATCH /v1/ops/orders/{orderId}/fulfillment (đóng gói xong → packed)
Shipping->>GHN: POST /v1/ops/shipments (tạo vận đơn)
GHN-->>Shipping: tracking_number
Shipping->>CartOrder: cập nhật OrderSeller.status=shipped (qua event OrderShipped)
GHN->>Shipping: POST /v1/webhooks/ghn (cập nhật in_transit/delivered, idempotent)
Shipping->>Shipping: Ghi ShipmentEvent, cập nhật Shipment.status
alt status=delivered
Shipping->>MQ: publish OrderDelivered(orderSellerId, deliveredAt, categoryId)
MQ->>CartOrder: consume → OrderSeller.status=delivered
MQ->>Commission: consume → tạo PayoutHold, hold_until_date = deliveredAt + holdDays (BR-04)
MQ->>Loyalty: consume → tính & ghi LoyaltyTransaction earn (BR-06)
MQ->>Notify: consume → thông báo giao hàng thành công cho Customer
end
Note over GHN,Shipping: Nếu GHN timeout — fallback thử GHTK hoặc đưa vào hàng đợi Ops xử lý thủ công (BR-15, theo mục 3.4)
6.1.3 Đổi trả & xử lý tranh chấp (FR-09, FR-25)
sequenceDiagram
actor Customer
participant Web as Web Storefront
participant CartOrder as Cart & Order Service
participant MQ as Message Broker
actor CSR
participant AdminBO as Admin/CSR Backoffice
participant Payment as Payment Service
participant Commission as Commission & Payout Service
participant Notify as Notification Service
Customer->>Web: Yêu cầu đổi trả cho Order đã giao
Web->>CartOrder: POST /v1/orders/{orderId}/return-requests (reason)
CartOrder->>CartOrder: Tạo ReturnRequest (status=requested)
CartOrder->>MQ: publish ReturnRequested
MQ->>Commission: consume → nếu PayoutHold liên quan đang holding, chuyển release_status=disputed_frozen (BR-14a)
MQ->>CartOrder: (nếu seller từ chối/không phản hồi trong SLA) tạo Dispute (status=open, assigned_csr_id=null)
CSR->>AdminBO: GET /v1/admin/disputes (danh sách cần xử lý)
CSR->>AdminBO: Điều tra: xem lịch sử Order, trao đổi Customer/Seller (status=investigating)
CSR->>CartOrder: PATCH /v1/admin/disputes/{disputeId} (quyết định: refund/reject/escalate)
Note over CartOrder,MQ: Quyết định dispute (refund/reject/escalate) phát event ghi audit_log tại Audit & Compliance Service (actor=CSR/Admin, action=dispute_decision, resource=disputeId) — mục 5.2.11 (v3)
alt Quyết định hoàn tiền (refund)
CartOrder->>MQ: publish DisputeResolved(decision=refund)
MQ->>Payment: consume → khởi tạo hoàn tiền qua VNPay/Momo API (hoặc điều chỉnh COD)
MQ->>Commission: consume → PayoutHold liên quan không được release (loại khỏi kỳ payout — xem Finding mục 6.6)
CartOrder->>CartOrder: ReturnRequest.status=refunded, OrderSeller.status=returned
else Từ chối khiếu nại (reject)
CartOrder->>MQ: publish DisputeResolved(decision=reject)
MQ->>Commission: consume → PayoutHold.release_status=holding (chờ đến hold_until_date để release bình thường)
CartOrder->>CartOrder: ReturnRequest.status=rejected
end
MQ->>Notify: consume DisputeResolved → thông báo kết quả cho Customer và Seller
6.1.4 Tính hoa hồng & payout định kỳ có kỳ giữ tiền (FR-20, FR-21, FR-22)
sequenceDiagram
participant Scheduler as Weekly Payout Job (cron)
participant Commission as Commission & Payout Service
participant DB as Commission & Payout DB
actor Admin as Platform Admin
participant AdminBO as Admin Backoffice
participant Bank as Ngân hàng (batch transfer)
participant Notify as Notification Service
actor Seller
participant SellerPortal as Seller Portal
Scheduler->>Commission: Trigger payout run (hàng tuần)
Commission->>DB: SELECT PayoutHold WHERE release_status='holding' AND hold_until_date<=today
loop Với mỗi PayoutHold đủ điều kiện
Commission->>DB: Kiểm tra không có Dispute đang open/investigating cho order_seller liên quan
alt Không có tranh chấp mở
Commission->>DB: release_status='released'; cộng CommissionTransaction.net_amount vào batch payout của seller
else Có tranh chấp mở
Commission->>DB: giữ nguyên 'holding' (chờ CSR xử lý xong — xem 6.1.3)
end
end
Commission->>DB: Tạo Payout (status=scheduled) theo seller, period_start/period_end
Commission->>DB: Lấy SellerBankAccount đang active
Commission->>Bank: Gửi batch file chuyển khoản (Payout.status=processing) — kênh truyền: SFTP + mã hoá PGP hoặc API HTTPS của ngân hàng đối tác (v3, xem ghi chú giả định bên dưới)
Bank-->>Commission: Kết quả xử lý batch (ack/reject theo dòng)
alt Chuyển khoản thành công
Commission->>DB: Payout.status='paid', paid_at=now
Commission->>Notify: publish PayoutCompleted → thông báo Seller
else Thất bại (sai thông tin NH, bị NH từ chối)
Commission->>DB: Payout.status='failed'
Commission->>AdminBO: Cảnh báo Admin — không tự động thử lại (tránh double-payout)
Admin->>Commission: POST /v1/admin/payouts/{payoutId}/retry (thủ công, sau khi xác minh)
Note over Commission: Retry payout ghi audit_log (actor=Admin, action=payout_retry, resource=payoutId) — mục 5.2.11 (v3)
end
Seller->>SellerPortal: GET /v1/seller/payouts (xem lịch sử/trạng thái)
Admin->>AdminBO: GET /v1/admin/payouts (giám sát toàn sàn theo kỳ)
(v3) Kênh truyền batch file payout tới ngân hàng: giả định dùng SFTP với mã hoá PGP cho file định dạng chuẩn ngân hàng nội địa, hoặc API HTTPS của ngân hàng đối tác (nếu ngân hàng hỗ trợ) — ngân hàng đối tác và chuẩn kết nối cụ thể chưa được chốt trong brief, cần chủ dự án/đối tác ngân hàng xác nhận trước go-live (ảnh hưởng cách hiện thực
Commission & Payout Servicegọi ra bên ngoài, xem mục 3 tích hợp bên thứ ba).(v3) Hành động cấu hình
CommissionRule/holdDays(PUT /v1/admin/commission-rules/{categoryId}, mục 4.1.8) và khoá/mở khoáSeller(PATCH /v1/admin/sellers/{sellerId}/status, mục 4.1.7) là CRUD đơn giản nên không có sequence diagram riêng, nhưng đều là hành động nhạy cảm — mỗi lần ghi đều phát event ghiaudit_log(actor,before_json/after_json, resource) tại Audit & Compliance Service, theo mục 5.2.11.
6.1.5 Seller onboarding & KYC (FR-17, FR-23)
sequenceDiagram
actor Seller
participant SellerPortal as Seller Portal
participant SellerSvc as Seller Management Service
participant S3 as S3 (KYC bucket)
actor Admin
participant AdminBO as Admin Backoffice
participant MQ as Message Broker
participant Notify as Notification Service
Seller->>SellerPortal: Đăng ký gian hàng
SellerPortal->>SellerSvc: POST /v1/sellers/register
SellerSvc->>SellerSvc: Tạo Seller (status=pending_kyc)
Seller->>SellerPortal: Upload giấy phép kinh doanh/CMND
SellerPortal->>SellerSvc: POST /v1/sellers/{sellerId}/kyc-documents (multipart)
SellerSvc->>S3: Lưu file (mã hoá at-rest)
SellerSvc->>SellerSvc: Tạo KYCDocument (verified_status=pending) cho từng document_type bắt buộc
Admin->>AdminBO: GET /v1/admin/sellers?status=pending_kyc
Admin->>SellerSvc: GET /v1/admin/sellers/{sellerId}/kyc-documents/{documentId}/view-url (v3 — yêu cầu link xem tài liệu; endpoint cần bổ sung ở mục 4, xem Finding 6.6)
SellerSvc->>S3: Sinh pre-signed URL, quyền đọc duy nhất object đó, TTL <= 5 phút (v3)
S3-->>SellerSvc: presignedUrl (hết hạn sau tối đa 300 giây)
SellerSvc-->>Admin: 200 { viewUrl, expiresInSeconds<=300 } — Admin không được cấp quyền truy cập trực tiếp bucket/object storage
Admin->>AdminBO: Mở viewUrl trong trình duyệt, đối chiếu từng KYCDocument thủ công (không auto-approve — BR-13)
Admin->>SellerSvc: PATCH /v1/admin/sellers/{sellerId}/kyc-review (approved|rejected, reason)
Note over SellerSvc,MQ: Quyết định duyệt/từ chối KYC phát event ghi audit_log (actor=Admin, action=kyc_review, resource=kycDocumentId/sellerId) — mục 5.2.11 (v3)
alt Tất cả document bắt buộc đều verified
SellerSvc->>SellerSvc: Seller.status=active
SellerSvc->>MQ: publish SellerApproved
else Có document bị rejected
SellerSvc->>SellerSvc: Seller.status=rejected (giữ pending_kyc nếu seller có thể nộp lại)
SellerSvc->>MQ: publish SellerRejected(reason)
end
MQ->>Notify: gửi email kết quả duyệt cho Seller
Seller->>SellerPortal: GET /v1/sellers/{sellerId}/kyc-status (tự kiểm tra)
6.1.6 Đăng nhập, MFA và Social login (FR-01, FR-02, FR-27)
sequenceDiagram
actor User as Customer/Seller/Admin
participant Web as Web/Seller/Admin Portal
participant IDSvc as Identity & Access Service
participant DB as Identity DB
User->>Web: Nhập email/password
Web->>IDSvc: POST /v1/auth/login
IDSvc->>DB: Đọc user_account (password_hash, role, mfa_enabled, failed_login_count, locked_until) — v3
alt Tài khoản đang bị khoá (locked_until > now) — v3
IDSvc-->>Web: 401 sai thông tin đăng nhập / tài khoản tạm khoá do đăng nhập sai nhiều lần (mã lỗi cụ thể và khoảng thời gian khoá do mục 8 — security-architect quy định)
else Không bị khoá
IDSvc->>IDSvc: So khớp password_hash
alt Mật khẩu sai — v3
IDSvc->>DB: Tăng failed_login_count += 1, ghi last_failed_login_at=now
alt failed_login_count vượt ngưỡng cho phép (ngưỡng cụ thể do mục 8 quy định) — v3
IDSvc->>DB: Đặt locked_until = now + khoảng thời gian khoá (khoảng thời gian do mục 8 quy định)
end
IDSvc-->>Web: 401 sai thông tin đăng nhập
else Mật khẩu đúng
IDSvc->>DB: Reset failed_login_count=0, last_failed_login_at=null — v3
alt role=platform_admin (bắt buộc MFA) hoặc role=seller có mfa_enabled=true
IDSvc-->>Web: 200 { mfaRequired:true, mfaChallengeToken, mfaMethod }
Web->>User: Yêu cầu nhập mã OTP
User->>Web: Nhập OTP (TOTP/SMS)
Web->>IDSvc: POST /v1/auth/mfa/challenge (mfaChallengeToken, otp)
IDSvc->>DB: Xác minh MFA_DEVICE.secret_encrypted
IDSvc-->>Web: 200 { accessToken, refreshToken }
else Không cần MFA (Customer, hoặc Seller chưa bật MFA)
IDSvc-->>Web: 200 { accessToken, refreshToken }
end
end
end
Note over User,IDSvc: Luồng Social login (FR-02, v3): User chọn "Đăng nhập Google/Facebook" → redirect OAuth2 kèm tham số state (sinh ngẫu nhiên, lưu tạm phía server) → provider → POST /v1/auth/oauth/{provider}/callback (state, code) → IDSvc xác thực state khớp giá trị đã phát hành (thiếu/không khớp → 400 ERR_OAUTH_STATE_INVALID, chống CSRF) → nếu email trả về từ provider đã có tài khoản email/password đăng ký sẵn (chưa liên kết OAuth), KHÔNG tự động merge (no auto-merge) → trả 409 ERR_ACCOUNT_LINK_REQUIRED, yêu cầu xác minh sở hữu email trước khi liên kết → nếu email chưa tồn tại, tạo Customer mới liên kết OAuthIdentity → phát hành accessToken/refreshToken tương tự trên
6.2 Sơ đồ lớp (Class Diagram) & Trạng thái (State Diagram)
6.2.1 Class Diagram — Cart & Order domain (FR-05, FR-06, FR-08, FR-09)
classDiagram
class Cart {
+UUID id
+UUID customerId
+String sessionId
+String status
+addItem(productVariantId, sellerId, quantity)
+applyCoupon(code)
}
class CartItem {
+UUID id
+UUID cartId
+UUID productVariantId
+UUID sellerId
+int quantity
+Decimal unitPriceSnapshot
}
class Order {
+UUID id
+UUID customerId
+String orderNumber
+Decimal totalAmount
+String status
+splitBySeller() OrderSeller[]
+cancel()
}
class OrderSeller {
+UUID id
+UUID orderId
+UUID sellerId
+String subOrderNumber
+Decimal subtotalAmount
+String status
+confirm()
+markShipped()
+markDelivered()
}
class OrderItem {
+UUID id
+UUID orderSellerId
+UUID productVariantId
+int quantity
+Decimal unitPrice
+Decimal lineTotal
}
class ReturnRequest {
+UUID id
+UUID orderSellerId
+UUID customerId
+String status
+String reason
}
class Dispute {
+UUID id
+UUID orderSellerId
+String raisedBy
+UUID assignedCsrId
+String status
+resolve(decision)
}
Cart "1" *-- "many" CartItem
Order "1" *-- "many" OrderSeller
OrderSeller "1" *-- "many" OrderItem
OrderSeller "1" o-- "0..1" ReturnRequest
OrderSeller "1" o-- "0..*" Dispute
6.2.2 Class Diagram — Commission & Payout domain (FR-20, FR-21, FR-22)
classDiagram
class CommissionRule {
+UUID id
+UUID categoryId
+Decimal commissionPercent
+Date effectiveFrom
+Date effectiveTo
+calculateCommission(grossAmount) Decimal
}
class CommissionTransaction {
+UUID id
+UUID orderSellerId
+UUID sellerId
+Decimal grossAmount
+Decimal commissionAmount
+Decimal netAmount
}
class Payout {
+UUID id
+UUID sellerId
+Date periodStart
+Date periodEnd
+Decimal totalNetAmount
+String status
+submitToBank()
+markPaid()
+markFailed()
}
class PayoutHold {
+UUID id
+UUID commissionTransactionId
+Date holdUntilDate
+String releaseStatus
+release()
+freeze()
}
class Seller {
+UUID id
+String status
+approve()
+suspend()
}
CommissionRule "1" --> "many" CommissionTransaction : applies
CommissionTransaction "1" --> "0..1" PayoutHold : held_by
CommissionTransaction "many" --> "1" Payout : settled_in
Seller "1" --> "many" Payout : receives
6.3 State Diagram — vòng đời entity nhiều trạng thái
6.3.1 OrderSeller (FR-06, FR-08, FR-09, FR-19)
stateDiagram-v2
[*] --> pending: Checkout thành công (Cart & Order Service)
pending --> confirmed: Seller xác nhận (PATCH /v1/seller/orders/{orderId}/status) hoặc auto sau PaymentConfirmed
pending --> cancelled: Customer huỷ (BR-10) hoặc hết hạn thanh toán
confirmed --> cancelled: Customer huỷ trong điều kiện cho phép (BR-10) — Seller/CSR cũng có thể huỷ khi hết hàng
confirmed --> packed: Ops đóng gói xong (PATCH /v1/ops/orders/{orderId}/fulfillment)
packed --> shipped: Shipping & Fulfillment Service tạo vận đơn GHN/GHTK thành công
shipped --> delivered: Webhook GHN/GHTK báo giao thành công
delivered --> returned: CSR/Admin duyệt ReturnRequest (refund) — kích hoạt bởi Dispute resolution (FR-25)
cancelled --> [*]
returned --> [*]
delivered --> [*]: Hết thời gian khiếu nại, đơn coi như hoàn tất
6.3.2 Payment (FR-07)
stateDiagram-v2
[*] --> pending: POST /v1/payments khởi tạo giao dịch
pending --> success: Webhook VNPay/Momo xác nhận thành công (chữ ký hợp lệ)
pending --> failed: Webhook báo thất bại hoặc timeout không có callback (qua job đối soát, mục 3.4)
success --> refunded: CSR/Admin duyệt hoàn tiền sau Dispute resolution (FR-25)
failed --> [*]
success --> [*]
refunded --> [*]
6.3.3 Seller — trạng thái KYC/hoạt động (FR-17, FR-23)
stateDiagram-v2
[*] --> pending_kyc: Seller đăng ký (POST /v1/sellers/register)
pending_kyc --> active: Admin duyệt toàn bộ KYCDocument bắt buộc (PATCH .../kyc-review, chỉ Admin)
pending_kyc --> rejected: Admin từ chối KYC (chỉ Admin), Seller có thể nộp lại → về pending_kyc
rejected --> pending_kyc: Seller nộp lại giấy tờ
active --> suspended: Admin khoá do vi phạm (PATCH /v1/admin/sellers/{sellerId}/status, chỉ Admin)
suspended --> active: Admin mở khoá sau xác minh (chỉ Admin)
(v3) Mọi chuyển trạng thái do Admin thực hiện ở trên (
pending_kyc→active,pending_kyc→rejected,active↔suspended) đều phát event ghiaudit_log(actor=Admin, action tương ứng, resource=sellerId) tại Audit & Compliance Service — mục 5.2.11.
6.3.4 ReturnRequest (FR-09)
stateDiagram-v2
[*] --> requested: Customer gửi yêu cầu (POST .../return-requests)
requested --> approved: CSR/Admin hoặc Seller đồng ý đổi trả
requested --> rejected: CSR/Admin hoặc Seller từ chối (có thể mở Dispute nếu Customer không đồng ý)
approved --> refunded: Payment Service hoàn tất hoàn tiền
rejected --> [*]
refunded --> [*]
6.3.5 Dispute (FR-25)
stateDiagram-v2
[*] --> open: Tạo tự động khi Seller từ chối/không phản hồi ReturnRequest trong SLA, hoặc Customer/Seller khiếu nại trực tiếp
open --> investigating: CSR nhận xử lý (assigned_csr_id được gán)
investigating --> resolved: CSR/Admin ra quyết định (refund/reject) — chỉ CSR/Admin
investigating --> escalated: CSR chuyển cấp cao hơn (Admin) khi vượt thẩm quyền
escalated --> resolved: Admin ra quyết định cuối cùng
resolved --> [*]
6.3.6 Payout & PayoutHold (FR-22)
stateDiagram-v2
[*] --> holding: PayoutHold tạo khi nhận event OrderDelivered (hold_until_date = deliveredAt + holdDays, BR-04)
holding --> disputed_frozen: Dispute được mở cho order_seller liên quan trước hold_until_date (chỉ hệ thống, tự động qua event)
disputed_frozen --> holding: Dispute resolved với quyết định "reject" (từ chối khiếu nại) — chờ đến hold_until_date bình thường
holding --> released: Job payout hàng tuần release khi hold_until_date đã qua và không còn Dispute mở (chỉ hệ thống/Commission & Payout Service)
disputed_frozen --> [*]: Dispute resolved với quyết định "refund" — hoa hồng bị loại khỏi payout vĩnh viễn (xem Finding 6.4 — cần bổ sung trạng thái kết thúc rõ ràng ở mục 5)
stateDiagram-v2
[*] --> scheduled: Commission & Payout Service tạo Payout theo kỳ (chỉ hệ thống, job hàng tuần)
scheduled --> processing: Gửi batch file chuyển khoản tới Ngân hàng
processing --> paid: Ngân hàng xác nhận chuyển thành công
processing --> failed: Ngân hàng từ chối/lỗi định dạng
failed --> processing: Admin xác nhận thủ công và gọi POST /v1/admin/payouts/{payoutId}/retry (chỉ Admin, không tự động)
paid --> [*]
6.4 Logic nghiệp vụ (Business Rules)
| Mã | FR liên quan | Mô tả quy tắc |
|---|---|---|
| BR-01 | FR-06 | Tách đơn theo seller: khi checkout, Cart (nhiều CartItem từ nhiều seller) được nhóm theo seller_id; mỗi nhóm sinh ra một OrderSeller con thuộc Order cha; Order.totalAmount = tổng OrderSeller.subtotalAmount; mỗi OrderSeller có vòng đời trạng thái độc lập (xem 6.3.1) vì mỗi seller xử lý/giao hàng riêng. |
| BR-02 | FR-05, FR-06, FR-18 | Giữ tồn kho khi checkout (chống oversell): tại thời điểm POST /v1/checkout, hệ thống tăng inventory_stock.quantity_reserved và kiểm tra quantity_available - quantity_reserved >= quantity cho từng ProductVariant; nếu không đủ, trả 409 ERR_CONFLICT trước khi tạo Order. Sau khi PaymentConfirmed, phần reserved được commit trừ vào quantity_available thật; nếu thanh toán thất bại/timeout, phần reserved được nhả lại (release) sau một khoảng thời gian chờ. |
| BR-03 | FR-21 | Tính hoa hồng: commissionAmount = orderItem.lineTotal × commissionRule.commissionPercent / 100, trong đó commissionRule là bản ghi CommissionRule có effective_from <= orderDate và (effective_to là null hoặc >= orderDate) cho category_id tương ứng sản phẩm; netAmount = grossAmount − commissionAmount. Nếu một Category chưa có CommissionRule nào hiệu lực, hệ thống chặn seller đăng bán sản phẩm thuộc category đó cho tới khi Admin cấu hình (ràng buộc bổ sung, cần Admin xác nhận trước go-live). |
| BR-04 | FR-22 | Kỳ giữ tiền (payout hold) — chốt giá trị mặc định + cấu hình theo ngành hàng: brief chỉ xác nhận cơ chế "3-7 ngày sau giao hàng thành công" như một khoảng, không có giá trị cụ thể. Để Commission & Payout Service vận hành được, thiết kế chốt: giá trị mặc định toàn sàn = 5 ngày (điểm giữa khoảng 3-7, cân bằng giữa bảo vệ quyền lợi đổi trả của khách và dòng tiền của seller), và cho phép Admin cấu hình số ngày hold khác nhau theo từng Category (VD ngành hàng tỷ lệ đổi trả cao như thời trang có thể đặt 7 ngày; ngành hàng ít đổi trả như thực phẩm có thể đặt 3 ngày). Pseudo-code: holdDays = CommissionRule.findByCategory(categoryId).holdDays if holdDays is null: holdDays = PLATFORM_DEFAULT_HOLD_DAYS # = 5PayoutHold.hold_until_date = OrderDelivered.deliveredAt + holdDays days Đây là giả định mặc định cần chủ dự án xác nhận trước go-live (số ngày cụ thể + có nên giới hạn admin trong khoảng 3-7 hay cho phép vượt khoảng cho ngành hàng đặc thù) — xem openQuestions và Finding bên dưới (cần bổ sung cột hold_days ở mục 5 và field tương ứng ở endpoint mục 4). |
| BR-05 | FR-22 | Điều kiện release payout: job hàng tuần chỉ release PayoutHold khi hold_until_date <= ngày chạy job và không tồn tại Dispute ở trạng thái open/investigating cho OrderSeller liên quan; nếu có Dispute mở, giữ nguyên holding (hoặc chuyển disputed_frozen) cho đến khi Dispute được resolved. Một Payout gộp toàn bộ CommissionTransaction.netAmount đã released trong kỳ của một seller thành một lần chuyển khoản (không chuyển riêng từng đơn) — theo brief "payout hàng tuần". |
| BR-06 | FR-14 | Tích điểm loyalty: pointsEarned = floor(orderSeller.subtotalAmount / 10000) × 1, ghi nhận khi nhận event OrderDelivered (không tích điểm khi mới đặt hàng, tránh gian lận huỷ đơn sau khi tích). Giả định cần xác nhận: brief ghi "1 điểm/10.000đ giá trị đơn hàng" nhưng không nói rõ tính trên Order cha hay từng OrderSeller, và có trừ phí vận chuyển/giảm giá coupon hay không — thiết kế tạm tính trên subtotalAmount (đã trừ giảm giá) của từng OrderSeller, chưa gồm phí ship — xem openQuestions. |
| BR-07 | FR-14 | Xếp hạng thành viên (tier): LoyaltyAccount.total_spend_12m là tổng chi tiêu (theo subtotalAmount các đơn delivered) trong cửa sổ trượt 12 tháng gần nhất, được tính lại bởi batch job định kỳ (đề xuất: hằng đêm) vì đơn hàng cũ hơn 12 tháng phải rớt khỏi cửa sổ tính toán, không chỉ cộng dồn một chiều. Tier được gán theo ngưỡng MembershipTier.min_spend_threshold (Bạc < Vàng < Kim Cương). Giả định cần xác nhận: brief xác nhận có 3 hạng nhưng không cho số VND ngưỡng cụ thể cho từng hạng — xem openQuestions. |
| BR-08 | FR-14 | Đổi điểm lấy giảm giá: 100 điểm = 10.000đ; chỉ cho đổi theo bội số 100 điểm; điểm đổi được áp làm giảm giá cho Cart/Order hiện tại qua POST /v1/customers/me/loyalty/redeem, ghi LoyaltyTransaction(type=redeem, points=-N); không cho đổi vượt quá points_balance hiện có. |
| BR-09 | FR-13 | Điều kiện áp dụng Promotion/coupon: promotion.status='active', valid_from <= now <= valid_to, số lượt đã dùng (đếm từ promotion_usage) < usage_limit (nếu có), và cart.subtotal >= min_order_amount (nếu có). Giảm giá tính theo type (percent: value% trên subtotal; fixed_amount: trừ thẳng value, không âm). Mỗi coupon chỉ áp dụng một lần cho một Order (UNIQUE(promotion_id, order_id)). |
| BR-10 | FR-08 | Điều kiện huỷ đơn (Customer tự huỷ): chỉ cho phép khi OrderSeller.status ∈ {pending, confirmed} (chưa đóng gói); từ packed trở đi, Customer phải gửi yêu cầu qua đổi trả/khiếu nại (FR-09/FR-25) thay vì huỷ trực tiếp. Giả định: brief/FR-08 chỉ nói "huỷ đơn (trong điều kiện cho phép)" mà không định nghĩa ngưỡng chính xác — mốc packed là giả định hợp lý theo luồng vận hành (mục 6.1.2), cần chủ dự án xác nhận. |
| BR-11 | FR-11 | Điều kiện được đánh giá sản phẩm: Customer chỉ được tạo Review cho một order_item_id khi OrderSeller.status = delivered (đã nhận hàng) và tồn tại order_item thuộc customer_id đó; ràng buộc UNIQUE(customer_id, order_item_id) đảm bảo mỗi lượt mua chỉ đánh giá một lần (khớp mục 5.2.8). |
| BR-12 | FR-27 | Chính sách MFA: role='platform_admin' → bắt buộc mfa_enabled=true, chặn hoàn toàn truy cập scope admin:* cho đến khi hoàn tất mfa/enroll; role='seller' → khuyến khích, không chặn đăng nhập nhưng Seller Portal hiển thị nhắc bật MFA liên tục cho đến khi bật; role='customer' → không áp dụng MFA ở MVP. (v3) Ngoài MFA, đăng nhập sai mật khẩu liên tiếp làm tăng user_account.failed_login_count; vượt ngưỡng (do mục 8 quy định) → đặt locked_until tạm khoá đăng nhập — xem sequence 6.1.6. |
| BR-13 | FR-17 | Duyệt KYC thủ công, không auto-approve: Seller.status chỉ chuyển active khi toàn bộ KYCDocument bắt buộc (business_license, id_card_front, id_card_back) có verified_status='verified', mỗi tài liệu được một Admin xem xét và duyệt riêng lẻ (không có quy tắc tự động duyệt theo brief — marketplace xác nhận "admin duyệt thủ công"). Nếu bất kỳ tài liệu nào rejected, Seller.status='rejected' kèm reason, Seller có thể nộp lại. (v3 — theo review mục 8) Admin xem nội dung KYCDocument qua pre-signed URL sinh bởi Seller Management Service, TTL tối đa 5 phút, không truy cập trực tiếp object storage; mọi quyết định duyệt/từ chối ghi audit_log (xem sequence 6.1.5). |
| BR-14 | FR-09, FR-25 | Xử lý tranh chấp — nguyên tắc chung (không có công thức hoàn tiền cụ thể trong brief): (a) khi ReturnRequest được tạo hoặc Dispute mở, PayoutHold liên quan (nếu còn holding) được tự động chuyển disputed_frozen để tránh giải ngân trước khi có quyết định cuối; (b) quyết định refund/reject chỉ do CSR/Admin thực hiện qua PATCH /v1/admin/disputes/{disputeId} (ghi audit_log, xem 6.1.3); (c) khi refund, Payment.status chuyển refunded và hoa hồng tương ứng bị loại khỏi payout. Brief không quy định: mức hoàn tiền (toàn phần/một phần theo tỷ lệ đã sử dụng), ai chịu phí vận chuyển hoàn trả, và SLA phản hồi của seller trước khi hệ thống tự mở Dispute — đây là openQuestions, không tự đặt công thức cụ thể. |
| BR-15 | FR-26 | Fallback vận chuyển: khi tạo vận đơn qua GHN timeout/lỗi sau tối đa 3 lần retry (theo mục 3.4), hệ thống thử tạo lại qua GHTK nếu khu vực giao hàng được GHTK hỗ trợ; nếu cả hai đều lỗi, đưa vào hàng đợi để Ops xử lý thủ công, không chặn trạng thái OrderSeller (vẫn giữ confirmed/packed chờ xử lý). |
6.5 Ma trận truy vết bổ sung cho mục 2.4
| FR | Sequence/State/Business Rule liên quan |
|---|---|
| FR-01 | 6.1.6 (Sequence đăng nhập) |
| FR-02 | 6.1.6 (Social login) |
| FR-05 | 6.1.1 (Checkout), BR-02 |
| FR-06 | 6.1.1, BR-01, State 6.3.1 |
| FR-07 | 6.1.1, State 6.3.2 |
| FR-08 | State 6.3.1, BR-10 |
| FR-09 | 6.1.3, State 6.3.4, BR-14 |
| FR-11 | BR-11 |
| FR-12 | 6.1.1, 6.1.2 (Notification qua event) |
| FR-13 | 6.1.1, BR-09 |
| FR-14 | 6.1.2, BR-06, BR-07, BR-08 |
| FR-17 | 6.1.5, State 6.3.3, BR-13 |
| FR-18 | 6.1.1, BR-02 |
| FR-19 | 6.1.2, State 6.3.1 |
| FR-20 | 6.1.4, Class Diagram 6.2.2 |
| FR-21 | 6.1.4, BR-03 |
| FR-22 | 6.1.4, State 6.3.6, BR-04, BR-05 |
| FR-23 | State 6.3.3 |
| FR-25 | 6.1.3, State 6.3.5, BR-14 |
| FR-26 | 6.1.2, BR-15 |
| FR-27 | 6.1.6, BR-12 |
Các FR không xuất hiện ở trên (FR-03, FR-04, FR-10, FR-15, FR-16, FR-24) là các luồng CRUD/tra cứu/cross-cutting đơn giản, không có rẽ nhánh nghiệp vụ đáng kể cần sequence/state diagram riêng — đã được đặc tả đầy đủ qua endpoint mục 4 và schema mục 5.
6.6 Findings (nhắm mục 4/5)
- [severity: medium — đã giải quyết ở mục 4/5 v3] Bảng
commission_rule(mục 5.2.6) trước đây (v1) thiếu cột lưu số ngày hold theo ngành hàng (BR-04); mục 5 v3 đã bổ sungcommission_rule.hold_days(nullable, fallback mặc định 5 ngày) và mục 4 v3 đã bổ sung fieldholdDaysởGET/PUT /v1/admin/commission-rules(mục 4.1.8). Không còn khoảng trống — giữ lại mục này như ghi nhận lịch sử. - [severity: medium — đã giải quyết ở mục 4/5 v3] Enum
payout_hold.release_status(mục 5.2.6) trước đây (v1) thiếu trạng thái kết thúc rõ ràng cho trường hợp Dispute được duyệt hoàn tiền; mục 5 v3 đã bổ sung trạng thái kết thúcreversedđể phân biệt vớidisputed_frozen(tạm giữ). Không còn khoảng trống — giữ lại mục này như ghi nhận lịch sử. - [severity: low] Bảng
membership_tier(mục 5.2.7) có cộtmin_spend_thresholdnhưng brief/mục 2 không cung cấp giá trị VND cụ thể cho từng hạng Bạc/Vàng/Kim Cương — cần chủ dự án xác nhận trước khi seed dữ liệu (liên quan BR-07). - [severity: low] FR-08 (mục 2) mô tả "huỷ đơn (trong điều kiện cho phép)" nhưng không định nghĩa ngưỡng trạng thái chính xác — BR-10 tạm giả định mốc
packed, cần bổ sung rõ trong mục 2 hoặc xác nhận với chủ dự án. - [severity: low, mới — v2] Mục 4 (4.1.7 Seller Management Service) hiện chưa có endpoint cho Admin lấy pre-signed URL để xem nội dung một
KYCDocumentcụ thể (chỉ cóPOST .../kyc-documentsđể upload vàPATCH .../kyc-reviewđể duyệt). Theo ghi chú người duyệt (findings bảo mật mục 8), cần bổ sung một endpoint dạngGET /v1/admin/sellers/{sellerId}/kyc-documents/{documentId}/view-urltrả về{ viewUrl, expiresInSeconds<=300 }để Admin không truy cập trực tiếp object storage — xem sequence 6.1.5. - [severity: low, mới — v2] Mục 4 (4.1.3 Identity & Access) chưa có mã lỗi cụ thể cho trường hợp tài khoản bị khoá tạm do vượt ngưỡng đăng nhập sai (
user_account.locked_until, mục 5.2.1 v3) — hiện chỉ có401 ERR_AUTH_REQUIRED/ERR_AUTH_INVALID_TOKEN/ERR_MFA_REQUIRED. Đề xuất bổ sung mã lỗi riêng (VD403/423 ERR_ACCOUNT_LOCKED) tại mục 4 khi ngưỡng/khoảng thời gian khoá được chốt ở mục 8.
6.7 Giả định (Assumptions)
- Giá trị mặc định kỳ giữ tiền (payout hold) = 5 ngày (giữa khoảng 3-7 ngày theo brief), có thể cấu hình khác theo từng
Category— cần chủ dự án xác nhận trước go-live (BR-04). - Điểm loyalty tính trên
subtotalAmountcủa từngOrderSeller(đã trừ giảm giá, chưa gồm phí vận chuyển), kích hoạt khi đơndelivered— cần xác nhận với chủ dự án (BR-06). - Ngưỡng huỷ đơn tự phục vụ của Customer dừng ở trạng thái
packed— cần xác nhận (BR-10). - SLA phản hồi của Seller trước khi hệ thống tự động mở
Disputetừ mộtReturnRequestbị từ chối/không phản hồi chưa được định nghĩa số ngày cụ thể — tạm không đặt giá trị cứng, cần chủ dự án cung cấp. - (v2, mới) Kênh truyền batch file chuyển khoản payout tới ngân hàng: giả định SFTP + mã hoá PGP hoặc API HTTPS của ngân hàng đối tác — ngân hàng đối tác và chuẩn kết nối cụ thể chưa được chốt trong brief, cần xác nhận trước go-live (6.1.4).
- (v2, mới) Ngưỡng số lần đăng nhập sai (
failed_login_count) và khoảng thời gian khoá tài khoản (locked_until) trong luồng 6.1.6 chưa có giá trị cụ thể ở mục này — theo ghi chú người duyệt, đây là phạm vi của mục 8 (security-architect) quy định; thiết kế luồng chỉ mô tả cơ chế (đếm, khoá, mở khoá tự động), không tự đặt số.
6.8 Câu hỏi còn mở (Open Questions)
- Số ngày hold payout chính xác (đã chốt giá trị mặc định 5 ngày + cơ chế cấu hình theo category ở BR-04) có cần giới hạn cứng trong khoảng 3-7 ngày hay cho phép Admin đặt ngoài khoảng này cho ngành hàng đặc thù?
- Ngưỡng chi tiêu 12 tháng (VND) cụ thể cho từng hạng thành viên Bạc/Vàng/Kim Cương là bao nhiêu?
- Công thức/mức hoàn tiền khi Dispute được duyệt: hoàn toàn phần hay theo tỷ lệ đã sử dụng? Ai chịu phí vận chuyển hoàn trả (Customer/Seller/Sàn)?
- SLA cụ thể (số ngày) để Seller phản hồi một
ReturnRequesttrước khi hệ thống tự động leo thang thànhDispute? - Điểm loyalty tính trên giá trị đơn hàng gộp (
Ordercha) hay theo từngOrderSeller— và có gồm phí vận chuyển/thuế hay không? - (v2, mới) Ngân hàng đối tác cụ thể cho payout và chuẩn kết nối (SFTP+PGP nội bộ hay API HTTPS của ngân hàng) — cần chủ dự án/đối tác ngân hàng xác nhận (6.1.4).
- (v2, mới) Ngưỡng
failed_login_countvà khoảng thời gianlocked_until(khoá tài khoản tạm thời) cụ thể là bao nhiêu — cần mục 8 (security-architect) quy định để hoàn thiện luồng 6.1.6 và mã lỗi tương ứng ở mục 4.