--- section: "06" title: Thiết kế luồng xử lý chi tiết status: approved version: 2 reviewer_notes: "" --- # 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 = true` và 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 `KYCDocument` qua 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 theo `failed_login_count`/`locked_until` (mục 5.2.1 v3); (e) phản ánh `403 ERR_FORBIDDEN_OWNERSHIP`, chống replay webhook (timestamp ±5 phút + idempotency theo `gatewayTransactionRef`), và OAuth `state`/`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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ```mermaid 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 Service` gọ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 ghi `audit_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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ```mermaid 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 ghi `audit_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) ```mermaid 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) ```mermaid 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) ```mermaid 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) ``` ```mermaid 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 # = 5`
`PayoutHold.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) 1. **[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ổ sung `commission_rule.hold_days` (nullable, fallback mặc định 5 ngày) và mục 4 v3 đã bổ sung field `holdDays` ở `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ử. 2. **[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úc `reversed` để phân biệt với `disputed_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ử. 3. **[severity: low]** Bảng `membership_tier` (mục 5.2.7) có cột `min_spend_threshold` như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). 4. **[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. 5. **[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 `KYCDocument` cụ 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ạng `GET /v1/admin/sellers/{sellerId}/kyc-documents/{documentId}/view-url` trả về `{ viewUrl, expiresInSeconds<=300 }` để Admin không truy cập trực tiếp object storage — xem sequence 6.1.5. 6. **[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 (VD `403/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 `subtotalAmount` của từng `OrderSeller` (đã trừ giảm giá, chưa gồm phí vận chuyển), kích hoạt khi đơn `delivered` — 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ở `Dispute` từ một `ReturnRequest` bị 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 `ReturnRequest` trước khi hệ thống tự động leo thang thành `Dispute`? - Điểm loyalty tính trên giá trị đơn hàng gộp (`Order` cha) hay theo từng `OrderSeller` — 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_count` và khoảng thời gian `locked_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.