RS ULife · Odoo 18 Enterprise

MLM Commission Engine
Sale Order → เงินเข้าบัญชี → WHT

คู่มืออธิบายเชิงเทคนิคแบบอินเทอร์แอกทีฟ — ไล่ทีละ 7 ขั้นตอนตั้งแต่ยืนยันออเดอร์ จนถึงเงินโอนผ่าน BBL และใบ 50 ทวิ พร้อมชื่อตาราง/field ตรงตาม source code

7 stages · sale_order → commission_line → account_payment อ้างอิงโค้ด 2026-07-14 โมดูล ulife_commission / _sales / _bbl_payout / _th_tax
🔑
กฎเหล็ก 2 ข้อ ที่ต้องเข้าใจก่อนอ่านทั้งหน้า — (1) QV ใช้วัดคุณสมบัติ (qualify) ส่วน CV ใช้จ่ายเงิน (pay) อย่าสลับกัน · (2) การส่งของ (delivery) คือตัวจุดชนวนเงิน ไม่ใช่การยืนยันออเดอร์ — Confirm แค่ "แช่แข็ง" ว่าใครจะได้เครดิต

Diagram 1 · Pipeline Overview

ภาพรวม Pipeline 7 ขั้นตอน

ค่าคอมมิชชั่นไม่ได้เกิดในขั้นตอนเดียว แต่ไหลผ่าน 7 stage เรียงตามเวลา — กดที่การ์ดแต่ละใบเพื่อดูรายละเอียด field/ตารางที่เกี่ยวข้อง (ออเดอร์ตัวอย่าง SO001 ฿10,000 เดียวกับ "เดินทางของเงิน") (source §1)

S1 / 7
กด ▶ เพื่อดูออเดอร์ไหลผ่านทั้ง 7 ขั้นตอน — หรือกดที่การ์ดด้านล่างเพื่ออ่านรายละเอียดแบบไม่ใช้แอนิเมชัน
SO001 · ฿10,000
1 ยืนยัน Sale Order (Confirm) action_confirm() → FREEZE ว่าใครได้เครดิต + สายงาน (genealogy) ณ วินาทีนั้น
ยังไม่มีเงินสักบาทเกิดขึ้น — เขียนแค่ attribution snapshot ลง sale_order: ผู้ได้เครดิต (ulife_commission_partner_id), สายงานแช่แข็ง (genealogy_path_snapshot), sponsor ณ เวลานั้น, และวันที่งวด ดูรายละเอียดเต็มที่ Diagram 2
sale_order frozen
กฎข้อ 1 QV ใช้วัดคุณสมบัติ (qualify) · CV ใช้จ่ายเงิน (pay) — อย่าสลับกัน
2 คำนวณ QV/CV compute อัตโนมัติจากตาราง ulife_product_qvcv (matrix: สินค้า × ประเภทสมาชิก)
คำนวณตั้งแต่ตอนร่าง (draft) เก็บที่ sale_order.qv_total / cv_total แล้ว cron รายคืน (~01:00) สรุปยอดลง res_partner + เก็บ audit ราย snapshot ที่ commission_qv_cv_daily — ตัวเลขพวกนี้ ใช้แสดงผล/ตรวจคุณสมบัติเท่านั้น ไม่ใช่ฐานเงินจริง ดูรายละเอียดที่ Diagram 3
ulife_product_qvcv master sale_order.qv/cv_total recomputed res_partner recomputed commission_qv_cv_daily append-only
3 ส่งของครบ (delivery trigger) delivery_status = 'full' ← จุดชนวนเงิน (กฎข้อ 2)
สัญญาณ "ส่งของแล้ว" เป็น OR 3 ทาง: (1) field มาตรฐาน Odoo delivery_status='full' (2) มี ulife.delivery.attempt ส่งสำเร็จแบบ DSL/messenger (3) สมาชิกกดยืนยันรับของเอง หรือระบบประมาณจากพัสดุค้าง 14 วัน — บางส่วนจึงเป็น "วันส่งของแบบประมาณการ" ไม่ใช่ยืนยันจากขนส่งเสมอไป
sale_order.delivery_status live ulife.delivery.attempt transactional
กฎข้อ 2 การส่งของ (delivery) คือตัวจุดชนวนเงิน — Confirm ไม่สร้างค่าคอมแม้แต่บาทเดียว
4 ค่าคอม 10% รายวัน (FRONT) cron 02:00 ทุกคืน — 1 แถวใน commission_line ต่อ 1 ออเดอร์ (base = cv_total)
ผู้รับ = affiliate ที่ยัง active + earns_commission ตัวแรกที่เจอ เดินขึ้นจากสายงานแช่แข็ง (ดู Diagram 4) · rate = daily_rate ของตำแหน่ง Paid-As (ปกติ 10%) · state เริ่มที่ 'pending'
commission_line (front) frozen re-derived cv
5 รอบเดือน (title + bonus) cron 3 ตัว ลำดับ 10 → 20 → 30 — ห้ามสลับ
ประเมินตำแหน่ง (Paid-As) + Bonus-Plus → คุณสมบัติ Partner T1–T6 → Team Bonus L0–L3 ทุกตัวเลขยัง "ประมาณการ" จนกว่างวดจะปิด ดูรายละเอียดที่ Diagram 6
ulife_commission_period control commission_line (monthly) recomputed commission_line_source recomputed commission_title master
6 ปิดงวด + ปล่อยจ่าย + หัก WHT Manager กด action_close() → cron ปล่อย pending → available → หัก WHT ตรงนี้
WHT ถูกหักตอนปล่อยจ่าย ไม่ใช่ตอนถอนเงิน ลง GL ทันที: DR 610101 Commission Expense / CR 210203 Commission Payable ดูรายละเอียดที่ Diagram 5 และ 8
ulife_commission_period (closed) control commission_line (available) recomputed commission_nid_ytd ledger account_move transactional
7 ถอนเงิน (Withdrawal) + โอนผ่าน BBL portal → account_payment → BBL SFTP → MT940 ยืนยัน → state = 'paid' → ใบ 50 ทวิ
สมาชิกกดถอนบน portal (ขั้นต่ำ ฿500) → สร้าง account.payment แบบ draft → BBL SFTP batch → ธนาคารยืนยันด้วย MT940 → cascade เป็น paid + ลง GL settlement ดูรายละเอียดที่ Diagram 7
account_payment transactional account_batch_payment transactional withholding_tax_cert materialized
📝
สรุปเป็นประโยคเดียว: ยืนยันออเดอร์ = แช่แข็งว่าใครได้ · ส่งของ = ปลดชนวนให้เงินเกิด · รอบเดือน = คำนวณโบนัสตามตำแหน่ง · ปิดงวด = หักภาษีและทำให้จ่ายได้ · ถอนเงิน = โอนจริงผ่านธนาคาร

Centerpiece · Interactive · Animated

การเดินทางของเงิน — ตัวอย่างจริงทีละสเต็ป (Order CV ฿10,000)

ตามเหรียญทีละบาทจาก 1 ออเดอร์ ว่าไปลงตารางไหนบ้าง — กด ▶ เล่นต่อเนื่อง หรือกดจุดเพื่อกระโดดไปดูสเต็ปไหนก็ได้

🎭
ตัวอย่างสมมติ — อัตราจริงขึ้นกับตำแหน่ง Paid-As และเงื่อนไขคุณสมบัติของแต่ละคน ตัวเลขที่ใช้ในสเต็ปนี้เป็นตัวอย่างประกอบเพื่อสอน ไม่ใช่ตารางอัตรามาตรฐาน
ส้ม
คุณส้ม — ผู้ซื้อ
Shopper (ไม่ใช่ affiliate)
เอ
คุณเอ — ผู้แนะนำ (frozen)
Affiliate · Paid-As = Advanced
daily_rate 10% · team_l0_rate 5%
บี
คุณบี — upline ของเอ
Partner
team_l1_rate 3%
STEP 0 / 6
← เลื่อนดูผัง →
฿0 commission
sale_order
sale.order
period: open
commission_line — คุณเอ
commission.line
period: open
commission_line — คุณบี
commission.line
commission_nid_ytd
commission.nid.ytd (คุณเอ)
account_move
GL — accrual + settlement
ยังไม่ถอน
account_payment
account.payment (คุณเอ)
กด ▶ เพื่อเริ่มดูการเดินทางของเงิน…

สำหรับทีม IT · Interactive · Animated

มุมมองฐานข้อมูล (Technical) — ลงลึกระดับตาราง

ออเดอร์เดิม ฿10,000 (SO001) จาก "การเดินทางของเงิน" ด้านบน แต่คราวนี้ดูเป็น row จริงในตาราง ทีละ INSERT/UPDATE — เขียวกะพริบ = แถวใหม่ · เหลืองกะพริบ = cell ที่ถูกแก้ (ตัวอย่างสมมติ — ตัวเลขตรงกับส่วน "เดินทางของเงิน" ด้านบนทุกจุด)

T1 / T10
กด ▶ เพื่อเริ่มดู INSERT/UPDATE ทีละแถว…

sale_order

sale.order
← เลื่อนดู →
namepartnerqv_totalcv_totalulife_commission_partner_id genealogy_path_snapshotsponsor_snapshot_idulife_commission_date commission_eligibledelivery_status

commission_line

commission.line
← เลื่อนดู →
idpartnersale_order_idcommission_typeperiod_yymm base_amountrateamountwht_amountnet_amount stateavailable_datepayment_id

commission_line_source

commission.line.source
← เลื่อนดู →
idcommission_line_idsale_order_idsource_partner_id cv_amountperiod_yymm

commission_nid_ytd

commission.nid.ytd
← เลื่อนดู →
nidyeargross

ulife_commission_period

ulife.commission.period
← เลื่อนดู →
namedate_startdate_endstateclosed_byclosed_at

account_move

account.move (GL)
← เลื่อนดู →
iddatedr_accountdr_amountcr_accountcr_amountref

account_payment

account.payment
← เลื่อนดู →
idpartnerpayment_typeamountis_wht_appliedstatesettlement_move_id
🔗
ทุกแถวในทุกตารางด้านบน trace กลับถึง SO001 ได้ผ่าน sale_order_id / commission_line_id — นี่คือ audit-immutability ที่กล่าวถึงใน Diagram 3 และ 5

Diagram 2 · STAGE 1

แช่แข็ง (freeze) ตอน Confirm — ระบบล็อกอะไรบ้าง

เมื่อกด Confirm ระบบเขียนชุด "attribution snapshot" ลง sale_order แล้วล็อกไว้ถาวร — ต่อให้ภายหลังสมาชิกย้ายสาย/เปลี่ยน sponsor ค่าเหล่านี้ก็ไม่เปลี่ยนตาม (source §2)

sale_order — frozen ณ วินาที action_confirm()
ulife_commission_partner_id★ ตัวสำคัญที่สุด — "สมาชิกที่ได้รับเครดิต" ทุกการรวมยอดค่าคอมอ่านค่านี้ ไม่เคยอ่าน partner_id ตรงๆ
genealogy_node_idโหนดตำแหน่งในสายงาน (placement node) ของผู้ได้เครดิต
genealogy_path_snapshotสายงานแช่แข็ง — สตริง partner-id จากราก→ผู้ซื้อ เช่น 1.45.230.881 ใช้เดินหา "ใครอยู่เหนือใคร"
sponsor_snapshot_idผู้แนะนำ (sponsor) ณ เวลานั้น
ulife_commission_dateวันที่จัดงวดเดือน — ตั้งเป็นวันยืนยัน (COD จะถูกขยับเป็นวันส่งของภายหลัง)
สิ่งที่ยังไม่เกิดใน STAGE 1: action_confirm() ไม่แตะ qv_total/cv_total และไม่สร้าง commission.line ใดๆ ทั้งสิ้น — ตรงกับกฎข้อ 2

🛒 กรณี "ซื้อให้ทีมงาน" (Buy-for) — จุดที่เข้าใจผิดบ่อย

affiliate ซื้อของแล้วยกเครดิตให้ downline (ตั้ง ulife_credit_partner_id) — กติกาแยกเป็น 2 มิติที่ไม่ตรงกัน:

ขนาดของยอด (magnitude)

QV/CV คิดจาก member_type ของผู้ซื้อ — เพราะราคา/ภาษี/VAT ถูกคิดที่ระดับผู้ซื้อ

การระบุเจ้าของ (attribution)

เงินไหลขึ้นสายของ ulife_credit_partner_id (ผู้ได้เครดิต) — ไม่ใช่สายของผู้ซื้อ

⚠️
แผนภาพแบบ "ผู้ซื้อ → QV/CV ของผู้ซื้อ → สายของผู้ซื้อ" ใช้ไม่ได้กับออเดอร์ Buy-for — ขนาดตามผู้ซื้อ แต่สายตามผู้ได้เครดิต · หมายเหตุ: ulife_credit_partner_id แก้ไม่ได้หลัง confirm (raise ValidationError)

Diagram 3 · STAGE 2

QV/CV มาจากไหน เก็บที่ไหน

ต้นทางคือตาราง matrix ulife_product_qvcv — 1 แถวเก็บว่า "สินค้า X ขายให้สมาชิกประเภท Y ได้ QV/CV เท่าไร" (source §3.1)

← เลื่อนดูตาราง →
สินค้า (ตัวอย่างประกอบ)retailshopperwellnessaffiliateemployeevip
สินค้า AQV 80 / CV 800QV 100 / CV 1,000↩ fallback→shopper↩ fallback→shopper↩ fallback→shopperQV 100 / CV 1,200
สินค้า B✗ ไม่มี fallbackQV 60 / CV 600QV 60 / CV 600QV 60 / CV 600↩ fallback→shopper↩ fallback→shopper
Auto-ship lineไม่ใช้ matrix — ใช้ ulife_program_qv/cv ที่ stamp มาบนบรรทัดโปรแกรมแทน
บรรทัดของแถมulife_is_freebie = True → QV/CV = 0 เสมอ
🔴
retail ไม่ fallback — ต้องมีแถว retail ของตัวเองชัดเจน (มติ PO 2026-07-13: ให้ออเดอร์ retail มีค่าคอมได้ จึงต้องกำหนดค่า retail แยก) ส่วน wellness/affiliate/employee/vip ถ้าไม่เจอแถวตรง จะ fallback ไปใช้ค่าของ shopper

สัดส่วนที่จ่ายจริง (paid-ratio proration)

ถ้าออเดอร์มีส่วนลด/voucher จะเฉลี่ย QV/CV ลงตามสัดส่วนเงินที่จ่ายจริง:

ratio = (ยอดสินค้ารวม + ยอดบรรทัดส่วนลด) / ยอดสินค้ารวม qv_total = int(round(qv × ratio)) // เช่นเดียวกับ cv_total

ผลลัพธ์ไปลงที่ไหน

sale_order
qv_total / cv_total
cron_compute_qv_cv
~01:00 ทุกคืน
res_partner
current_month_qv/cv, qv/cv_ytd
+
commission_qv_cv_daily
🏷️ display only
⚠️
สำคัญ: ตัวเลขบน res_partner และ commission_qv_cv_daily เป็นข้อมูลไว้แสดงผล/ตรวจคุณสมบัติเท่านั้น — เงินจริงตอนคิดโบนัสรอบเดือนจะคำนวณใหม่จากออเดอร์ต้นทางเสมอ (audit-immutability) compute อ้างอิง partner_id.member_type = ประเภทของ "ผู้ซื้อ" เสมอ (ไม่ใช่ผู้ได้เครดิต)

Diagram 4 · STAGE 4

ใครได้ 10% — การหาผู้รับ (payee walk-up)

เดินขึ้นจาก earner (ผู้ได้เครดิตที่ freeze ไว้) ตามสายงานแช่แข็ง จนเจอโหนดแรกที่ exists + active + member_type='affiliate' + earns_commission=True (source §4.2, method _front_commission_payee)

เดินสายจาก earner ขึ้นไปหา payee

ตัวอย่างประกอบ: earner ไม่ใช่ affiliate → upline ชั้นแรกถูกระงับ (ข้าม) → upline ชั้นถัดไปคือ payee

คุณสมชาย (earner)
Wellness Memberผู้ได้เครดิตของออเดอร์นี้
START
เดินขึ้น genealogy_path_snapshot
ยังไม่ผ่านเงื่อนไข → เดินต่อ
คุณวิชัย
affiliate · activeearns_commission = True
✓ PAYEE 10%
ไม่ต้องเดินต่อ (resolve แล้ว)
คุณประยุทธ (upline ถัดไป)
ไม่ถูกอ่านแล้ว
payee ที่ได้เงิน ข้าม (ไม่ผ่านเงื่อนไข) โหนดอื่นในสาย
🔩
หลักการ "สายแช่แข็ง แต่สถานะโหนดสด" (path frozen, node states live): ตัวสายงานถูกล็อกถาวรตอน confirm แต่คุณสมบัติแต่ละโหนด (active / member_type / earns_commission) ถูกอ่านสดทุกคืน — ออเดอร์เดียวกันจึงอาจ resolve payee ต่างวันกัน (วันนี้ A ถูกระงับ พรุ่งนี้เงินอาจไต่ขึ้นไปที่ upline)
🔒
กติกาเหล็ก "1 ออเดอร์ = 1 front line ตลอดกาล": app guard (search_count ก่อนสร้าง) + DB partial unique index commission_line_uniq_front บน (sale_order_id, commission_type) — เมื่อออเดอร์มี front line แล้ว จะไม่มีวันได้แถวที่สองอีก แม้ payee resolve ได้คนละคนในวันถัดไป payee คนแรกได้ไปตลอด · clawback เป็น commission_type คนละตัว ไม่ถูกบล็อกโดยกติกานี้

🧮 ตัวอย่างค่าคอม Front

ออเดอร์ cv_total = 5,000 · ผู้รับตำแหน่งพื้นฐาน daily_rate = 0.10 amount = 5,000 × 0.10 = ฿500 · state = 'pending' ส่งของวันที่ 1 → available_date = 1 + 7 = วันที่ 8 (settlement period default 7 วัน)

Interactive · Animated

การย้ายสายงาน — Node Switch (รหัสสมาชิกเดิม แต่ย้ายไปเกาะ upline ใหม่)

สิ่งที่คนสับสนบ่อยที่สุด: "ย้ายสาย" ไม่ได้แปลว่าเปลี่ยนรหัสสมาชิกหรือสร้างสมาชิกใหม่ — สิ่งที่ย้ายคือ "โหนด" ในผังสายงาน

รหัสไม่ย้าย — โหนดย้าย

S
THR0001234
คุณ S (Shopper)
res_partner · 1 แถวเสมอ
ulife_genealogy_node
partner_idสมาชิก (คงที่ต่อแถว)
sponsor_partner_idupline ของแถวนี้
stateactive / inactive — active ได้ทีละแถวเท่านั้น
UNIQUE constraint(partner_id, sponsor_partner_id) — กันแถวซ้ำคู่เดิม

1 สมาชิกมีได้หลายแถวตามประวัติการย้าย แต่ active ได้ทีละแถว

ulife_genealogy_node_event
node_idชี้กลับไป ulife_genealogy_node
event_typecreated / activated / deactivated / reactivated
actor_id, event_dateใคร ทำเมื่อไหร่
from_sponsor / to_sponsor, reasonจากใคร→ไปใคร และเหตุผล

audit log แบบ append-only ของทุกการย้าย

A → B → A — ทีละสเต็ป (ตัวอย่างจริงจาก test suite)

อ้างอิงจาก test_a_b_a_within_one_period_splits_volume_and_reactivates_node + test_shopper_switch_creates_then_reactivates — ทุกออเดอร์ CV 1,000

STEP 0 / 5
← เลื่อนดูผัง →
A — upline เดิม
node(S,A)
PGV งวดนี้: 0
B — upline ใหม่
node(S,B)
PGV งวดนี้: 0
S — คุณ S (Shopper)
THR0001234
เชื่อมกับ A
กด ▶ เพื่อเริ่มดูการย้ายสายงาน…

node record rail

ออเดอร์ (CV 1,000 ต่อออเดอร์)

audit log — ulife_genealogy_node_event

    node ที่เคยสร้างทั้งหมด1
    PGV รวม A0
    PGV รวม B0

    🔩 กฎเหล็ก 4 ข้อ

    กฎข้อ 1

    รหัสสมาชิกไม่เคยเปลี่ยน — สิ่งที่ย้ายคือ "โหนด" ในผังสายงาน (ulife_genealogy_node) ไม่ใช่ตัวสมาชิกใน res_partner

    กฎข้อ 2

    Shopper ย้ายได้เสมอ ไม่มี freeze window (ตรงมติ PO 7 ก.ค.) — กรอกรหัส sponsor ใหม่ตอน checkout แล้วกด "ยืนยันคำสั่งซื้อ" · Wellness/Affiliate = ล็อกสาย ต้องให้ Back Office ย้ายให้เท่านั้น

    กฎข้อ 3

    ออเดอร์เก่าไม่ย้ายตาม — ทุกออเดอร์ freeze สาย ณ ตอนยืนยันตลอดไป สิ่งที่เปลี่ยนคือออเดอร์อนาคตเท่านั้น

    กฎข้อ 4

    A→B→A ไม่สร้างประวัติซ้ำ — reactivate โหนดเดิม (กันด้วย SQL unique) + audit log ครบทุกการย้าย

    เกร็ดสำคัญ

    🖱️
    จุดสลับจริงคือปุ่ม "ยืนยันคำสั่งซื้อ" ไม่ใช่ตอนหยิบใส่ตะกร้า · กรอกโค้ดของ sponsor เดิม (ไม่เปลี่ยน) = ไม่เกิดอะไร ไม่มี event ถูกสร้าง
    🛠️
    Admin org-move เป็นคนละเส้นทาง — มีตัวเลือก volume_disposition: default = เหมือน shopper (ไม่แตะยอดเก่า) ส่วน move_to_new จะ restamp ได้เฉพาะ "งวดเปิดปัจจุบัน + ออเดอร์ที่ยังไม่จ่าย front" เท่านั้น — งวดที่ปิดแล้วไม่มีทางขยับ ทุกเส้นทาง
    ulife_genealogy_node ulife_genealogy_node_event append-only res_partner.sponsor_id / genealogy_path sale_order.sponsor_snapshot_id / genealogy_node_id / genealogy_path_snapshot frozen

    Diagram 5 · commission_line

    สถานะของค่าคอม (state machine)

    pending → available → paid เป็นเส้นทางหลัก มีแขนงไป held และ reversed / clawback (source §6.2, §6.3, §4.4)

    ← เลื่อนดูผัง →
    pending สร้างแล้ว ยังถอนไม่ได้ available net_amount พร้อมถอน paid MT940 ยืนยันแล้ว held พักชั่วคราว reversed action_reverse() clawback (new line) commission_type='clawback' action_set_available() _apply_wht() · DR 610101 / CR 210203 MT940 cascade DR 210203 / CR bank(net) + CR 210401/210501 พักได้ทั้งจาก pending/available ดึง gross ออกจาก YTD · ไม่คืน WHT

    Front vs Monthly — เงื่อนไข release ต่างกัน

    Front: เป็นแค่นาฬิกา settlement window — available_date ≤ today พอ ไม่ผูกกับ invoice/payment
    Monthly: ต้องรอ Period._is_closed() = True ด้วย (PERIOD_GATED_RELEASE_TYPES) — ถ้ายังไม่ปิดงวด จะค้างไว้และ self-heal หลังปิด

    งวดปิดแล้ว = ห้ามแก้ย้อนหลัง

    แถวสถานะ paid/reversed ไม่เคยถูก mutate ต้องใช้ commission.adjustment (audit immutable, Manager-gated) หรือ clawback ในงวดปัจจุบันที่ยังเปิดเท่านั้น

    Diagram 6 · STAGE 5

    รอบเดือน — 3 cron เรียงลำดับ 10 → 20 → 30

    ลำดับนี้ load-bearing — partner-tier อ่านผล Paid-As จาก title-eval; team-bonus อ่านตำแหน่งที่ resolve แล้ว รันขนานกันไม่ได้ (source §5.2)

    ← เลื่อนดู →
    PRIORITY 10

    _cron_monthly_title_eval

    • คำนวณ PGV (L0) หน่วย QV + คู่หน่วย CV โดย key ตาม affiliate ancestor ที่สดใกล้ที่สุดบนสาย frozen
    • เขียน paid_as = ตำแหน่งสูงสุดที่ pgv_l0_min ≤ pgv (ratchet up เท่านั้น)
    • Bonus-Plus: gate + จ่าย บน CV → สร้าง commission_line ชนิด bonus_plus
    PRIORITY 20

    _cron_monthly_partner_tier

    • Dual gate: T0(PTV) ≥ 100,000 และ L0(PGV) ≥ 30,000
    • เลือกตำแหน่งหมวด partner สูงสุดที่ผ่านเกณฑ์ legs/ttv → เขียน active_partner_legs, ttv, title
    • Pass 2: สร้าง commission_line ชนิด partner_t1..t6 ต่อ generation ที่มี CV
    PRIORITY 30

    _cron_monthly_team_bonus

    • team_l0 (ฐาน PGV-CV หรือ own_cv ตาม config) + team_l1/l2/l3 (ฐาน CV ต่อ generation)
    • rate: team_l1/l2/l3 = 3% / 3% / 3%
    • ตัด generation ด้วยเกณฑ์ 10K tree-cut (ดูด้านล่าง)

    การตัด generation — 10K tree-cut

    โหนดที่มียอด ≥ 10,000 (config ulife_commission.team_cut_threshold_thb) จะ "ปิด generation" ของตัวเอง (รวมยอดตัวเอง, subtree เปิด generation ถัดไป) โหนด < 10,000 ยอดไหลขึ้นและเดินลึกต่อ

    U (คุณ) ขา A — 6,000 ขา B — 12,000 ★ปิด gen ขา C — 4,000 subtree ของ B → เปิด gen ถัดไป team_l1 base = A+B+C (ทุกขาก่อนจุดตัด)
    commission_line_source invariant: Σ cv_amount (ทุกแถวย่อย) == commission_line.base_amount // ยอด CV ที่ไม่ผูกออเดอร์ (POS/ปรับยอด manual) รวมเป็นแถว residual เดียว เพื่อรักษาสมการนี้ให้ตรงเป๊ะ
    ⚖️
    อัตรามาจาก "Paid-As" ไม่ใช่ Title ที่ถือครองcommission_line.rate เป็น Float copy-by-value จาก commission.title ณ เวลารัน cron ไม่มี FK ดังนั้นแก้อัตราภายหลังไม่ย้อนหลัง มีผลเฉพาะงวดอนาคต

    Diagram 7 · STAGE 7

    ถอนเงิน (Withdrawal) → โอนผ่าน BBL

    Swimlane: ■ สมาชิก · ■ ระบบ (Odoo) · ■ ธนาคาร (BBL) · ■ บัญชี/GL (source §7.1–7.4)

    ← เลื่อนดู swimlane →
    สมาชิก
    POST /api/v1/portal/withdraw

    อ่านจาก commission_line: state='available', available_date≤today, payment_id=False, เก่าสุดก่อน

    min ฿500 · ฿50K/รายการ · ฿200K/วัน · ฿2M/เดือน · 4 รายการ/วัน · cooldown 6 ชม.
    ระบบ
    reserve lines (greedy oldest-first)

    _reserve_lines_for_amount() — แถว clawback (ติดลบ) ถูกดึงมารวมก่อนเสมอ

    ระบบ
    สร้าง account.payment (draft)

    _create_bbl_payout_payment() — outbound, amount=net, is_wht_applied=True → เขียน commission_line.payment_id ทันที (ปิดหน้าต่างจ่ายซ้ำ)

    approval tier: auto (KYC+ถอนสำเร็จ≥3 ครั้ง, TH-Individual, ≤฿10K) · senior_manual (>฿30K) · อื่นๆ เข้ารีวิว
    🏢 ประตูนิติบุคคล (juristic gate)

    is_company=True → wht_method='pnd53' → state='awaiting_documents' (post ไม่ได้) → ต้องมีเลขใบกำกับภาษี (ulife_tax_invoice_number) → Commission-Manager กด action_ulife_juristic_approve() → approved → route "odoo_internal" (ไม่ผ่าน BBL rail) · WHT = PND53 flat 3%

    ระบบ
    bulk approve → account.batch.payment

    bbl_payout_kind = affiliate_daily / member_monthly → สร้างไฟล์ HDR/DTL/TRL (res_partner_bank.bbl_proxy_type = bankac/natid/msisdn)

    ธนาคาร
    push ผ่าน ulife.bbl.sftp.push (Phase 1, live)

    log ที่ ulife.bbl.sftp.log · Phase-2 BBL Direct REST ยังปิดอยู่ (config OFF)

    ธนาคาร
    ธนาคารยืนยันกลับด้วยไฟล์ MT940

    wizard ulife.bbl.mt940.import → _mt940_set_paid_cascade()

    ระบบ
    payment.state = 'paid' → cascade

    commission_line ที่ผูกอยู่ → state='paid', เขียน paid_date

    Rejections: ACMT01/07/09 = retry ได้ (เปลี่ยนบัญชี) · AM04/BE01 = terminal (mark failed, reverse liability, แจ้ง LINE Flex)
    GL
    _book_bbl_settlement_move() — idempotent

    DR 210203 Commission Payable (gross) CR bank (net) CR 210401 WHT Payable PND1 CR 210501 WHT Payable PND53

    เก็บที่ ulife_settlement_move_id (ไม่ใช่ move_id — กัน Odoo payment-sync ทับ)
    🌏
    ผู้รับต่างประเทศ (KH/PH/LA): cron _cron_autowithdraw_foreign กวาดเข้า batch AUTOWITHDRAW บน rail ประเทศนั้นโดยอัตโนมัติ — ไม่ผ่านหน้า portal ถอนเงินแบบเดียวกับในประเทศ

    Diagram 8 · STAGE 6-7

    WHT ก้าวหน้า (PND1) — หักเฉพาะส่วนที่เพิ่มขึ้น

    หักตอน release (pending→available) ไม่ใช่ตอนถอน · ค่าลดหย่อน ฿169,000 + เพดานช่วง 0% แรก ฿150,000 = จุดเริ่มเสียภาษีที่ ฿319,000 ของ gross YTD (source §8.2, §8.4)

    ← เลื่อนดูกราฟ →
    ลดหย่อน (ภาษี=0) 0% แรก (taxable 0–150,000) 0 169,000 319,000 ← จุดเริ่มเสียภาษี YTD ก่อน 310,000 YTD หลัง 330,000 9,000 @0% 11,000 @5% = ฿550 commission ก้อนใหม่ = ฿20,000 gross
    taxable_before = 310,000 − 169,000 = 141,000 (อยู่ในช่วง 0% ทั้งหมด) → ภาษี = ฿0 taxable_after = 330,000 − 169,000 = 161,000 (เข้าช่วง 150,000–300,000 @5%) → ภาษี = (161,000−150,000)×0.05 = ฿550 incremental = 550 − 0 = wht_amount = ฿550.00 net_amount = 20,000 − 550 = ฿19,450.00 // ไม่ใช่หัก 5% ทั้งก้อน (จะได้ 1,000) — หักเฉพาะส่วนเกิน 150,000 เท่านั้น (effective rate จริง = 2.75%)
    ความเข้าใจผิดที่ว่า "พอเข้าขั้นถัดไป ทั้งก้อนโดนอัตราใหม่" — ผิด ระบบหักแบบ incremental จากบันไดภาษีจริง (marginal band) ในตาราง commission_wht_bracket
    🪪
    เงินได้สะสม (YTD) เก็บต่อ "เลขบัตรประชาชน" (NID) ไม่ใช่รหัสสมาชิก — ตาราง commission_nid_ytd (1 แถวต่อ NID+ปี) เพราะภาษีเงินได้ผูกกับตัวบุคคล นิติบุคคลที่สลับผู้ดำเนินการกลางปี → คนใหม่เริ่มจากฐาน 0 ของตัวเอง ไม่รับช่วงฐานของคนเก่า

    นิติบุคคล — PND53 flat 3%

    is_company=True → wht_method='pnd53' → wht_amount = gross × 3% เสมอ ไม่ใช้บันไดก้าวหน้า

    Clawback ไม่คืน WHT

    แถว clawback carry wht_amount=0 แต่ดึง gross เดิมออกจาก YTD ledger — WHT ที่หักไปแล้วไม่เคยถูก "คืน" เป็นบรรทัด

    Diagram 9 · ภาคผนวก ก

    ตารางฐานข้อมูลทั้งหมด — จัดกลุ่มตาม stage

    17 ตารางที่ pipeline แตะ พร้อมสถานะข้อมูล — ดู ERD ฉบับเต็ม 4 หน้าที่ footer

    frozen ล็อกถาวรตอน confirm live อ่านสดเปลี่ยนได้ append-only เพิ่มอย่างเดียว recomputed คำนวณทับใหม่ master ข้อมูลตั้งต้น control คุมสถานะงวด ledger บัญชีสะสม transactional ธุรกรรม materialized สร้างจากแหล่งอื่น immutable audit แก้ไม่ได้

    STAGE 1-2 Sale Order & QV/CV

    sale_order
    sale.order
    ต้นทางทุกอย่าง — qv_total/cv_total + field แช่แข็ง (ulife_commission_partner_id ฯลฯ)
    frozenrecomputed
    ulife_product_qvcv
    ulife.product.qvcv
    Matrix (สินค้า × ประเภทสมาชิก) → ต้นทางค่า QV/CV
    master
    res_partner
    res.partner
    สายงานสด + ยอดสรุป current_month_qv/cv, qv/cv_ytd, ตำแหน่ง
    liverecomputed
    commission_qv_cv_daily
    commission.qv.cv.daily
    Snapshot QV/CV รายวัน (audit/display เท่านั้น)
    append-only

    STAGE 4-6 หัวใจค่าคอม

    commission_line
    commission.line
    ทุกค่าคอม (front/bonus_plus/partner_t*/team_l*/clawback) — base, rate, amount, wht, state
    frozenrecomputed
    commission_line_source
    commission.line.source
    ร่องรอยที่มา monthly line (Σ cv_amount == base_amount)
    recomputed
    ulife_commission_period
    ulife.commission.period
    นิยามงวด + สถานะ open/closed คุมการจ่าย
    control
    commission_title
    commission.title
    ตำแหน่ง + อัตรา (daily_rate, tier/team rates)
    master
    commission_title_history
    commission.title.history
    ประวัติตำแหน่งต่องวด (idempotent upsert)
    append/upsert
    commission_adjustment
    commission.adjustment
    audit การปรับค่าคอม — Manager กด action_adjust
    immutable audit

    STAGE 6-7 ภาษี + เงินได้สะสม

    commission_wht_bracket
    commission.wht.bracket
    บันไดภาษีก้าวหน้า PND1 (seed จาก wht_brackets.xml)
    master (seed)
    commission_nid_ytd
    commission.nid.ytd
    เงินได้สะสมต่อ (เลขบัตร ปชช., ปี) = ฐาน WHT
    ledger

    STAGE 7 จ่ายเงิน / ธนาคาร

    account_payment
    account.payment
    payment การจ่าย: wht_amount, juristic state, settlement move
    transactional
    account_batch_payment
    account.batch.payment
    batch ส่ง BBL (bbl_payout_kind, export/status file)
    transactional
    res_partner_bank
    res.partner.bank
    บัญชีปลายทาง (bbl_proxy_type, verified name)
    master
    account_move
    account.move (core)
    GL accrual + settlement JE
    transactional
    withholding_tax_cert
    withholding.tax.cert (OCA)
    ใบ 50 ทวิ / PND1ก — cron cert-sync รายเดือน
    materialized

    ภาคผนวก ข (เลือก 10 ข้อที่เกี่ยวข้องกับธุรกิจมากที่สุด)

    เข้าใจผิด ❌ vs ที่จริง ✅

    รวมข้อควรระวังที่พบบ่อยที่สุดจากทั้งหมด 17 ข้อในเอกสารต้นฉบับ

    #1
    ยืนยันออเดอร์ (Confirm) สร้างค่าคอมทันที
    ต้อง "ส่งของ" ก่อน ค่าคอม front จึงเกิด (กฎข้อ 2) — Confirm แค่แช่แข็งว่าใครได้เครดิต
    #3
    ฐานค่าคอมคือ QV หรือราคาขายสินค้า
    ฐาน = CV เท่านั้น และ Buy-for คิดขนาดตาม "ผู้ซื้อ" แต่เงินไหลขึ้นสาย "ผู้ได้เครดิต"
    #4
    แก้อัตราบน title มีผลย้อนหลังกับค่าคอมที่จ่ายไปแล้ว
    commission_line.rate copy-by-value ไม่มี FK — มีผลเฉพาะงวดอนาคตเท่านั้น
    #5
    "รันคำนวณรอบเดือนแล้ว" = จ่ายได้เลย
    ตัวเลขยังเป็น provisional คำนวณทับทุกคืน จ่ายไม่ได้จนกว่า Manager กด action_close()
    #7
    Payee resolve เปลี่ยนวัน → ระบบสร้างแถวค่าคอมใหม่ (จ่ายซ้ำ)
    app guard + DB unique index กันไว้ — "1 ออเดอร์ = 1 front line ตลอดกาล" payee คนแรกได้ไปตลอด
    #8
    WHT ถูกหักตอนสมาชิกกดถอนเงิน
    หักตอน release (pending→available) ในสเตจก่อนหน้า — ตอนถอนแค่เลือกแถวที่เป็น net อยู่แล้ว
    #9
    พอยอดสะสมเข้าขั้นภาษีถัดไป ทั้งก้อนโดนอัตราใหม่
    หักแบบ incremental — เฉพาะภาษีส่วนที่เพิ่มขึ้นจากยอดสะสม (ดู Diagram 8)
    #10
    เงินได้สะสม (YTD) นับตามรหัสสมาชิก
    นับตามเลขบัตรประชาชน (NID) — นิติบุคคลสลับผู้ดำเนินการกลางปีมีฐานก้าวหน้าแยกอิสระต่อคน
    #12
    นิติบุคคลใช้ flow เดียวกับบุคคลธรรมดา แค่เปลี่ยนอัตราภาษี
    ต้องมีใบกำกับภาษี + Manager อนุมัติ + ไม่ผ่าน BBL rail (route odoo_internal) + WHT = PND53 flat 3%
    #17
    ตัวเลข QV/CV บน res_partner ใช้เป็นฐานจ่ายเงินได้เลย
    ใช้แสดงผล/ตรวจคุณสมบัติเท่านั้น — เงินจริงคำนวณใหม่จากออเดอร์ต้นทางเสมอ (audit-immutability)