diff --git a/av_tools/av_tools/page/salary_calculator/__init__.py b/av_tools/av_tools/page/salary_calculator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.css b/av_tools/av_tools/page/salary_calculator/salary_calculator.css new file mode 100644 index 0000000..23e2b81 --- /dev/null +++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.css @@ -0,0 +1,126 @@ +/* ── Page Layout ───────────────────────────────────────────────────── */ + +.salary-calculator-page { padding: 0 !important; background: var(--bg-color); } + +.sc-container { + display: grid; + grid-template-columns: 440px 1fr; + gap: 20px; + padding: 20px; + max-width: 1400px; + margin: 0 auto; + min-height: calc(100vh - 120px); +} +@media (max-width: 1024px) { .sc-container { grid-template-columns: 1fr; } } + +/* ── Cards ─────────────────────────────────────────────────────────── */ + +.sc-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + margin-bottom: 16px; + overflow: hidden; +} +.sc-card-header { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border-color); + background: var(--subtle-fg); +} +.sc-card-title { font-size: var(--text-base); font-weight: 600; margin: 0; color: var(--heading-color); } +.sc-card-body { padding: 18px; } +.sc-preview-card { position: sticky; top: 80px; } + +/* ── Fields ────────────────────────────────────────────────────────── */ + +.sc-field-wrap { margin-bottom: 12px; } +.sc-field-wrap:last-child { margin-bottom: 0; } +.sc-field-wrap .frappe-control { margin-bottom: 0; } +.sc-amount-row { display: flex; gap: 12px; } +.sc-field-half { flex: 1; } +.sc-field-disabled .control-input-wrapper input { + background: var(--subtle-fg) !important; color: var(--text-muted) !important; cursor: not-allowed; +} + +/* ── Component List ────────────────────────────────────────────────── */ + +.sc-header-right { display: flex; align-items: center; gap: 8px; } +.sc-badge { + display: inline-flex; align-items: center; padding: 2px 10px; + border-radius: 20px; font-size: var(--text-xs); font-weight: 500; + background: var(--bg-blue); color: var(--text-color); +} +.sc-section { margin-bottom: 14px; } +.sc-section:last-child { margin-bottom: 0; } +.sc-section-label { + display: flex; align-items: center; gap: 6px; + font-size: var(--text-xs); font-weight: 600; color: var(--text-muted); + text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; +} +.sc-dot { width: 8px; height: 8px; border-radius: 50%; } +.sc-dot-green { background: var(--green-500, #22c55e); } +.sc-dot-red { background: var(--red-500, #ef4444); } + +.sc-list { display: flex; flex-direction: column; gap: 4px; } + +.sc-comp-row { + display: flex; align-items: center; justify-content: space-between; + padding: 7px 10px; border: 1px solid var(--border-color); + border-radius: var(--border-radius-md); background: var(--card-bg); + transition: background .12s; gap: 8px; +} +.sc-comp-row:hover { background: var(--subtle-fg); } +.sc-comp-info { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; } +.sc-comp-name { font-size: var(--text-sm); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sc-comp-hint { font-size: var(--text-xs); color: var(--text-light); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px; } +.sc-tag { + font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; + background: var(--bg-purple); color: var(--purple-600, #9333ea); + text-transform: uppercase; letter-spacing: .3px; flex-shrink: 0; +} +.sc-remove-btn { + flex-shrink: 0; width: 24px; height: 24px; padding: 0; line-height: 24px; text-align: center; + border-radius: var(--border-radius-sm); color: var(--text-muted); + border: none; background: transparent; font-size: 16px; transition: all .12s; +} +.sc-remove-btn:hover { background: var(--bg-red); color: var(--red-600, #dc2626); } +.sc-empty-msg { font-size: var(--text-xs); padding: 6px 10px; font-style: italic; } + +/* ── Editable Earning Amount ───────────────────────────────────────── */ + +.sc-comp-amount { + flex-shrink: 0; width: 110px; +} +.sc-comp-amount .frappe-control { margin-bottom: 0; } +.sc-comp-amount .control-input-wrapper input { + font-size: var(--text-xs); height: 28px; padding: 2px 8px; text-align: right; +} + +/* ── Preview ───────────────────────────────────────────────────────── */ + +.sc-placeholder { display: flex; flex-direction: column; align-items: center; padding: 60px 20px; text-align: center; } +.sc-placeholder p { color: var(--text-muted); font-size: var(--text-sm); max-width: 220px; margin-top: 12px; line-height: 1.5; } + +/* Salary Slip Preview */ +.salary-slip-preview-content { font-size: var(--text-sm); } +.slip-header { margin-bottom: 18px; padding-bottom: 14px; border-bottom: 2px solid var(--border-color); } +.slip-title { font-size: var(--text-lg); font-weight: 700; margin-bottom: 10px; } +.slip-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 4px 16px; } +.meta-row { display: flex; gap: 6px; } +.meta-label { font-weight: 600; color: var(--text-muted); white-space: nowrap; } +.slip-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; margin-bottom: 18px; } +@media (max-width: 768px) { .slip-columns { grid-template-columns: 1fr; } } +.column-title { font-size: var(--text-xs); font-weight: 600; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; padding-bottom: 4px; border-bottom: 2px solid var(--border-color); } +.earnings-title { color: var(--green-600, #16a34a); border-color: var(--green-200, #bbf7d0); } +.deductions-title { color: var(--red-600, #dc2626); border-color: var(--red-200, #fecaca); } +.slip-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } +.slip-table th { font-size: var(--text-xs); font-weight: 600; color: var(--text-muted); padding: 5px 0; border-bottom: 1px solid var(--border-color); } +.slip-table td { padding: 5px 0; border-bottom: 1px solid var(--subtle-fg); } +.slip-table .total-row td { border-top: 2px solid var(--border-color); border-bottom: none; padding-top: 6px; } +.slip-footer { background: var(--subtle-fg); border-radius: var(--border-radius-md); padding: 14px; } +.net-pay-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; } +.net-pay-label { font-size: var(--text-lg); font-weight: 700; } +.net-pay-value { font-size: var(--text-xl); font-weight: 700; color: var(--blue-600, #2563eb); } +.gross-pay-row { font-size: var(--text-xs); color: var(--text-muted); display: flex; justify-content: center; gap: 8px; } +.gross-pay-row .separator { color: var(--gray-400); } diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.js b/av_tools/av_tools/page/salary_calculator/salary_calculator.js new file mode 100644 index 0000000..2054787 --- /dev/null +++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.js @@ -0,0 +1,490 @@ +frappe.pages["salary-calculator"].on_page_load = function (wrapper) { + const page = frappe.ui.make_app_page({ + parent: wrapper, + title: "Salary Calculator", + single_column: true, + }); + frappe.breadcrumbs.add("HR"); + new SalaryCalculatorPage(page); +}; + +class SalaryCalculatorPage { + constructor(page) { + this.page = page; + this.state = { + salary_structure: "", + employee: "", + calculate_based_on: "Net Pay", + gross_pay: 0, + net_pay: 0, + }; + this.structure_data = null; + this.active_keys = new Set(); + this.earning_overrides = {}; + this.result = null; + this.currency = "TZS"; + this._calc_timer = null; + + this.render_layout(); + this.create_fields(); + } + + // ── Layout ────────────────────────────────────────────────────────── + + render_layout() { + this.page.main.addClass("salary-calculator-page").html(` +
+
+
+
+

Calculator Inputs

+
+
+
+ +
+
+
+
+

Salary Slip Preview

+
+
+
+
+ + + + + +

Select a salary structure and enter an amount to begin

+
+ +
+
+
+
+ `); + } + + // ── Fields ─────────────────────────────────────────────────────────── + + create_fields() { + const $fields = this.page.main.find(".sc-fields"); + this.fields = {}; + + const defs = [ + { + fieldtype: "Link", fieldname: "salary_structure", label: "Salary Structure", + options: "Salary Structure", reqd: 1, + get_query: () => ({ filters: { is_active: "Yes", docstatus: 1 } }), + change: () => this.on_structure_change(), + }, + { + fieldtype: "Link", fieldname: "employee", label: "Employee", + options: "Employee", + get_query: () => ({ filters: { status: "Active" } }), + change: () => { this.state.employee = this.val("employee"); this.schedule_calc(); }, + }, + { + fieldtype: "Select", fieldname: "calculate_based_on", label: "Calculate Based On", + options: ["Net Pay", "Gross Pay"], default: "Net Pay", reqd: 1, + change: () => { this.state.calculate_based_on = this.val("calculate_based_on"); this.apply_field_states(); this.schedule_calc(); }, + }, + ]; + + for (const df of defs) { + const $w = $('
').appendTo($fields); + this.fields[df.fieldname] = frappe.ui.form.make_control({ df, parent: $w, render_input: true }); + } + this.fields.calculate_based_on.set_value("Net Pay"); + + const $row = $('
').appendTo($fields); + for (const name of ["gross_pay", "net_pay"]) { + const $w = $('
').appendTo($row); + this.fields[name] = frappe.ui.form.make_control({ + df: { + fieldtype: "Currency", fieldname: name, precision: 0, + label: name === "gross_pay" ? "Gross Pay" : "Net Pay", + change: () => { + this.state.gross_pay = flt(this.val("gross_pay")); + this.state.net_pay = flt(this.val("net_pay")); + this.schedule_calc(); + }, + }, + parent: $w, render_input: true, + }); + } + + this.apply_field_states(); + } + + val(name) { return this.fields[name]?.get_value(); } + + apply_field_states() { + const is_net = this.state.calculate_based_on === "Net Pay"; + for (const [name, disabled] of [["gross_pay", is_net], ["net_pay", !is_net]]) { + const f = this.fields[name]; + if (f?.$input) { + f.$input.prop("disabled", disabled); + f.$wrapper.toggleClass("sc-field-disabled", disabled); + } + } + } + + // ── Structure Change ──────────────────────────────────────────────── + + on_structure_change() { + const value = this.val("salary_structure"); + this.state.salary_structure = value; + + if (!value) { + this.structure_data = null; + this.active_keys = new Set(); + this.earning_overrides = {}; + this.result = null; + this.$(".sc-components-card").hide(); + this.show_placeholder(); + return; + } + + frappe.xcall( + "av_tools.av_tools.page.salary_calculator.salary_calculator.get_salary_structure_components", + { salary_structure: value }, + ).then((data) => { + this.structure_data = data; + this.currency = data.currency || "TZS"; + this.earning_overrides = {}; + + this.active_keys = new Set(); + for (const comp of [...data.earnings, ...data.deductions]) { + if (!comp.statistical_component && !comp.do_not_include_in_total) { + this.active_keys.add(comp.key); + } + } + + this.render_components(); + this.$(".sc-components-card").show(); + this.schedule_calc(); + }); + } + + // ── Components UI ─────────────────────────────────────────────────── + + render_components() { + if (!this.structure_data) return; + this.render_component_list(this.$(".sc-earnings-list"), this.structure_data.earnings, true); + this.render_component_list(this.$(".sc-deductions-list"), this.structure_data.deductions, false); + this.update_counts(); + } + + render_component_list($list, components, is_earning) { + $list.empty(); + let has_rows = false; + const seen = new Set(); + + for (const comp of components) { + if (comp.statistical_component || comp.do_not_include_in_total) continue; + if (!this.active_keys.has(comp.key)) continue; + + // Deduplicate deductions by salary_component name (e.g. 5 PAYE tiers → 1 row) + if (!is_earning) { + if (seen.has(comp.salary_component)) continue; + seen.add(comp.salary_component); + } + + has_rows = true; + $list.append(this.build_component_row(comp, is_earning)); + } + + if (!has_rows) { + $list.append('
None
'); + } + } + + build_component_row(comp, is_earning) { + const is_base = this.is_base(comp); + const hint = comp.formula + ? `Formula: ${comp.formula}` + : comp.amount ? `Amount: ${comp.amount}` : ""; + const esc = frappe.utils.escape_html; + + const $row = $(` +
+
+ ${esc(comp.salary_component)} + ${is_base ? 'Base' : ""} + ${hint ? `${esc(hint)}` : ""} +
+ ${is_earning && !is_base ? '
' : ""} + ${!is_base ? '' : ""} +
+ `); + + // Editable amount input for non-base earnings + if (is_earning && !is_base) { + const $wrap = $row.find(".sc-comp-amount"); + const comp_name = comp.salary_component; + const ctrl = frappe.ui.form.make_control({ + df: { + fieldtype: "Currency", fieldname: `ovr_${comp.key}`, + placeholder: "Amount", precision: 0, + change: () => { + const v = flt(ctrl.get_value()); + if (v > 0) { + this.earning_overrides[comp_name] = v; + } else { + delete this.earning_overrides[comp_name]; + } + this.schedule_calc(); + }, + }, + parent: $wrap, render_input: true, + }); + const existing = this.earning_overrides[comp_name]; + if (existing !== undefined) ctrl.set_value(existing); + } + + // Remove button + if (!is_base) { + $row.find(".sc-remove-btn").on("click", () => { + if (is_earning) { + this.active_keys.delete(comp.key); + delete this.earning_overrides[comp.salary_component]; + } else { + // Remove ALL keys for this salary_component (handles dupes like PAYE) + for (const c of this.structure_data.deductions) { + if (c.salary_component === comp.salary_component) { + this.active_keys.delete(c.key); + } + } + } + this.render_components(); + this.schedule_calc(); + }); + } + + return $row; + } + + is_base(comp) { + const f = (comp.formula || "").replace(/\s/g, "").toLowerCase(); + return comp.abbr === "B" || (comp.salary_component || "").toLowerCase() === "basic" || f === "base"; + } + + get_inactive_components() { + if (!this.structure_data) return []; + const seen = new Set(); + return [...this.structure_data.earnings, ...this.structure_data.deductions].filter((c) => { + if (c.statistical_component || c.do_not_include_in_total || this.is_base(c)) return false; + if (this.active_keys.has(c.key)) return false; + if (seen.has(c.salary_component)) return false; + seen.add(c.salary_component); + return true; + }); + } + + update_counts() { + this.$(".sc-count-badge").text(`${this.active_keys.size} active`); + const inactive = this.get_inactive_components(); + const $btn = this.$(".sc-add-btn"); + if (inactive.length) { + $btn.show().off("click").on("click", () => this.show_add_dialog()); + } else { + $btn.hide(); + } + } + + show_add_dialog() { + const inactive = this.get_inactive_components(); + if (!inactive.length) return; + + const d = new frappe.ui.Dialog({ + title: __("Add Salary Component"), + fields: [{ + fieldtype: "Select", fieldname: "key", label: "Component", reqd: 1, + options: inactive.map((c) => ({ label: c.salary_component, value: c.key })), + }], + primary_action_label: __("Add"), + primary_action: ({ key }) => { + d.hide(); + const selected = inactive.find((c) => c.key === key); + if (selected) { + // Re-activate ALL rows with same salary_component (handles deduction dupes) + for (const c of [...this.structure_data.earnings, ...this.structure_data.deductions]) { + if (c.salary_component === selected.salary_component) { + this.active_keys.add(c.key); + } + } + } + this.render_components(); + this.schedule_calc(); + }, + }); + d.show(); + } + + // ── Calculation ───────────────────────────────────────────────────── + + get_selected_names() { + if (!this.structure_data) return []; + const names = []; + for (const comp of [...this.structure_data.earnings, ...this.structure_data.deductions]) { + if (this.active_keys.has(comp.key) && !comp.statistical_component && !comp.do_not_include_in_total) { + names.push(comp.salary_component); + } + } + return names; + } + + schedule_calc() { + if (this._applying_result) return; + clearTimeout(this._calc_timer); + this._calc_timer = setTimeout(() => this.run_calculation(), 300); + } + + run_calculation() { + const ss = this.state.salary_structure; + const mode = this.state.calculate_based_on; + if (!ss || !mode) return; + + const target = mode === "Net Pay" ? flt(this.val("net_pay")) : flt(this.val("gross_pay")); + if (!target) { + this.result = null; + this.show_placeholder(); + return; + } + + frappe.xcall( + "av_tools.av_tools.page.salary_calculator.salary_calculator.run_calculation", + { + salary_structure: ss, + calculate_based_on: mode, + gross_pay: this.state.gross_pay, + net_pay: this.state.net_pay, + selected_components: this.get_selected_names(), + earning_overrides: this.earning_overrides, + employee: this.state.employee || undefined, + }, + ).then((r) => { + if (!r) return; + this.result = r; + + // Update computed field without retriggering calculation + this._applying_result = true; + if (mode === "Net Pay") { + this.fields.gross_pay.set_value(r.gross_pay); + } else { + this.fields.net_pay.set_value(r.net_pay); + } + this._applying_result = false; + + this.refresh_preview(); + }).catch((err) => { + this.result = null; + frappe.show_alert({ message: __("Calculation error: {0}", [err?.message || err]), indicator: "orange" }, 5); + }); + } + + // ── Preview ────────────────────────────────────────────────────────── + + refresh_preview() { + const ss = this.state.salary_structure; + const r = this.result; + + if (!ss || !r) { + this.show_placeholder(); + return; + } + + // HRMS path returns earnings/deductions directly; use them as-is + const earnings = r.earnings || []; + const deductions = r.deductions || []; + + const emp = this.state.employee; + frappe.xcall( + "av_tools.av_tools.page.salary_calculator.salary_calculator.get_salary_slip_preview", + { + salary_structure: ss, + base: r.base, + gross_pay: r.gross_pay, + net_pay: r.net_pay, + earnings_data: earnings, + deductions_data: deductions, + employee: emp || undefined, + }, + ).then((html) => { + this.$(".sc-placeholder").hide(); + this.$(".sc-preview-content").html(html).show(); + this.render_preview_actions(); + }); + } + + show_placeholder() { + this.$(".sc-placeholder").show(); + this.$(".sc-preview-content").hide(); + this.$(".sc-preview-actions").empty(); + } + + // ── Assignment ─────────────────────────────────────────────────────── + + render_preview_actions() { + const $a = this.$(".sc-preview-actions").empty(); + if (!this.state.employee || !this.result) return; + + $('') + .appendTo($a) + .on("click", () => this.show_assign_dialog()); + } + + show_assign_dialog() { + const d = new frappe.ui.Dialog({ + title: __("Create Salary Structure Assignment"), + fields: [ + { fieldtype: "Link", fieldname: "employee", label: "Employee", options: "Employee", default: this.state.employee, read_only: 1 }, + { fieldtype: "Link", fieldname: "salary_structure", label: "Salary Structure", options: "Salary Structure", default: this.state.salary_structure, read_only: 1 }, + { fieldtype: "Currency", fieldname: "base", label: "Base Amount", default: this.result?.base || 0, precision: 0, description: "Calculated base salary" }, + { fieldtype: "Date", fieldname: "from_date", label: "From Date", reqd: 1, default: frappe.datetime.get_today() }, + ], + primary_action_label: __("Create & Submit"), + primary_action: (v) => { + d.hide(); + frappe.xcall( + "av_tools.av_tools.page.salary_calculator.salary_calculator.create_salary_structure_assignment", + { employee: v.employee, salary_structure: v.salary_structure, from_date: v.from_date, base: v.base }, + ).then((name) => { + frappe.show_alert({ + message: __("Assignment {0} created", [`${name}`]), + indicator: "green", + }, 7); + }); + }, + }); + d.show(); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + $(selector) { return this.page.main.find(selector); } + + fmt(amount) { + return format_number(flt(amount), null, this.currency === "TZS" ? 0 : 2); + } +} diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.json b/av_tools/av_tools/page/salary_calculator/salary_calculator.json new file mode 100644 index 0000000..d73208b --- /dev/null +++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.json @@ -0,0 +1,29 @@ +{ + "content": null, + "creation": "2026-04-07 10:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-04-09 10:00:00.000000", + "modified_by": "Administrator", + "module": "Av Tools", + "name": "salary-calculator", + "owner": "Administrator", + "page_name": "Salary Calculator", + "roles": [ + { + "role": "HR Manager" + }, + { + "role": "HR User" + }, + { + "role": "System Manager" + } + ], + "script": null, + "standard": "Yes", + "style": null, + "system_page": 0, + "title": "Salary Calculator" +} \ No newline at end of file diff --git a/av_tools/av_tools/page/salary_calculator/salary_calculator.py b/av_tools/av_tools/page/salary_calculator/salary_calculator.py new file mode 100644 index 0000000..ca397c0 --- /dev/null +++ b/av_tools/av_tools/page/salary_calculator/salary_calculator.py @@ -0,0 +1,451 @@ +"""Salary Calculator page backend. + +Uses HRMS ``make_salary_slip`` (for_preview=1) when an employee is provided, +giving the full formula context (employee fields, payment days, tax handling). +Falls back to a lightweight resilient engine when no employee is selected. +""" +from __future__ import annotations + +import json +import re + +import frappe +from frappe import _ +from frappe.utils import cint, cstr, flt + + +# ── Public API ─────────────────────────────────────────────────────── + +@frappe.whitelist() +def get_salary_structure_components(salary_structure): + """Return earnings/deductions for a salary structure with unique keys.""" + structure = frappe.get_cached_doc("Salary Structure", salary_structure) + + def serialize(rows, prefix): + return [ + { + "key": f"{prefix}-{i}", + "salary_component": row.salary_component, + "abbr": row.abbr, + "amount": flt(row.amount), + "amount_based_on_formula": row.amount_based_on_formula, + "formula": row.formula or "", + "condition": row.condition or "", + "do_not_include_in_total": row.do_not_include_in_total, + "statistical_component": row.statistical_component, + } + for i, row in enumerate(rows) + ] + + return { + "earnings": serialize(structure.earnings, "E"), + "deductions": serialize(structure.deductions, "D"), + "currency": structure.currency or "", + } + + +@frappe.whitelist() +def run_calculation( + salary_structure, calculate_based_on, gross_pay=0, net_pay=0, + selected_components=None, earning_overrides=None, employee=None, +): + """Calculate salary. Uses HRMS when employee provided, else lightweight fallback.""" + if isinstance(selected_components, str): + selected_components = json.loads(selected_components) + if isinstance(earning_overrides, str): + earning_overrides = json.loads(earning_overrides) + + overrides = earning_overrides or {} + target_field = "gross_pay" if calculate_based_on == "Gross Pay" else "net_pay" + target_amount = flt(gross_pay if target_field == "gross_pay" else net_pay) + + if target_amount <= 0: + return _empty_result() + + if employee: + try: + return _solve_via_hrms(salary_structure, employee, target_field, target_amount, overrides) + except Exception: + pass # Fall through to fallback + + selected = list(dict.fromkeys(selected_components or [])) + structure = frappe.get_cached_doc("Salary Structure", salary_structure) + precision = 0 if cstr(structure.currency) == "TZS" else 2 + return _fallback_solve(structure, target_field, flt(target_amount, precision), precision, selected, overrides) + + +@frappe.whitelist() +def get_salary_slip_preview( + salary_structure, base, gross_pay, net_pay, + earnings_data, deductions_data, employee=None, +): + """Render a salary slip preview from calculated data.""" + if isinstance(earnings_data, str): + earnings_data = json.loads(earnings_data) + if isinstance(deductions_data, str): + deductions_data = json.loads(deductions_data) + + emp = frappe.get_cached_doc("Employee", employee) if employee else None + currency = frappe.get_cached_value("Salary Structure", salary_structure, "currency") or "TZS" + fmt = (lambda a: format(flt(a), ",.0f")) if currency == "TZS" else (lambda a: format(flt(a), ",.2f")) + + # CTC = Gross Pay + employer-cost contributions (deduction components flagged + # do_not_include_in_total, e.g. employer NSSF/pension/SDL/WCF). + ctc = flt(gross_pay) + comp_names = [d.get("salary_component") for d in deductions_data if d.get("salary_component")] + if comp_names: + employer_comps = set( + frappe.get_all( + "Salary Component", + filters={"name": ["in", comp_names], "do_not_include_in_total": 1}, + pluck="name", + ) + ) + for d in deductions_data: + if d.get("salary_component") in employer_comps: + ctc += flt(d.get("amount")) + + return frappe.render_template( + "av_tools/av_tools/page/salary_calculator/salary_slip_preview.html", + { + "employee": employee or "", + "employee_name": emp.employee_name if emp else "", + "department": (emp.department or "") if emp else "", + "designation": (emp.designation or "") if emp else "", + "company": emp.company if emp else "", + "salary_structure": salary_structure, + "currency": currency, + "base": flt(base), + "gross_pay": flt(gross_pay), + "net_pay": flt(net_pay), + "ctc": flt(ctc), + "total_earning": sum(flt(e.get("amount")) for e in earnings_data), + "total_deduction": sum(flt(d.get("amount")) for d in deductions_data), + "earnings": earnings_data, + "deductions": deductions_data, + "format_amount": fmt, + }, + ) + + +@frappe.whitelist() +def create_salary_structure_assignment(employee, salary_structure, from_date, base=0): + """Create and submit a Salary Structure Assignment.""" + if frappe.db.exists( + "Salary Structure Assignment", + {"employee": employee, "salary_structure": salary_structure, "from_date": from_date, "docstatus": ["!=", 2]}, + ): + frappe.throw( + _("Salary Structure Assignment already exists for {0} with {1} from {2}").format( + frappe.bold(employee), frappe.bold(salary_structure), frappe.bold(from_date), + ) + ) + + ssa = frappe.new_doc("Salary Structure Assignment") + ssa.employee = employee + ssa.salary_structure = salary_structure + ssa.from_date = from_date + ssa.base = flt(base) + ssa.company = frappe.get_cached_value("Employee", employee, "company") + ssa.save() + ssa.submit() + return ssa.name + + +# ── HRMS-based Calculation ─────────────────────────────────────────── + +def _empty_result(): + return {"base": 0, "gross_pay": 0, "net_pay": 0, "total_deductions": 0, "earnings": [], "deductions": []} + + +def _make_preview_slip(salary_structure, employee, base_override=None, earning_overrides=None): + """Create an in-memory salary slip using HRMS, optionally overriding base/earnings.""" + from hrms.payroll.doctype.salary_structure.salary_structure import make_salary_slip + + ss = make_salary_slip(salary_structure, employee=employee, for_preview=1) + + if base_override is not None: + ss._salary_structure_assignment["base"] = flt(base_override) + ss.set("earnings", []) + ss.set("deductions", []) + ss.process_salary_structure(for_preview=1) + + if earning_overrides: + changed = False + for row in ss.earnings: + if row.salary_component in earning_overrides: + row.amount = flt(earning_overrides[row.salary_component]) + row.default_amount = row.amount + changed = True + if changed: + ss.calculate_net_pay() + + return ss + + +def _format_slip_result(ss): + """Extract a result dict from a salary slip object.""" + currency = cstr(getattr(ss, "currency", "")) + precision = 0 if currency == "TZS" else 2 + base = flt(ss._salary_structure_assignment.get("base", 0), precision) if hasattr(ss, "_salary_structure_assignment") else 0 + return { + "base": base, + "gross_pay": flt(ss.gross_pay, precision), + "net_pay": flt(ss.net_pay, precision), + "total_deductions": flt(ss.total_deduction, precision), + "earnings": [ + {"salary_component": r.salary_component, "amount": flt(r.amount)} + for r in ss.earnings + ], + "deductions": [ + {"salary_component": r.salary_component, "amount": flt(r.amount)} + for r in ss.deductions + ], + } + + +def _solve_via_hrms(salary_structure, employee, target_field, target_amount, earning_overrides=None): + """Binary-search for the base that produces the target gross/net using HRMS.""" + lo, hi = 0.0, max(target_amount * 3, 1) + best, best_val = None, None + + # Suppress repeated msgprint calls during binary search iterations + original_messages = frappe.local.message_log[:] + frappe.flags.mute_messages = True + try: + for i in range(60): + mid = (lo + hi) / 2 + ss = _make_preview_slip(salary_structure, employee, base_override=mid, earning_overrides=earning_overrides) + val = flt(ss.gross_pay) if target_field == "gross_pay" else flt(ss.net_pay) + + if best is None or abs(val - target_amount) < abs(best_val - target_amount): + best, best_val = ss, val + + if abs(val - target_amount) <= 1: + break + if i > 2 and val == 0: + return _empty_result() + if val < target_amount: + lo = mid + else: + hi = mid + finally: + frappe.flags.mute_messages = False + frappe.local.message_log = original_messages + + return _format_slip_result(best) if best else _empty_result() + + +# ── Fallback Engine (no employee) ──────────────────────────────────── + +_SAFE_GLOBALS = {"int": int, "float": float, "round": round, "abs": abs, "min": min, "max": max, "flt": flt} + + +def _fallback_solve(structure, target_field, target_amount, precision, selected, overrides=None): + overrides = overrides or {} + tol = 1 if precision == 0 else 0.05 + lo, hi = 0.0, max(target_amount, 1) + best, best_diff = None, None + + for i in range(20): + r = _fallback_calc(structure, hi, precision, selected, overrides) + val = flt(r[target_field], precision) + if val >= target_amount: + best, best_diff = r, abs(val - target_amount) + break + if i > 2 and val == 0: + return _empty_result() + hi *= 2 + else: + best = r + best_diff = abs(flt(r[target_field], precision) - target_amount) + + for _ in range(60): + mid = (lo + hi) / 2 + r = _fallback_calc(structure, mid, precision, selected, overrides) + diff = flt(r[target_field], precision) - target_amount + if abs(diff) < (best_diff or float("inf")): + best, best_diff = r, abs(diff) + if abs(diff) <= tol: + break + if diff < 0: + lo = mid + else: + hi = mid + + base_int = int(round(flt(best["base"], precision))) + for offset in range(-3, 4): + cand = base_int + offset + if cand < 0: + continue + r = _fallback_calc(structure, cand, precision, selected, overrides) + d = abs(flt(r[target_field], precision) - target_amount) + if d < best_diff or (d == best_diff and flt(r[target_field], precision) >= flt(best[target_field], precision)): + best, best_diff = r, d + + return best + + +def _fallback_calc(structure, base_amount, precision, selected, overrides=None): + overrides = overrides or {} + rows = { + "earnings": [_norm_row(r, "Earning", precision) for r in structure.earnings], + "deductions": [_norm_row(r, "Deduction", precision) for r in structure.deductions], + } + gross_pay = net_pay = total_ded = 0.0 + prev = None + + for _ in range(10): + ctx = {"base": flt(base_amount, precision), "gross_pay": gross_pay, "net_pay": net_pay, "total_deductions": total_ded} + for r in rows["earnings"] + rows["deductions"]: + if r.abbr: + ctx[r.abbr] = flt(r.amount, precision) + ctx[f"{r.abbr}_amount"] = flt(r.amount, precision) + _add_missing_ctx(ctx, rows["earnings"] + rows["deductions"]) + + earnings = _eval_rows(rows["earnings"], ctx, precision, selected, overrides) + gross_pay = flt(sum(r.amount for r in earnings if _incl_earning(r, selected)), precision) + ctx["gross_pay"] = gross_pay + for r in earnings: + if r.abbr: + ctx[r.abbr] = flt(r.amount, precision) + ctx[f"{r.abbr}_amount"] = flt(r.amount, precision) + + deductions = _eval_rows(rows["deductions"], ctx, precision, selected) + total_ded = flt(sum(r.amount for r in deductions if _incl_deduction(r, selected)), precision) + net_pay = flt(gross_pay - total_ded, precision) + + state = (gross_pay, total_ded, net_pay, tuple(r.amount for r in earnings + deductions)) + rows = {"earnings": earnings, "deductions": deductions} + if state == prev: + break + prev = state + + # Sum amounts by component name (handles duplicates like PAYE tiers) + amounts = {} + comp_types = {} + for r in earnings + deductions: + name = cstr(r.salary_component) + if name: + amounts[name] = flt(amounts.get(name, 0) + flt(r.amount, precision), precision) + comp_types[name] = r.component_type + + # Always include all selected components so they appear in the preview + seen = set() + result_earnings, result_deductions = [], [] + for c in selected: + if c in seen: + continue + seen.add(c) + entry = {"salary_component": c, "amount": flt(amounts.get(c, 0), precision)} + if comp_types.get(c) == "Earning": + result_earnings.append(entry) + elif comp_types.get(c) == "Deduction": + result_deductions.append(entry) + + return { + "base": flt(base_amount, precision), + "gross_pay": gross_pay, + "net_pay": net_pay, + "total_deductions": total_ded, + "earnings": result_earnings, + "deductions": result_deductions, + } + + +def _norm_row(row, ctype, precision): + return frappe._dict( + salary_component=cstr(row.salary_component), abbr=cstr(row.abbr), component_type=ctype, + condition=cstr(row.condition), formula=cstr(row.formula), + amount=flt(row.amount, precision), amount_based_on_formula=cint(row.amount_based_on_formula), + do_not_include_in_total=cint(row.do_not_include_in_total), statistical_component=cint(row.statistical_component), + ) + + +def _add_missing_ctx(ctx, rows): + """Default any undefined variable referenced in conditions/formulas. + + Condition variables (used in boolean expressions like ``x == 1``) default + to 1 so that components are assumed applicable when no employee context is + available. Formula-only variables default to 0 so they don't inflate + amounts. + """ + # Collect all tokens used in conditions vs formulas + cond_tokens = set() + formula_tokens = set() + for r in rows: + if r.condition: + cond_tokens.update(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", r.condition)) + if r.formula: + formula_tokens.update(re.findall(r"\b[A-Za-z_][A-Za-z0-9_]*\b", r.formula)) + + # Python keywords and safe_eval builtins to skip + skip = {"and", "or", "not", "if", "else", "True", "False", "None", + "int", "float", "round", "abs", "min", "max", "flt"} + + for tok in cond_tokens: + if tok not in skip: + ctx.setdefault(tok, 1) # assume applicable + + for tok in formula_tokens - cond_tokens: + if tok not in skip: + ctx.setdefault(tok, 0) # don't inflate amounts + + +def _eval_rows(rows, base_ctx, precision, selected, overrides=None): + overrides = overrides or {} + ctx = frappe._dict(base_ctx.copy()) + result = [] + for row in rows: + comp = cstr(row.salary_component) + if comp in overrides and row.component_type == "Earning" and not _is_base(row): + amt = flt(overrides[comp], precision) + elif not _should_eval(row, selected): + amt = 0 + elif row.condition and not _safe_eval_cond(row.condition, ctx): + amt = 0 + elif row.amount_based_on_formula and row.formula: + amt = _safe_eval_formula(row.formula, ctx, precision) + else: + amt = row.amount if not row.amount_based_on_formula else 0 + row.amount = flt(amt, precision) + result.append(row) + if row.abbr: + ctx[row.abbr] = row.amount + ctx[f"{row.abbr}_amount"] = row.amount + return result + + +def _safe_eval_formula(formula, ctx, precision): + try: + return flt(frappe.safe_eval(cstr(formula).strip(), eval_globals=_SAFE_GLOBALS, eval_locals=ctx), precision) + except Exception: + return 0 + + +def _safe_eval_cond(condition, ctx): + try: + return cint(frappe.safe_eval(cstr(condition).strip(), eval_globals=_SAFE_GLOBALS, eval_locals=ctx)) + except Exception: + return False + + +def _is_base(row): + f = cstr(row.formula).replace(" ", "").lower() + return row.abbr == "B" or cstr(row.salary_component).lower() == "basic" or f == "base" + + +def _should_eval(row, selected): + return row.statistical_component or _is_base(row) or cstr(row.salary_component) in set(selected) + + +def _incl_earning(row, selected): + if row.component_type != "Earning" or row.do_not_include_in_total or row.statistical_component: + return False + return _is_base(row) or cstr(row.salary_component) in set(selected) + + +def _incl_deduction(row, selected): + if row.component_type != "Deduction" or row.do_not_include_in_total or row.statistical_component: + return False + return cstr(row.salary_component) in set(selected) diff --git a/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html b/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html new file mode 100644 index 0000000..e185ae9 --- /dev/null +++ b/av_tools/av_tools/page/salary_calculator/salary_slip_preview.html @@ -0,0 +1,103 @@ +
+
+

Salary Slip Preview

+
+ {% if employee %} +
+ Employee: + {{ employee }} - {{ employee_name }} +
+ {% endif %} + {% if department %} +
+ Department: + {{ department }} +
+ {% endif %} + {% if designation %} +
+ Designation: + {{ designation }} +
+ {% endif %} + {% if company %} +
+ Company: + {{ company }} +
+ {% endif %} +
+ Salary Structure: + {{ salary_structure }} +
+
+
+ +
+
+
+
Earnings
+ + + + + + + + + {% for e in earnings %} + + + + + {% endfor %} + + + + + + + +
ComponentAmount ({{ currency }})
{{ e.salary_component }}{{ format_amount(e.amount) }}
Total Earnings{{ format_amount(total_earning) }}
+
+ +
+
Deductions
+ + + + + + + + + {% for d in deductions %} + + + + + {% endfor %} + {% if not deductions %} + + + + {% endif %} + + + + + + + +
ComponentAmount ({{ currency }})
{{ d.salary_component }}{{ format_amount(d.amount) }}
No deductions
Total Deductions{{ format_amount(total_deduction) }}
+
+
+
+ + +