Skip to content

[16.0][ADD] finance_kmitl: ระบุหัก ณ ที่จ่ายบนใบจ่ายเงินที่การเงินสร้างเอง - #1211

Open
coreichamp wants to merge 2 commits into
16.0from
coreichamp/16.0-add-finance_kmitl-withholding-tax
Open

[16.0][ADD] finance_kmitl: ระบุหัก ณ ที่จ่ายบนใบจ่ายเงินที่การเงินสร้างเอง#1211
coreichamp wants to merge 2 commits into
16.0from
coreichamp/16.0-add-finance_kmitl-withholding-tax

Conversation

@coreichamp

Copy link
Copy Markdown
Contributor

ที่มา

ทุกวันนี้ภาษีหัก ณ ที่จ่ายเข้าถึงใบจ่ายเงินได้ทางเดียว คือ ไหลมาจากใบตั้งหนี้ผ่านใบขอเบิก:

res.partner.type.wht_tax_id
  → disbursement.request.line.wht_tax_id
    → account.move.line.wht_tax_id (บรรทัดบิล)
      → disbursement.payment.line._payment_amount_vals()
        → write_off_line_vals ตอน account.payment.create()

แต่การเงินพิมพ์ใบจ่ายเงินขึ้นเองได้ (การเงิน ▸ Payment Out ▸ ใบสำคัญจ่าย) และใบนั้นไม่มีบิล จึงหักภาษีไม่ได้เลย มีแต่กลุ่มสรุปอ่านอย่างเดียวที่ไม่เคยโผล่

ทำอะไร

1. ระบุหัก ณ ที่จ่ายบนใบจ่ายเงิน (1 รายการต่อใบ)

ฟิลด์ใหม่บน account.payment: wht_tax_id, wht_amount_base, wht_cert_income_type

ฐานเงินได้เป็นช่องที่พิมพ์ ยอดจ่ายจริงเป็นผลลัพธ์ (ฐาน − ภาษี) ตามลำดับที่กองคลังทำงาน ช่อง amount กลายเป็น readonly ตราบที่ยังมีภาษี ถอดภาษีออก → คืนเงินให้ผู้รับเต็มยอด

บรรทัดภาษีลงสมุดรายวันผ่าน seam เดิม _prepare_move_line_default_vals พา wht_tax_id + tax_base_amount ไปครบ — สองฟิลด์นี้คือทั้งหมดที่ withholding move / หนังสือรับรอง / รายงาน ภ.ง.ด. สร้างจาก

ตัวอย่าง: ฐาน 10,000 · ภาษี 3% → เจ้าหนี้ debit 10,000 / ธนาคาร credit 9,700 / ภาษีค้างจ่าย credit 300

2. ป้องกันหักซ้ำสองชั้น — ทำไมใช้ onchange ไม่ใช่ compute

อัตราภาษีเสนอค่ามาจาก partner_id.partner_type_id.wht_tax_id ผ่าน onchange ซึ่งเป็นจุดสำคัญ ไม่ใช่รายละเอียด: ใบจ่ายเงินทุกใบที่ใบขอเบิกสร้างเกิดจาก create() ใน Python ที่ onchange ไม่ทำงาน อัตราจึงไปโผล่บนใบเหล่านั้นไม่ได้เลย — ใบพวกนั้นมีภาษีอยู่บนบรรทัด entry แล้ว ถ้ามีอัตราซ้ำที่นี่จะหักผู้รับเงินสองชั้น (compute จะเข้าถึงและทำแบบนั้นทันที)

3. การเงินออกหนังสือรับรอง 50 ทวิ ได้เองก่อนบัญชี post — ADR-0008

l10n_th_account_tax สร้างหนังสือรับรองได้เฉพาะตอน entry posted และ account.move._post() ยัง unlink() ทุกฉบับที่ห้อยกับ entry ทิ้ง — ซึ่งของ KMITL คือหลัง Hand-over ไปแล้วหลายสัปดาห์ ผู้รับเงินถือกระดาษไปนานแล้ว

ทางออก: ผูก cert กับใบจ่ายเงิน (payment_id) และปล่อย move_id ว่างmove.wht_cert_ids เดินผ่าน move_id เท่านั้น จึงมองไม่เห็น cert ของเรา รอดโดยไม่ต้องแฮ็ก unlink() ตามด้วย 3 override เล็ก ๆ: _compute_wht_cert_status (ไม่ให้ alert ชวนบัญชีออกฉบับที่สอง), create_wht_cert (กันซ้ำ), _prepare_withholding_move (พาประเภทเงินได้ที่ officer เลือกเข้ารายงาน ภ.ง.ด.)

วันที่บนหนังสือรับรองยังมาจาก withholding_tax_cert._compute_wht_cert_data เดิม คือวันที่เงินออกจริง (effective date ของไฟล์ e-Payment หรือวันที่บนเช็ค)

4. เมนูตั้งค่า — การเงิน ▸ Settings ▸ Withholding Tax

เมนู โมเดล
10 Withholding Taxes account.withholding.tax
20 Payee Types res.partner.type
30 WHT Income Codes withholding.tax.code.income
40 PIT Rate Table personal.income.tax

action ของ upstream ทั้งหมด → เป็น <menuitem> ล้วน · ไม่ต้องเพิ่ม ACL แถวเดียว (group_finance_kmitl_manager implies account.group_account_managergroup_account_usergroup_account_invoice)

ใส่ Payee Types ด้วยเพราะการผูก partner type → ภาษี ไม่ได้ถูก seed ไว้เลย ถ้าไม่มีจอนี้ กองคลังเปิดใช้ WHT ไม่ได้ตั้งแต่ต้น

เมนู upstream ใต้ บัญชี ▸ Configuration ไม่ปิด (ต่างจาก menu_bank_payment_export ที่โมดูลนี้เคยปิด) เพราะตัวภาษีถือบัญชี GL อยู่ในตัว จึงเป็นข้อมูลอ้างอิงของทั้งสองห้อง และของเราซ่อนใต้ Settings ที่เห็นเฉพาะ finance manager

ข้อจำกัดที่รู้ตัว

  • ฐานเงินได้ = ยอดทั้งก้อนที่ผู้รับได้ ใบที่พิมพ์เองยังหักบางส่วนของยอดไม่ได้ (KMITL ไม่มี VAT + ตกลง 1 รายการหักต่อใบ จึงยังไม่กระทบ)
  • l10n_th_account_tax รองรับ 1 ภาษีต่อ 1 payment (l10n_th_account_tax_multi ลงไม่ได้ — OCA account-payment ไม่อยู่ใน repos.yaml)
  • withholding.tax.code.income seed แค่ ภ.ง.ด.1 ⇒ ไฟล์ text ยื่นสรรพากรของ ภ.ง.ด.3/53 ยังทำไม่ได้ (นอกขอบเขต PR นี้)
  • wht_cert_income_type เป็นฟิลด์เดียวบนฟอร์มที่ยังแก้ได้หลังยืนยัน — เป็นข้อยกเว้นของ ADR-0002 ที่จงใจและบันทึกไว้ใน ADR-0008 (ไม่แตะบัญชี ไม่แตะธนาคาร และหนังสือรับรองออกหลังยืนยัน ถ้าปิดพร้อมฟอร์มจะแก้ไม่ได้เลย) ปิดตอนออกหนังสือรับรอง

ไม่กระทบ

disbursement_finance_kmitl ไม่ต้องแก้เลย · kmitl_preserved_write_off ไม่ถูกแตะ · _payment_amount_vals() ยังเป็นที่เดียวที่คิดเงินของฝั่ง DR · amount_before_wht ยังเป็น computed อ่านอย่างเดียวเหมือนเดิม (ไม่มี migration, ใบเก่าไม่เปลี่ยน)

เทสต์

finance_kmitl/tests/test_withholding_tax.py — 23 เคส ครอบ: การคำนวณ, 3 บรรทัดใน entry, rebuild entry แล้ว wht_tax_id/tax_base_amount ไม่หาย, การ์ดตอนยืนยัน, money side แข็งตัว, ถอดภาษี, หนังสือรับรอง (ไม่ผูก move / กันซ้ำ / รอด post), และ regression: ใบที่มาจากบิลยังอ่านภาษีจาก entry เหมือนเดิม + ใบที่สร้างด้วย create() ไม่มีอัตราติดมา

Verification ที่ควรเดินก่อน merge

  1. การเงิน ▸ Settings ▸ Withholding Tax — เปิดครบ 4 จอด้วยผู้ใช้ที่มีแค่ group_finance_kmitl_manager (ไม่ใช่ admin) แล้วลองสร้าง 1 รายการทุกจอ + ผูกอัตราบน Payee Type "บริษัท"
  2. ใบสำคัญจ่าย ▸ สร้าง → ฐาน 10,000 · ภาษี 3% → เห็น 300 / 9,700 (readonly) → เปิด Journal Entry ตรวจ 3 บรรทัด
  3. ยืนยันพร้อมส่งธนาคาร → ได้เลข PV/… → กด Issue WHT Certificate → พิมพ์ PDF 50 ทวิ
  4. ใส่ในไฟล์ e-Payment แล้วปิดไฟล์ → วันที่บนหนังสือรับรองเลื่อนเป็น effective date
  5. ให้บัญชี post → หนังสือรับรองยังอยู่ ไม่มี alert ชวนออกฉบับใหม่ และตาราง Withholding Moves มีประเภทเงินได้ถูก
  6. regression: เดินใบขอเบิกที่มี WHT ตามปกติ (audit → authorize) → ใบจ่ายเงินยังหักถูก

หมายเหตุเรื่องขนาด diff

pre-commit จัดรูปแบบเดิมที่ค้างอยู่ในไฟล์ที่แตะเข้ามาด้วย ~130 บรรทัด (po-pretty-format จัด wrap ทั้งไฟล์ finance_kmitl/i18n/th.po, ruff/prettier จัด partner_type_aginix — quote คู่, ถอด encoding pragma, view, po) ไม่ใช่การเปลี่ยนพฤติกรรม

🤖 Generated with Claude Code

PHATTARAPON.K added 2 commits August 26, 2026 17:12
…oucher

A voucher the finance office types itself had nowhere to say what it
withholds. The only path to a withholding was from a bill, through a
disbursement request: partner type -> request line -> bill line ->
write-off line on the payment. A voucher raised in the Finance app had
no bill, and so no withholding at all.

Add the rate, the base and the type of income to the voucher. The base
is what is typed and the amount paid follows from it (base less
withholding), which is the order the office works in; the amount becomes
read-only for as long as a rate applies. The withholding reaches the
entry as a write-off line carrying wht_tax_id and tax_base_amount, the
two fields the withholding moves, the certificate and the PND filing are
all made of.

The rate is proposed by an onchange rather than a compute, and that is
load-bearing: every voucher a disbursement raises is created in Python,
where onchanges do not run, so a rate can never appear on one of those.
Their withholding already sits on the entry, and a rate here as well
would withhold the payee twice.

The finance office also issues the payee's WHT certificate itself, from
the confirmation onwards. It is bound to the voucher and not to the
journal entry (ADR-0008): account.move._post unlinks every certificate
hanging off the entry it posts, and by then the payee has been holding
theirs for weeks. account.move._compute_wht_cert_status is widened so
the accounting office is not invited to issue a second one, and
_prepare_withholding_move keeps the type of income the officer chose.

Also adds a Withholding Tax group under Finance > Settings, pointing at
the l10n_th_account_tax screens and at the payee categories that hold
the default rates - nothing seeds that link, so without it the office
cannot start withholding at all. No new ACL is needed: the finance
manager already implies account.group_account_manager.

Note: pre-commit swept in pre-existing formatting on the files touched
(po re-wrapping, and quote style plus the encoding pragma in
partner_type_aginix), which accounts for part of the diff.
…ficate

KMITL does not use a bank's withholding service — it gathers the month's
withholding and files PND itself. That single fact changes what a
certificate is. It is not a courtesy copy handed to a payee; it is the
record the return is footed from. The filing wizard in
l10n_th_account_tax_report reads withholding.tax.cert.line directly,
filtered on cert_id.date and cert_id.state != 'draft', and nothing else.
So no certificate means a payee missing from the return, and a
certificate still in draft means the same.

A document with that consequence cannot be left to somebody remembering
to press a button, and the volume makes forgetting the normal case:
closing one e-payment file pays every payee it carried. So _mark_paid()
raises them — the single shared hook for "the money reached the payee",
which is also the moment the law's date is settled and therefore the
month the certificate is filed in. Twenty forms and twenty presses is
what ADR-0006 took out of this phase; it does not come back here.

They are raised as drafts, because the review that matters is monthly
rather than per voucher: the certificate list gains the filters and the
totals a filing run needs, and one press confirms the month. See
ADR-0009.

Three further changes follow from the same finding:

- The certificate is built from the entry's withholding lines rather than
  from the voucher's own rate and base, so one builder serves a voucher
  typed by hand and one billed through a disbursement request. Most of
  KMITL's withholding is the second kind, and a builder that read only
  the voucher's fields would leave most of the return unwritten.
- wht_cert_income_type becomes computed from whichever of the two knows
  the rate, overridable either way. This is what lets a voucher show how
  much was withheld *and* under which type of income before the
  accounting office posts anything — the native withholding moves cannot,
  since they only exist after posting.
- _check_wht_complete now also refuses a rate that names no type of
  income, at the confirmation, so it reaches the person who can fix it
  instead of becoming a chatter note nobody reads.

The input surface changes too: what an officer types into the amount box
is now read as the income, because the figure in their hand is the one on
the invoice and not the smaller one that will leave the bank. The box then
shows the amount paid and closes. The equality test in the onchange is
load-bearing — Odoo re-runs onchange methods until the form settles, so
without it each pass would withhold from the previous net.

Out of scope, deliberately: the PND report itself (installing
l10n_th_account_tax_report drags in partner_firstname, which turns
res.partner.name into a computed field and rewrites every contact), and a
cheque that dies after it was handed over (ADR-0007 states the domain
rule; the gap is recorded in ADR-0009).
@coreichamp

Copy link
Copy Markdown
Contributor Author

รอบที่ 2 — grill แล้วเจอข้อเท็จจริงที่เปลี่ยนดีไซน์

a6aa5b4b · ข้อเท็จจริงใหม่: KMITL ไม่ใช้บริการธนาคารออก 50 ทวิ — รวบรวมรายการแล้วนำส่งสรรพากรเอง

ผลคือ withholding.tax.cert.line ไม่ใช่แค่กระดาษให้ผู้รับเงิน มันคือรายการที่ยื่น ภ.ง.ด. — wizard นำส่งของ l10n_th_account_tax_report อ่านจากตรงนั้นตัวเดียว filter บน cert_id.date + cert_id.state != 'draft'ไม่มี cert = ยื่นขาดราย และ cert ค้างร่าง = ยื่นขาดราย

4 อย่างที่เปลี่ยน

1. ใบ 50 ทวิ ถูกยกโดยการจ่าย ไม่ใช่โดยการกดปุ่ม_mark_paid() ซึ่งเป็นจุดร่วมจุดเดียวของ "เงินถึงมือผู้รับ" (ปิดไฟล์ e-Payment / มอบเช็ค / ยืนยันเงินสด / ใบขอเบิกยืนยันทั้งใบ) และเป็นวันที่กฎหมายใช้กำหนดเดือนที่ยื่น · ปิดไฟล์ทีเดียวได้ cert ครบทุกใบ — ADR-0006 เอาการกดต่อใบออกจากเฟสนี้ไปแล้ว

2. ปั้น cert จากบรรทัดภาษีใน entry ไม่ใช่จากฟิลด์บน payment ⇒ ครอบ ใบที่มาจากใบขอเบิก ด้วย ซึ่งเป็นที่มาของภาษีส่วนใหญ่ของ KMITL

3. wht_cert_income_type เป็น computed จากอัตราภาษี ไม่ว่าอัตราจะอยู่บน payment หรือบนบรรทัด entry ⇒ ตอบโจทย์ "ใบจ่ายเงินต้องเห็นว่า WHT เท่าไร ประเภทไหน" ได้ทั้งสองเส้นทาง ก่อนบัญชีลงบัญชี (ตาราง Withholding Moves ของ upstream ทำไม่ได้ เพราะเกิดหลัง post)

4. เพิ่มหน้าจอรวบรวมนำส่งรายเดือน — cert เกิดเป็นร่าง แล้วสิ้นเดือนกดยืนยันทั้งเดือนทีเดียว (views/withholding_tax_cert_views.xml + ยอดรวมบน cert ให้ list บวกได้)

ช่องกรอกเปลี่ยน

พิมพ์ยอดใน ช่องจำนวนเงินตามปกติ → ระบบอ่านเป็น เงินได้ (เพราะเลขที่เจ้าหน้าที่ถืออยู่คือเลขบนใบแจ้งหนี้ ไม่ใช่เลขที่จะออกจากธนาคาร) → ช่องเด้งเป็นยอดจ่ายจริงแล้วล็อก · แก้ยอดผิดด้วยการเคลียร์อัตราภาษี ซึ่งคืนเงินเต็มยอดและปลดล็อกช่อง

การ์ดเช็คความเท่ากันใน onchange เป็นหัวใจ — Odoo วน onchange ซ้ำจนฟอร์มนิ่ง ถ้าไม่มีการ์ด แต่ละรอบจะหักจากยอดสุทธิรอบก่อน (10,000 → 9,700 → 9,409 → …) มีเทสต์ดักไว้

เอกสาร

ADR-0009 ใหม่ (ทำไม cert เกิดเอง / ทำไมเกิดเป็นร่าง / ทางเลือกที่ปฏิเสธ / known gap) · ADR-0008 อ้างถึง · CONTEXT.md แก้ term ฐานเงินได้ + เพิ่ม การยกหนังสือรับรอง และ การรวบรวมนำส่ง

นอกขอบเขต (ตัดสินใจไว้ชัด ๆ)

เรื่อง เหตุผล
รายงาน/ไฟล์ยื่น ภ.ง.ด. l10n_th_account_tax_report ลาก l10n_th_partnerpartner_firstname ซึ่งเปลี่ยน res.partner.name เป็นช่องคำนวณ และมี post_init_hook ตัดชื่อผู้ติดต่อทุกคนในฐานข้อมูล ⇒ ต้องมี PR + การทดสอบของตัวเอง
เช็คตายหลังมอบ กับ cert ที่ออกไปแล้ว ADR-0007 เขียนกฎโดเมนไว้แล้ว (กองคลังยื่นเพิ่มเติม) บันทึกเป็น known gap ใน ADR-0009
เปิดปุ่ม Register Payment จากใบตั้งหนี้ ปุ่มถูกซ่อนทั้งฟอร์มและ list ใน accounting_kmitl มาแต่เดิม และ kmitl_payment_type_id (required) ไม่มีใครเซ็ตบนเส้นทางนั้น ⇒ ประตูปิดอยู่แล้ว มี regression test กันไม่ให้พัง

หมายเหตุที่แก้ความเข้าใจผิดของตัวเอง: ผมเคยเขียนว่าไฟล์ TXT ของ ภ.ง.ด.3/53 ต้อง seed รหัสเงินได้ — ไม่จริง โค้ดคอมเมนต์ไว้ว่า # for pnd1 only ภ.ง.ด.3/53 ใช้ wht_cert_income_desc + wht_percent ที่ cert มีอยู่แล้ว

เทสต์

test_withholding_tax.py เป็น 34 เคส เพิ่ม: พิมพ์ในช่องจำนวนเงิน (+ พิมพ์ซ้ำไม่หักซ้อน) · เคลียร์อัตราคืนยอด · _mark_paid ยก cert เป็นร่าง · ยกให้ใบที่มาจากใบขอเบิกด้วย · ปิดไฟล์หลายใบยกครบ · ไม่ยกซ้ำ · cert สร้างไม่ได้แล้วไม่ล้ม Hand-over (savepoint + chatter) · ยืนยันทั้งเดือน · ยอดรวม

ยังไม่รันเทสต์ (ตาม CLAUDE.md) — verification ที่ควรเดินอยู่ในไฟล์แผน

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant