feat(employee-payroll-register): add ƏH and MK base deduction columns

Duplicates the 7 deduction columns twice per month: once with the formula
evaluated at base=ƏH amount (LE=0), and once at base=0/LE=MK amount with
the formula(0,0) baseline subtracted. The sum of (ƏH-part + MK-part) for
each row equals the slip's actual deduction — serves as a built-in sanity
check. Baseline subtraction keeps Gəlir vergisi correct across the
threshold (200 AZN) without special-casing it.

Formulas are read from Salary Component at runtime via _safe_eval — no
hardcoded rates, so the report stays correct when tax law changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-05-01 12:45:03 +00:00
parent 24c8205193
commit 02973690aa
1 changed files with 99 additions and 1 deletions

View File

@ -1,3 +1,5 @@
import datetime as _dt
import math as _math
from calendar import monthrange from calendar import monthrange
from datetime import date from datetime import date
@ -7,6 +9,8 @@ from frappe.utils import flt, getdate
import erpnext import erpnext
from hrms.payroll.doctype.salary_slip.salary_slip import _safe_eval
salary_slip = frappe.qb.DocType("Salary Slip") salary_slip = frappe.qb.DocType("Salary Slip")
salary_detail = frappe.qb.DocType("Salary Detail") salary_detail = frappe.qb.DocType("Salary Detail")
@ -28,6 +32,21 @@ DEDUCTION_TYPES = [
"Gəlir vergisi", "Gəlir vergisi",
] ]
EH_COMPONENT = "Əsas əmək haqqı"
MK_COMPONENT = "Məzuniyyət Kompensasiyası"
# Контекст для безопасного eval формул Salary Component. Зеркалит whitelisted_globals из HRMS.
FORMULA_GLOBALS = {
"int": int,
"float": float,
"long": int,
"round": round,
"date": _dt.date,
"getdate": getdate,
"ceil": _math.ceil,
"floor": _math.floor,
}
TAX_FREE_FIELDS = [ TAX_FREE_FIELDS = [
("custom_vergi_indicator", "custom_vergi_amount", "Vergi tutulmayan gəlir"), ("custom_vergi_indicator", "custom_vergi_amount", "Vergi tutulmayan gəlir"),
("custom_mdss_indicator", "custom_mdss_amount", "MDSS cəlb olunmayan gəlir"), ("custom_mdss_indicator", "custom_mdss_amount", "MDSS cəlb olunmayan gəlir"),
@ -46,6 +65,46 @@ TAX_FREE_FIELDS = [
MONTHS = (1, 2, 3) MONTHS = (1, 2, 3)
def get_deduction_specs(names):
specs = {}
for name in names:
specs[name] = frappe.db.get_value(
"Salary Component",
name,
["formula", "condition", "amount_based_on_formula", "disabled"],
as_dict=True,
)
return specs
def eval_deduction(spec, base_val, le_val, ss):
"""Запускает формулу Salary Component с заданными base и LE.
Источник истины текущая формула в Salary Component, читается на лету.
Возвращает 0 если: компонент отсутствует/disabled/без формулы/condition не сработал/eval упал.
"""
if not spec or spec.get("disabled") or not spec.get("amount_based_on_formula") or not spec.get("formula"):
return 0.0
context = {
"base": flt(base_val),
"LE": flt(le_val),
"start_date": ss.start_date,
"end_date": ss.end_date,
}
try:
condition = spec.get("condition")
if condition and condition.strip():
if not _safe_eval(condition, FORMULA_GLOBALS, context):
return 0.0
return flt(_safe_eval(spec.formula, FORMULA_GLOBALS, context))
except Exception:
frappe.log_error(
frappe.get_traceback(),
"Employee Payroll Register: deduction formula eval failed",
)
return 0.0
def execute(filters=None): def execute(filters=None):
if not filters: if not filters:
filters = {} filters = {}
@ -77,6 +136,7 @@ def execute(filters=None):
ss_earning_map = get_salary_slip_details(salary_slips, currency, company_currency, "earnings") ss_earning_map = get_salary_slip_details(salary_slips, currency, company_currency, "earnings")
ss_ded_map = get_salary_slip_details(salary_slips, currency, company_currency, "deductions") ss_ded_map = get_salary_slip_details(salary_slips, currency, company_currency, "deductions")
doj_map = get_employee_doj_map() doj_map = get_employee_doj_map()
ded_specs = get_deduction_specs(DEDUCTION_TYPES)
employee_rows = {} employee_rows = {}
for ss in salary_slips: for ss in salary_slips:
@ -104,6 +164,21 @@ def execute(filters=None):
for d in DEDUCTION_TYPES: for d in DEDUCTION_TYPES:
row[prefix + frappe.scrub(d)] += flt(ss_ded_map.get(ss.name, {}).get(d, 0)) row[prefix + frappe.scrub(d)] += flt(ss_ded_map.get(ss.name, {}).get(d, 0))
# Удержания, рассчитанные отдельно от ƏH и от MK (LE).
# Формула удержания в проде = f(base) + g(LE). Чтобы корректно разделить
# вклад каждой части — вычитаем baseline = formula(0, 0). Для линейных
# формул baseline = 0 (no-op), для Gəlir vergisi baseline = -6 из-за
# порога 200 AZN в base-части. Без вычитания MK-колонка для GV давала
# бы -3 вместо 3 (см. формулу `(base - 200) * 0.03 + LE * 0.03`).
eh_amount = flt(ss_earning_map.get(ss.name, {}).get(EH_COMPONENT, 0))
mk_amount = flt(ss_earning_map.get(ss.name, {}).get(MK_COMPONENT, 0))
for d in DEDUCTION_TYPES:
slug = frappe.scrub(d)
spec = ded_specs.get(d)
baseline = eval_deduction(spec, 0, 0, ss)
row[prefix + "ded_eh_" + slug] += eval_deduction(spec, eh_amount, 0, ss)
row[prefix + "ded_mk_" + slug] += eval_deduction(spec, 0, mk_amount, ss) - baseline
if currency == company_currency: if currency == company_currency:
rate = flt(ss.exchange_rate) rate = flt(ss.exchange_rate)
row[prefix + "gross_pay"] += flt(ss.gross_pay) * rate row[prefix + "gross_pay"] += flt(ss.gross_pay) * rate
@ -149,7 +224,10 @@ def build_empty_row(ss, doj_map, currency, company_currency):
for e in EARNING_TYPES: for e in EARNING_TYPES:
row[prefix + frappe.scrub(e)] = 0.0 row[prefix + frappe.scrub(e)] = 0.0
for d in DEDUCTION_TYPES: for d in DEDUCTION_TYPES:
row[prefix + frappe.scrub(d)] = 0.0 slug = frappe.scrub(d)
row[prefix + slug] = 0.0
row[prefix + "ded_eh_" + slug] = 0.0
row[prefix + "ded_mk_" + slug] = 0.0
for indicator_field, amount_field, _lbl in TAX_FREE_FIELDS: for indicator_field, amount_field, _lbl in TAX_FREE_FIELDS:
row[prefix + indicator_field] = None row[prefix + indicator_field] = None
row[prefix + amount_field] = 0.0 row[prefix + amount_field] = 0.0
@ -287,6 +365,26 @@ def get_columns():
"width": 140, "width": 140,
} }
) )
for deduction in DEDUCTION_TYPES:
columns.append(
{
"label": deduction + " (ƏH)" + suffix,
"fieldname": prefix + "ded_eh_" + frappe.scrub(deduction),
"fieldtype": "Currency",
"options": "currency",
"width": 160,
}
)
for deduction in DEDUCTION_TYPES:
columns.append(
{
"label": deduction + " (MK)" + suffix,
"fieldname": prefix + "ded_mk_" + frappe.scrub(deduction),
"fieldtype": "Currency",
"options": "currency",
"width": 160,
}
)
columns.extend( columns.extend(
[ [
{ {