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:
parent
24c8205193
commit
02973690aa
|
|
@ -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(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue