[16.0][ADD] finance_kmitl: ระบุหัก ณ ที่จ่ายบนใบจ่ายเงินที่การเงินสร้างเอง - #1211
[16.0][ADD] finance_kmitl: ระบุหัก ณ ที่จ่ายบนใบจ่ายเงินที่การเงินสร้างเอง#1211coreichamp wants to merge 2 commits into
Conversation
…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).
รอบที่ 2 — grill แล้วเจอข้อเท็จจริงที่เปลี่ยนดีไซน์
ผลคือ 4 อย่างที่เปลี่ยน1. ใบ 50 ทวิ ถูกยกโดยการจ่าย ไม่ใช่โดยการกดปุ่ม — 2. ปั้น cert จากบรรทัดภาษีใน entry ไม่ใช่จากฟิลด์บน payment ⇒ ครอบ ใบที่มาจากใบขอเบิก ด้วย ซึ่งเป็นที่มาของภาษีส่วนใหญ่ของ KMITL 3. 4. เพิ่มหน้าจอรวบรวมนำส่งรายเดือน — cert เกิดเป็นร่าง แล้วสิ้นเดือนกดยืนยันทั้งเดือนทีเดียว ( ช่องกรอกเปลี่ยนพิมพ์ยอดใน ช่องจำนวนเงินตามปกติ → ระบบอ่านเป็น เงินได้ (เพราะเลขที่เจ้าหน้าที่ถืออยู่คือเลขบนใบแจ้งหนี้ ไม่ใช่เลขที่จะออกจากธนาคาร) → ช่องเด้งเป็นยอดจ่ายจริงแล้วล็อก · แก้ยอดผิดด้วยการเคลียร์อัตราภาษี ซึ่งคืนเงินเต็มยอดและปลดล็อกช่อง การ์ดเช็คความเท่ากันใน onchange เป็นหัวใจ — Odoo วน onchange ซ้ำจนฟอร์มนิ่ง ถ้าไม่มีการ์ด แต่ละรอบจะหักจากยอดสุทธิรอบก่อน (10,000 → 9,700 → 9,409 → …) มีเทสต์ดักไว้ เอกสารADR-0009 ใหม่ (ทำไม cert เกิดเอง / ทำไมเกิดเป็นร่าง / ทางเลือกที่ปฏิเสธ / known gap) · ADR-0008 อ้างถึง · นอกขอบเขต (ตัดสินใจไว้ชัด ๆ)
หมายเหตุที่แก้ความเข้าใจผิดของตัวเอง: ผมเคยเขียนว่าไฟล์ TXT ของ ภ.ง.ด.3/53 ต้อง seed รหัสเงินได้ — ไม่จริง โค้ดคอมเมนต์ไว้ว่า เทสต์
ยังไม่รันเทสต์ (ตาม CLAUDE.md) — verification ที่ควรเดินอยู่ในไฟล์แผน 🤖 Generated with Claude Code |
ที่มา
ทุกวันนี้ภาษีหัก ณ ที่จ่ายเข้าถึงใบจ่ายเงินได้ทางเดียว คือ ไหลมาจากใบตั้งหนี้ผ่านใบขอเบิก:
แต่การเงินพิมพ์ใบจ่ายเงินขึ้นเองได้ (การเงิน ▸ 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
account.withholding.taxres.partner.typewithholding.tax.code.incomepersonal.income.taxaction ของ upstream ทั้งหมด → เป็น
<menuitem>ล้วน · ไม่ต้องเพิ่ม ACL แถวเดียว (group_finance_kmitl_managerimpliesaccount.group_account_manager→group_account_user→group_account_invoice)ใส่ Payee Types ด้วยเพราะการผูก partner type → ภาษี ไม่ได้ถูก seed ไว้เลย ถ้าไม่มีจอนี้ กองคลังเปิดใช้ WHT ไม่ได้ตั้งแต่ต้น
เมนู upstream ใต้ บัญชี ▸ Configuration ไม่ปิด (ต่างจาก
menu_bank_payment_exportที่โมดูลนี้เคยปิด) เพราะตัวภาษีถือบัญชี GL อยู่ในตัว จึงเป็นข้อมูลอ้างอิงของทั้งสองห้อง และของเราซ่อนใต้ Settings ที่เห็นเฉพาะ finance managerข้อจำกัดที่รู้ตัว
l10n_th_account_taxรองรับ 1 ภาษีต่อ 1 payment (l10n_th_account_tax_multiลงไม่ได้ — OCAaccount-paymentไม่อยู่ในrepos.yaml)withholding.tax.code.incomeseed แค่ ภ.ง.ด.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
group_finance_kmitl_manager(ไม่ใช่ admin) แล้วลองสร้าง 1 รายการทุกจอ + ผูกอัตราบน Payee Type "บริษัท"PV/…→ กด Issue WHT Certificate → พิมพ์ PDF 50 ทวิหมายเหตุเรื่องขนาด 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