HR — Process flows
Architecture & lifecycle auto-generated from code (dispatch, CHECK status enums, SET status writes). Sequences & notes are authored.
Architecture
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
flowchart LR
C["Client"] --> A["Pages Fn hr<br/>bolt_session auth · company_id scope"]
A --> D["dispatch<br/>create_employee · accrue_leave · apply_leave · leave_balance · record_attendance · enroll_pf · generate_statutory_filing · run_payroll"]
D --> K["Kernel<br/>erp_document + journal<br/>atomic GL / stock"]
K --> DB[("D1 tables")]
D --> DB
D --> EV["events<br/>append audit trail"]
EV --> DB
DB -.-> T["party<br/>hr_payroll_employee<br/>hr_payroll_employment_contract<br/>hr_payroll_leave_ledger_entry<br/>hr_payroll_leave_request<br/>hr_payroll_pf_enrollment<br/>hr_payroll_run<br/>hr_payroll_payslip"]
Lifecycle
Kernel document lifecycle (erp_document.docstatus)
Every money/stock-moving document in this module posts through the shared kernel, walking a fixed docstatus:
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"state":{"useMaxWidth":false}}}%%
stateDiagram-v2
[*] --> Draft: createDocument (docstatus 0)
Draft --> Submitted: submitDocument (docstatus 1, journal posted)
Submitted --> Cancelled: cancelDocument (docstatus 2, reversed)
Submitted --> [*]
Cancelled --> [*]
hr_payroll_employee.status
States: active (initial) · on_probation · suspended · terminated
hr_payroll_employment_contract.status
States: draft (initial) · active · superseded · ended
Key flow: run_payroll → GL posting
run_payroll is the only HR flow that posts double-entry. It is idempotent at the period grain, so a retry after a transient finalize error never double-pays.
%%{init: {"theme":"base","themeVariables":{"darkMode":true,"background":"#1e1e1e","primaryColor":"#2d2d30","primaryBorderColor":"#569cd6","primaryTextColor":"#d4d4d4","lineColor":"#6796c6","secondaryColor":"#3a3d41","secondaryBorderColor":"#dcdcaa","secondaryTextColor":"#d4d4d4","tertiaryColor":"#252526","tertiaryBorderColor":"#569cd6","noteBkgColor":"#3a3d41","noteTextColor":"#dcdcaa","clusterBkg":"#252526","clusterBorder":"#569cd6","titleColor":"#569cd6","actorBkg":"#2d2d30","actorBorder":"#569cd6","actorTextColor":"#d4d4d4","signalColor":"#9cdcfe","signalTextColor":"#d4d4d4","noteBkgColor":"#3a3d41","labelBoxBkgColor":"#3a3d41","labelBoxBorderColor":"#dcdcaa"},"flowchart":{"useMaxWidth":false},"sequence":{"useMaxWidth":false},"state":{"useMaxWidth":false},"er":{"useMaxWidth":false}}}%%
sequenceDiagram
autonumber
participant C as Client
participant HR as Pages Fn (hr-payroll)
participant D1 as D1 (HR tables)
participant K as Kernel / GL
C->>HR: POST run_payroll {period_start, period_end, run_type}
HR->>D1: SELECT run WHERE period+run_type AND (state=posted OR doc_id set)
alt already posted
D1-->>HR: existing run
HR->>D1: recomputeEmployeeYtd (self-heal)
HR-->>C: {row, payslips, idempotent:true}
else new period
HR->>D1: INSERT run state=calculating
loop each selected employee
HR->>D1: compileWorkEntry (attendance + unpaid leave)
HR->>HR: computeSso / annualized PIT / providentFund
HR->>D1: INSERT payslip + payslip_line (clipped gross)
end
HR->>K: resolveAccount per line + createDocument HRPAYROLL
HR->>K: submitDocument (journal PAY, period idempotency_key)
K->>D1: post balanced GL dr expense / cr net+sso+pit+pf (atomic)
K-->>HR: doc_no
HR->>D1: UPDATE run state=posted, doc_id, totals
HR->>D1: recomputeEmployeeYtd (derived from posted payslips)
HR-->>C: {row, payslips, submitted}
end
GL journal lines built by buildPayrollJournalIntent (journal PAY, doctype HRPAYROLL):
| Side | transaction_kind | Amount |
|---|---|---|
| dr | payroll_salary_expense |
salaryExpense + SSO employer + PF employer |
| dr | payroll_overtime_expense |
overtime expense |
| cr | payroll_net_payable |
net pay |
| cr | payroll_sso_payable |
SSO employee + employer |
| cr | payroll_pit_payable |
PIT withheld (only if > 0) |
| cr | payroll_pf_payable |
PF employee + employer (only if > 0) |
Notes
- จุดประสงค์: จ่ายเงินเดือนไทยครบวงจร — attendance/leave feed เข้า payslip, คำนวณ SSO + PIT (annualized ภ.ง.ด.1) + Provident Fund, แล้ว post เป็น double-entry GL ผ่าน kernel เดียว.
generate_statutory_filingสรุป posted payslips เป็น pnd1 / pnd1_kor / sso_1_10 / pf_remittance. - Idempotency = period-scoped: guard match run ที่
state=postedหรือdoc_id IS NOT NULLก่อนเริ่ม; journalidempotency_key = hr-payroll:{company}:{start}:{end}:{run_type}:journalทำให้ retry (ถึงจะ mint run ใหม่) ให้ kernel replay journal เดิม ไม่ post ซ้ำ = ไม่จ่ายซ้ำ. - YTD เป็น projection ไม่ใช่ increment:
recomputeEmployeeYtdre-derive YTD = SUM ของ posted payslips ทั้งปี ทุกครั้ง → self-healing, drift-proof, และรันเป็น best-effort นอก catch (GL commit แล้วห้าม fail เพราะ YTD สะดุด). - Money-safety ใน payslip: deduction ถูก cap ที่ earned pay (unpaid leave แล้วค่อย other) และ expense debit สะสมจาก clipped gross ไม่ใช่ raw components — กันกรณีหักเกินจ่ายจนทำให้ Σdr ≠ Σcr แล้ว journal strand.
- Fail handling รอบ submit: pre-post error (เช่น resolveAccount หา posting account ไม่เจอ) mark run
failed; แต่หลังsubmitDocumentcommit GL แล้ว การ finalize/YTD ที่พลาดจะ surface เป็น error แต่ ไม่ mark run กลับเป็น failed (กันคนกด retry แล้วจ่ายซ้ำ). Run ที่ค้างcalculatingแต่doc_idมีค่า = healed เป็นpostedตอน retry. - Gotchas: attendance ใช้
INSERT OR IGNOREกับ(source, source_event_id)เป็น idempotency key;apply_leaveแบบ paid เช็ค balance (SUM ledger ≤ asof) ก่อน แล้วเขียนทั้ง leave_request (approved) + consumption ledger entry ติดลบ;create_employeeสร้างerp_party(kind person) +erp_party_roleemployee ก่อน insert employee row;ensureCompanyPayrollSeedsรันทุก POST เพื่อ seed tax/sso/scheme ต่อ tenant.