From 02973690aa966f71d1f037be9062d96a567bcae2 Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Fri, 1 May 2026 12:45:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(employee-payroll-register):=20add=20=C6=8F?= =?UTF-8?q?H=20and=20MK=20base=20deduction=20columns?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../employee_payroll_register.py | 100 +++++++++++++++++- 1 file changed, 99 insertions(+), 1 deletion(-) diff --git a/taxes_az/taxes_az/report/employee_payroll_register/employee_payroll_register.py b/taxes_az/taxes_az/report/employee_payroll_register/employee_payroll_register.py index 00c8806..74d2abd 100644 --- a/taxes_az/taxes_az/report/employee_payroll_register/employee_payroll_register.py +++ b/taxes_az/taxes_az/report/employee_payroll_register/employee_payroll_register.py @@ -1,3 +1,5 @@ +import datetime as _dt +import math as _math from calendar import monthrange from datetime import date @@ -7,6 +9,8 @@ from frappe.utils import flt, getdate import erpnext +from hrms.payroll.doctype.salary_slip.salary_slip import _safe_eval + salary_slip = frappe.qb.DocType("Salary Slip") salary_detail = frappe.qb.DocType("Salary Detail") @@ -28,6 +32,21 @@ DEDUCTION_TYPES = [ "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 = [ ("custom_vergi_indicator", "custom_vergi_amount", "Vergi tutulmayan 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) +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): if not filters: filters = {} @@ -77,6 +136,7 @@ def execute(filters=None): 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") doj_map = get_employee_doj_map() + ded_specs = get_deduction_specs(DEDUCTION_TYPES) employee_rows = {} for ss in salary_slips: @@ -104,6 +164,21 @@ def execute(filters=None): for d in DEDUCTION_TYPES: 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: rate = flt(ss.exchange_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: row[prefix + frappe.scrub(e)] = 0.0 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: row[prefix + indicator_field] = None row[prefix + amount_field] = 0.0 @@ -287,6 +365,26 @@ def get_columns(): "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( [ {