Files
sys-analysis-design/e-commerce/docs/sections/06-luong-xu-ly.md
2026-09-08 12:35:40 +07:00

41 KiB
Raw Blame History

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 = 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)

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 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)

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 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)

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 # = 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.