diff --git a/.flake8 b/.flake8
index 4ff88403244..4b852abd7c6 100644
--- a/.flake8
+++ b/.flake8
@@ -31,6 +31,7 @@ ignore =
E124, # closing bracket, irritating while writing QB code
E131, # continuation line unaligned for hanging indent
E123, # closing bracket does not match indentation of opening bracket's line
+ E101, # ensured by use of black
max-line-length = 200
exclude=.github/helper/semgrep_rules
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index f9a7a024aea..a63c5b841ca 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -11,7 +11,7 @@ fi
cd ~ || exit
-sudo apt-get install redis-server libcups2-dev
+sudo apt update && sudo apt install redis-server libcups2-dev
pip install frappe-bench
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index c77d8bca032..5a7705e3145 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -149,6 +149,7 @@ def allow_regional(fn):
return caller
+@frappe.whitelist()
def get_last_membership(member):
"""Returns last membership if exists"""
last_membership = frappe.get_all(
diff --git a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
index 8e0e62d5f8c..a944a373832 100644
--- a/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
+++ b/erpnext/accounts/doctype/period_closing_voucher/test_period_closing_voucher.py
@@ -78,7 +78,10 @@ class TestPeriodClosingVoucher(unittest.TestCase):
expense_account="Cost of Goods Sold - TPC",
rate=400,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
+
create_sales_invoice(
company=company,
cost_center=cost_center2,
@@ -86,6 +89,8 @@ class TestPeriodClosingVoucher(unittest.TestCase):
expense_account="Cost of Goods Sold - TPC",
rate=200,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
pcv = self.make_period_closing_voucher(submit=False)
@@ -119,14 +124,17 @@ class TestPeriodClosingVoucher(unittest.TestCase):
surplus_account = create_account()
cost_center = create_cost_center("Test Cost Center 1")
- create_sales_invoice(
+ si = create_sales_invoice(
company=company,
income_account="Sales - TPC",
expense_account="Cost of Goods Sold - TPC",
cost_center=cost_center,
rate=400,
debit_to="Debtors - TPC",
+ currency="USD",
+ customer="_Test Customer USD",
)
+
jv = make_journal_entry(
account1="Cash - TPC",
account2="Sales - TPC",
diff --git a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
index 572410fc665..98f3420d87e 100644
--- a/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
+++ b/erpnext/accounts/doctype/pos_closing_entry/pos_closing_entry.js
@@ -102,7 +102,9 @@ frappe.ui.form.on('POS Closing Entry', {
});
},
- before_save: function(frm) {
+ before_save: async function(frm) {
+ frappe.dom.freeze(__('Processing Sales! Please Wait...'));
+
frm.set_value("grand_total", 0);
frm.set_value("net_total", 0);
frm.set_value("total_quantity", 0);
@@ -112,17 +114,23 @@ frappe.ui.form.on('POS Closing Entry', {
row.expected_amount = row.opening_amount;
}
- for (let row of frm.doc.pos_transactions) {
- frappe.db.get_doc("POS Invoice", row.pos_invoice).then(doc => {
- frm.doc.grand_total += flt(doc.grand_total);
- frm.doc.net_total += flt(doc.net_total);
- frm.doc.total_quantity += flt(doc.total_qty);
- refresh_payments(doc, frm);
- refresh_taxes(doc, frm);
- refresh_fields(frm);
- set_html_data(frm);
- });
+ const pos_inv_promises = frm.doc.pos_transactions.map(
+ row => frappe.db.get_doc("POS Invoice", row.pos_invoice)
+ );
+
+ const pos_invoices = await Promise.all(pos_inv_promises);
+
+ for (let doc of pos_invoices) {
+ frm.doc.grand_total += flt(doc.grand_total);
+ frm.doc.net_total += flt(doc.net_total);
+ frm.doc.total_quantity += flt(doc.total_qty);
+ refresh_payments(doc, frm);
+ refresh_taxes(doc, frm);
+ refresh_fields(frm);
+ set_html_data(frm);
}
+
+ frappe.dom.unfreeze();
}
});
diff --git a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
index 4b81a7d6a23..5701402811e 100644
--- a/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
+++ b/erpnext/accounts/doctype/pricing_rule/test_pricing_rule.py
@@ -712,7 +712,7 @@ class TestPricingRule(unittest.TestCase):
title="_Test Pricing Rule with Min Qty - 2",
)
- si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD")
+ si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1)
item = si.items[0]
item.stock_qty = 1
si.save()
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
index 4f5640f9cb9..0c2ec5ffa31 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js
@@ -45,8 +45,6 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({
if (this.frm.doc.supplier && this.frm.doc.__islocal) {
this.frm.trigger('supplier');
}
-
- erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
},
refresh: function(doc) {
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index e4719d6b40c..3c1dc80970e 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -540,7 +540,16 @@ class PurchaseInvoice(BuyingController):
from_repost=from_repost,
)
elif self.docstatus == 2:
+ provisional_entries = [a for a in gl_entries if a.voucher_type == "Purchase Receipt"]
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
+ if provisional_entries:
+ for entry in provisional_entries:
+ frappe.db.set_value(
+ "GL Entry",
+ {"voucher_type": "Purchase Receipt", "voucher_detail_no": entry.voucher_detail_no},
+ "is_cancelled",
+ 1,
+ )
if update_outstanding == "No":
update_outstanding_amt(
@@ -1078,7 +1087,7 @@ class PurchaseInvoice(BuyingController):
# Stock ledger value is not matching with the warehouse amount
if (
self.update_stock
- and voucher_wise_stock_value.get(item.name)
+ and voucher_wise_stock_value.get((item.name, item.warehouse))
and warehouse_debit_amount
!= flt(voucher_wise_stock_value.get((item.name, item.warehouse)), net_amt_precision)
):
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 15803b5bfe1..038508b14a8 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -26,12 +26,13 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
make_purchase_receipt,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
+from erpnext.stock.tests.test_utils import StockTestMixin
test_dependencies = ["Item", "Cost Center", "Payment Term", "Payment Terms Template"]
test_ignore = ["Serial No"]
-class TestPurchaseInvoice(unittest.TestCase):
+class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
@classmethod
def setUpClass(self):
unlink_payment_on_cancel_of_invoice()
@@ -659,6 +660,80 @@ class TestPurchaseInvoice(unittest.TestCase):
self.assertEqual(expected_values[gle.account][0], gle.debit)
self.assertEqual(expected_values[gle.account][1], gle.credit)
+ def test_standalone_return_using_pi(self):
+ from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
+
+ item = self.make_item().name
+ company = "_Test Company with perpetual inventory"
+ warehouse = "Stores - TCP1"
+
+ make_stock_entry(item_code=item, target=warehouse, qty=50, rate=120)
+
+ return_pi = make_purchase_invoice(
+ is_return=1,
+ item=item,
+ qty=-10,
+ update_stock=1,
+ rate=100,
+ company=company,
+ warehouse=warehouse,
+ cost_center="Main - TCP1",
+ )
+
+ # assert that stock consumption is with actual rate
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 1200, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # assert loss booked in COGS
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 0, "debit": 200}],
+ gle_filters={"account": "Cost of Goods Sold - TCP1"},
+ )
+
+ def test_return_with_lcv(self):
+ from erpnext.controllers.sales_and_purchase_return import make_return_doc
+ from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
+ create_landed_cost_voucher,
+ )
+
+ item = self.make_item().name
+ company = "_Test Company with perpetual inventory"
+ warehouse = "Stores - TCP1"
+ cost_center = "Main - TCP1"
+
+ pi = make_purchase_invoice(
+ item=item,
+ company=company,
+ warehouse=warehouse,
+ cost_center=cost_center,
+ update_stock=1,
+ qty=10,
+ rate=100,
+ )
+
+ # Create landed cost voucher - will increase valuation of received item by 10
+ create_landed_cost_voucher("Purchase Invoice", pi.name, pi.company, charges=100)
+ return_pi = make_return_doc(pi.doctype, pi.name)
+ return_pi.save().submit()
+
+ # assert that stock consumption is with actual in rate
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 1100, "debit": 0}],
+ gle_filters={"account": "Stock In Hand - TCP1"},
+ )
+
+ # assert loss booked in COGS
+ self.assertGLEs(
+ return_pi,
+ [{"credit": 0, "debit": 100}],
+ gle_filters={"account": "Cost of Goods Sold - TCP1"},
+ )
+
def test_multi_currency_gle(self):
pi = make_purchase_invoice(
supplier="_Test Supplier USD",
@@ -1492,6 +1567,18 @@ class TestPurchaseInvoice(unittest.TestCase):
check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
+ # Cancel purchase invoice to check reverse provisional entry cancellation
+ pi.cancel()
+
+ expected_gle_for_purchase_receipt_post_pi_cancel = [
+ ["Provision Account - _TC", 0, 250, pi.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date],
+ ]
+
+ check_gl_entries(
+ self, pr.name, expected_gle_for_purchase_receipt_post_pi_cancel, pr.posting_date
+ )
+
company.enable_provisional_accounting_for_non_stock_items = 0
company.save()
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
index c6a110dcab6..a36872fb234 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.js
@@ -53,7 +53,6 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
me.frm.refresh_fields();
}
erpnext.queries.setup_warehouse_query(this.frm);
- erpnext.accounts.dimensions.setup_dimension_filters(this.frm, this.frm.doctype);
},
refresh: function(doc, dt, dn) {
@@ -475,7 +474,7 @@ erpnext.accounts.SalesInvoiceController = erpnext.selling.SellingController.exte
let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail)
});
- frm.trigger("calculate_timesheet_totals");
+ this.frm.trigger("calculate_timesheet_totals");
}
}
});
@@ -885,27 +884,44 @@ frappe.ui.form.on('Sales Invoice', {
set_timesheet_data: function(frm, timesheets) {
frm.clear_table("timesheets")
- timesheets.forEach(timesheet => {
+ timesheets.forEach(async (timesheet) => {
if (frm.doc.currency != timesheet.currency) {
- frappe.call({
- method: "erpnext.setup.utils.get_exchange_rate",
- args: {
- from_currency: timesheet.currency,
- to_currency: frm.doc.currency
- },
- callback: function(r) {
- if (r.message) {
- exchange_rate = r.message;
- frm.events.append_time_log(frm, timesheet, exchange_rate);
- }
- }
- });
+ const exchange_rate = await frm.events.get_exchange_rate(
+ frm, timesheet.currency, frm.doc.currency
+ )
+ frm.events.append_time_log(frm, timesheet, exchange_rate)
} else {
frm.events.append_time_log(frm, timesheet, 1.0);
}
});
},
+ async get_exchange_rate(frm, from_currency, to_currency) {
+ if (
+ frm.exchange_rates
+ && frm.exchange_rates[from_currency]
+ && frm.exchange_rates[from_currency][to_currency]
+ ) {
+ return frm.exchange_rates[from_currency][to_currency];
+ }
+
+ return frappe.call({
+ method: "erpnext.setup.utils.get_exchange_rate",
+ args: {
+ from_currency,
+ to_currency
+ },
+ callback: function(r) {
+ if (r.message) {
+ // cache exchange rates
+ frm.exchange_rates = frm.exchange_rates || {};
+ frm.exchange_rates[from_currency] = frm.exchange_rates[from_currency] || {};
+ frm.exchange_rates[from_currency][to_currency] = r.message;
+ }
+ }
+ });
+ },
+
append_time_log: function(frm, time_log, exchange_rate) {
const row = frm.add_child("timesheets");
row.activity_type = time_log.activity_type;
@@ -916,7 +932,7 @@ frappe.ui.form.on('Sales Invoice', {
row.billing_hours = time_log.billing_hours;
row.billing_amount = flt(time_log.billing_amount) * flt(exchange_rate);
row.timesheet_detail = time_log.name;
- row.project_name = time_log.project_name;
+ row.project_name = time_log.project_name;
frm.refresh_field("timesheets");
frm.trigger("calculate_timesheet_totals");
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index e39822e4036..954a8207780 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -1113,24 +1113,24 @@ class SalesInvoice(SellingController):
asset = self.get_asset(item)
if self.is_return:
+ if asset.calculate_depreciation:
+ self.reverse_depreciation_entry_made_after_sale(asset)
+ self.reset_depreciation_schedule(asset)
+
fixed_asset_gl_entries = get_gl_entries_on_asset_regain(
asset, item.base_net_amount, item.finance_book
)
asset.db_set("disposal_date", None)
- if asset.calculate_depreciation:
- self.reverse_depreciation_entry_made_after_sale(asset)
- self.reset_depreciation_schedule(asset)
-
else:
+ if asset.calculate_depreciation:
+ self.depreciate_asset(asset)
+
fixed_asset_gl_entries = get_gl_entries_on_asset_disposal(
asset, item.base_net_amount, item.finance_book
)
asset.db_set("disposal_date", self.posting_date)
- if asset.calculate_depreciation:
- self.depreciate_asset(asset)
-
for gle in fixed_asset_gl_entries:
gle["against"] = self.customer
gl_entries.append(self.get_gl_dict(gle, item=item))
@@ -1198,6 +1198,7 @@ class SalesInvoice(SellingController):
asset.save()
make_depreciation_entry(asset.name, self.posting_date)
+ asset.load_from_db()
def reset_depreciation_schedule(self, asset):
asset.flags.ignore_validate_update_after_submit = True
@@ -1207,6 +1208,7 @@ class SalesInvoice(SellingController):
self.modify_depreciation_schedule_for_asset_repairs(asset)
asset.save()
+ asset.load_from_db()
def modify_depreciation_schedule_for_asset_repairs(self, asset):
asset_repairs = frappe.get_all(
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index b76ce29b505..177624ca032 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -898,3 +898,18 @@ def get_default_contact(doctype, name):
return None
else:
return None
+
+
+def add_party_account(party_type, party, company, account):
+ doc = frappe.get_doc(party_type, party)
+ account_exists = False
+ for d in doc.get("accounts"):
+ if d.account == account:
+ account_exists = True
+
+ if not account_exists:
+ accounts = {"company": company, "account": account}
+
+ doc.append("accounts", accounts)
+
+ doc.save()
diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
index f3ccc868c4c..c41d0d10ffe 100644
--- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
+++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
@@ -198,10 +198,12 @@ def get_loan_entries(filters):
amount_field = (loan_doc.disbursed_amount).as_("credit")
posting_date = (loan_doc.disbursement_date).as_("posting_date")
account = loan_doc.disbursement_account
+ salary_condition = loan_doc.docstatus == 1
else:
amount_field = (loan_doc.amount_paid).as_("debit")
posting_date = (loan_doc.posting_date).as_("posting_date")
account = loan_doc.payment_account
+ salary_condition = loan_doc.repay_from_salary == 0
query = (
frappe.qb.from_(loan_doc)
@@ -214,14 +216,12 @@ def get_loan_entries(filters):
posting_date,
)
.where(loan_doc.docstatus == 1)
+ .where(salary_condition)
.where(account == filters.get("account"))
.where(posting_date <= getdate(filters.get("report_date")))
.where(ifnull(loan_doc.clearance_date, "4000-01-01") > getdate(filters.get("report_date")))
)
- if doctype == "Loan Repayment":
- query.where(loan_doc.repay_from_salary == 0)
-
entries = query.run(as_dict=1)
loan_docs.extend(entries)
@@ -267,15 +267,17 @@ def get_loan_amount(filters):
amount_field = Sum(loan_doc.disbursed_amount)
posting_date = (loan_doc.disbursement_date).as_("posting_date")
account = loan_doc.disbursement_account
+ salary_condition = loan_doc.docstatus == 1
else:
amount_field = Sum(loan_doc.amount_paid)
posting_date = (loan_doc.posting_date).as_("posting_date")
account = loan_doc.payment_account
-
+ salary_condition = loan_doc.repay_from_salary == 0
amount = (
frappe.qb.from_(loan_doc)
.select(amount_field)
.where(loan_doc.docstatus == 1)
+ .where(salary_condition)
.where(account == filters.get("account"))
.where(posting_date > getdate(filters.get("report_date")))
.where(ifnull(loan_doc.clearance_date, "4000-01-01") <= getdate(filters.get("report_date")))
diff --git a/erpnext/accounts/report/cash_flow/cash_flow.py b/erpnext/accounts/report/cash_flow/cash_flow.py
index 7929d4aa2ae..ee924f86a6a 100644
--- a/erpnext/accounts/report/cash_flow/cash_flow.py
+++ b/erpnext/accounts/report/cash_flow/cash_flow.py
@@ -263,7 +263,10 @@ def get_report_summary(summary_data, currency):
def get_chart_data(columns, data):
labels = [d.get("label") for d in columns[2:]]
datasets = [
- {"name": account.get("account").replace("'", ""), "values": [account.get("total")]}
+ {
+ "name": account.get("account").replace("'", ""),
+ "values": [account.get(d.get("fieldname")) for d in columns[2:]],
+ }
for account in data
if account.get("parent_account") == None and account.get("currency")
]
diff --git a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
index 2e7213f42b1..ac706666547 100644
--- a/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
+++ b/erpnext/accounts/report/item_wise_sales_register/item_wise_sales_register.py
@@ -443,12 +443,6 @@ def get_grand_total(filters, doctype):
] # nosec
-def get_deducted_taxes():
- return frappe.db.sql_list(
- "select name from `tabPurchase Taxes and Charges` where add_deduct_tax = 'Deduct'"
- )
-
-
def get_tax_accounts(
item_list,
columns,
@@ -462,6 +456,7 @@ def get_tax_accounts(
tax_columns = []
invoice_item_row = {}
itemised_tax = {}
+ add_deduct_tax = "charge_type"
tax_amount_precision = (
get_field_precision(
@@ -477,13 +472,13 @@ def get_tax_accounts(
conditions = ""
if doctype == "Purchase Invoice":
conditions = " and category in ('Total', 'Valuation and Total') and base_tax_amount_after_discount_amount != 0"
+ add_deduct_tax = "add_deduct_tax"
- deducted_tax = get_deducted_taxes()
tax_details = frappe.db.sql(
"""
select
name, parent, description, item_wise_tax_detail,
- charge_type, base_tax_amount_after_discount_amount
+ charge_type, {add_deduct_tax}, base_tax_amount_after_discount_amount
from `tab%s`
where
parenttype = %s and docstatus = 1
@@ -491,12 +486,22 @@ def get_tax_accounts(
and parent in (%s)
%s
order by description
- """
+ """.format(
+ add_deduct_tax=add_deduct_tax
+ )
% (tax_doctype, "%s", ", ".join(["%s"] * len(invoice_item_row)), conditions),
tuple([doctype] + list(invoice_item_row)),
)
- for name, parent, description, item_wise_tax_detail, charge_type, tax_amount in tax_details:
+ for (
+ name,
+ parent,
+ description,
+ item_wise_tax_detail,
+ charge_type,
+ add_deduct_tax,
+ tax_amount,
+ ) in tax_details:
description = handle_html(description)
if description not in tax_columns and tax_amount:
# as description is text editor earlier and markup can break the column convention in reports
@@ -529,7 +534,9 @@ def get_tax_accounts(
if item_tax_amount:
tax_value = flt(item_tax_amount, tax_amount_precision)
tax_value = (
- tax_value * -1 if (doctype == "Purchase Invoice" and name in deducted_tax) else tax_value
+ tax_value * -1
+ if (doctype == "Purchase Invoice" and add_deduct_tax == "Deduct")
+ else tax_value
)
itemised_tax.setdefault(d.name, {})[description] = frappe._dict(
diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
index 7b1e48d817a..fd613da5694 100644
--- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
+++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py
@@ -228,6 +228,7 @@ def set_gl_entries_by_account(
{additional_conditions}
and posting_date <= %(to_date)s
and {based_on} is not null
+ and is_cancelled = 0
order by {based_on}, posting_date""".format(
additional_conditions="\n".join(additional_conditions), based_on=based_on
),
diff --git a/erpnext/accounts/report/sales_register/sales_register.py b/erpnext/accounts/report/sales_register/sales_register.py
index 34b3f032068..33bd3c74965 100644
--- a/erpnext/accounts/report/sales_register/sales_register.py
+++ b/erpnext/accounts/report/sales_register/sales_register.py
@@ -346,9 +346,13 @@ def get_columns(invoice_list, additional_table_columns):
def get_conditions(filters):
conditions = ""
+ accounting_dimensions = get_accounting_dimensions(as_list=False) or []
+ accounting_dimensions_list = [d.fieldname for d in accounting_dimensions]
+
if filters.get("company"):
conditions += " and company=%(company)s"
- if filters.get("customer"):
+
+ if filters.get("customer") and "customer" not in accounting_dimensions_list:
conditions += " and customer = %(customer)s"
if filters.get("from_date"):
@@ -359,32 +363,18 @@ def get_conditions(filters):
if filters.get("owner"):
conditions += " and owner = %(owner)s"
- if filters.get("mode_of_payment"):
- conditions += """ and exists(select name from `tabSales Invoice Payment`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Payment`.mode_of_payment, '') = %(mode_of_payment)s)"""
+ def get_sales_invoice_item_field_condition(field, table="Sales Invoice Item") -> str:
+ if not filters.get(field) or field in accounting_dimensions_list:
+ return ""
+ return f""" and exists(select name from `tab{table}`
+ where parent=`tabSales Invoice`.name
+ and ifnull(`tab{table}`.{field}, '') = %({field})s)"""
- if filters.get("cost_center"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.cost_center, '') = %(cost_center)s)"""
-
- if filters.get("warehouse"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.warehouse, '') = %(warehouse)s)"""
-
- if filters.get("brand"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.brand, '') = %(brand)s)"""
-
- if filters.get("item_group"):
- conditions += """ and exists(select name from `tabSales Invoice Item`
- where parent=`tabSales Invoice`.name
- and ifnull(`tabSales Invoice Item`.item_group, '') = %(item_group)s)"""
-
- accounting_dimensions = get_accounting_dimensions(as_list=False)
+ conditions += get_sales_invoice_item_field_condition("mode_of_payments", "Sales Invoice Payment")
+ conditions += get_sales_invoice_item_field_condition("cost_center")
+ conditions += get_sales_invoice_item_field_condition("warehouse")
+ conditions += get_sales_invoice_item_field_condition("brand")
+ conditions += get_sales_invoice_item_field_condition("item_group")
if accounting_dimensions:
common_condition = """
diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py
new file mode 100644
index 00000000000..609f74eadbd
--- /dev/null
+++ b/erpnext/accounts/test/test_reports.py
@@ -0,0 +1,49 @@
+import unittest
+from typing import List, Tuple
+
+from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report
+
+DEFAULT_FILTERS = {
+ "company": "_Test Company",
+ "from_date": "2010-01-01",
+ "to_date": "2030-01-01",
+ "period_start_date": "2010-01-01",
+ "period_end_date": "2030-01-01",
+}
+
+
+REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [
+ ("General Ledger", {"group_by": "Group by Voucher (Consolidated)"}),
+ ("General Ledger", {"group_by": "Group by Voucher (Consolidated)", "include_dimensions": 1}),
+ ("Accounts Payable", {"range1": 30, "range2": 60, "range3": 90, "range4": 120}),
+ ("Accounts Receivable", {"range1": 30, "range2": 60, "range3": 90, "range4": 120}),
+ ("Consolidated Financial Statement", {"report": "Balance Sheet"}),
+ ("Consolidated Financial Statement", {"report": "Profit and Loss Statement"}),
+ ("Consolidated Financial Statement", {"report": "Cash Flow"}),
+ ("Gross Profit", {"group_by": "Invoice"}),
+ ("Gross Profit", {"group_by": "Item Code"}),
+ ("Gross Profit", {"group_by": "Item Group"}),
+ ("Gross Profit", {"group_by": "Customer"}),
+ ("Gross Profit", {"group_by": "Customer Group"}),
+ ("Item-wise Sales Register", {}),
+ ("Item-wise Purchase Register", {}),
+ ("Sales Register", {}),
+ ("Sales Register", {"item_group": "All Item Groups"}),
+ ("Purchase Register", {}),
+]
+
+OPTIONAL_FILTERS = {}
+
+
+class TestReports(unittest.TestCase):
+ def test_execute_all_accounts_reports(self):
+ """Test that all script report in stock modules are executable with supported filters"""
+ for report, filter in REPORT_FILTER_TEST_CASES:
+ with self.subTest(report=report):
+ execute_script_report(
+ report_name=report,
+ module="Accounts",
+ filters=filter,
+ default_filters=DEFAULT_FILTERS,
+ optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
+ )
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index 79455bb1b4e..25929a744af 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -193,31 +193,31 @@ class TestAsset(AssetSetup):
def test_gle_made_by_asset_sale(self):
asset = create_asset(
calculate_depreciation=1,
- available_for_use_date="2020-06-06",
- purchase_date="2020-01-01",
+ available_for_use_date="2021-06-06",
+ purchase_date="2021-01-01",
expected_value_after_useful_life=10000,
total_number_of_depreciations=3,
frequency_of_depreciation=10,
- depreciation_start_date="2020-12-31",
+ depreciation_start_date="2021-12-31",
submit=1,
)
- post_depreciation_entries(date="2021-01-01")
+ post_depreciation_entries(date="2022-01-01")
si = make_sales_invoice(asset=asset.name, item_code="Macbook Pro", company="_Test Company")
si.customer = "_Test Customer"
- si.due_date = nowdate()
- si.get("items")[0].rate = 25000
- si.insert()
+ si.posting_date = getdate("2022-04-22")
+ si.due_date = getdate("2022-04-22")
+ si.get("items")[0].rate = 75000
si.submit()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
expected_gle = (
- ("_Test Accumulated Depreciations - _TC", 20490.2, 0.0),
+ ("_Test Accumulated Depreciations - _TC", 36082.31, 0.0),
("_Test Fixed Asset - _TC", 0.0, 100000.0),
- ("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0),
- ("Debtors - _TC", 25000.0, 0.0),
+ ("_Test Gain/Loss on Asset Disposal - _TC", 0.0, 11082.31),
+ ("Debtors - _TC", 75000.0, 0.0),
)
gle = frappe.db.sql(
@@ -229,7 +229,9 @@ class TestAsset(AssetSetup):
self.assertEqual(gle, expected_gle)
+ si.load_from_db()
si.cancel()
+
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
def test_expense_head(self):
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.js b/erpnext/buying/doctype/purchase_order/purchase_order.js
index 6e943c2832d..ed0f9002a21 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.js
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.js
@@ -43,8 +43,6 @@ frappe.ui.form.on("Purchase Order", {
erpnext.queries.setup_queries(frm, "Warehouse", function() {
return erpnext.queries.warehouse(frm.doc);
});
-
- erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype);
},
apply_tds: function(frm) {
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 7d41c84acfc..127d1094a3d 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -35,6 +35,7 @@ from erpnext.accounts.doctype.pricing_rule.utils import (
from erpnext.accounts.party import (
get_party_account,
get_party_account_currency,
+ get_party_gle_currency,
validate_party_frozen_disabled,
)
from erpnext.accounts.utils import get_account_currency, get_fiscal_years, validate_fiscal_year
@@ -169,6 +170,7 @@ class AccountsController(TransactionBase):
self.validate_party()
self.validate_currency()
+ self.validate_party_account_currency()
if self.doctype in ["Purchase Invoice", "Sales Invoice"]:
pos_check_field = "is_pos" if self.doctype == "Sales Invoice" else "is_paid"
@@ -1445,6 +1447,27 @@ class AccountsController(TransactionBase):
# at quotation / sales order level and we shouldn't stop someone
# from creating a sales invoice if sales order is already created
+ def validate_party_account_currency(self):
+ if self.doctype not in ("Sales Invoice", "Purchase Invoice"):
+ return
+
+ if self.is_opening == "Yes":
+ return
+
+ party_type, party = self.get_party()
+ party_gle_currency = get_party_gle_currency(party_type, party, self.company)
+ party_account = (
+ self.get("debit_to") if self.doctype == "Sales Invoice" else self.get("credit_to")
+ )
+ party_account_currency = get_account_currency(party_account)
+
+ if not party_gle_currency and (party_account_currency != self.currency):
+ frappe.throw(
+ _("Party Account {0} currency ({1}) and document currency ({2}) should be same").format(
+ frappe.bold(party_account), party_account_currency, self.currency
+ )
+ )
+
def delink_advance_entries(self, linked_doc_name):
total_allocated_amount = 0
for adv in self.advances:
@@ -2636,7 +2659,8 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
parent.update_reserved_qty_for_subcontract()
parent.create_raw_materials_supplied("supplied_items")
parent.save()
- else:
+ else: # Sales Order
+ parent.validate_warehouse()
parent.update_reserved_qty()
parent.update_project()
parent.update_prevdoc_status("submit")
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index 0ad39949b6d..1e7dcfb2b6d 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -316,7 +316,7 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
return data[0]
-def make_return_doc(doctype, source_name, target_doc=None):
+def make_return_doc(doctype: str, source_name: str, target_doc=None):
from frappe.model.mapper import get_mapped_doc
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
diff --git a/erpnext/erpnext_integrations/connectors/shopify_connection.py b/erpnext/erpnext_integrations/connectors/shopify_connection.py
index 4579a274ffa..f28afbcd83a 100644
--- a/erpnext/erpnext_integrations/connectors/shopify_connection.py
+++ b/erpnext/erpnext_integrations/connectors/shopify_connection.py
@@ -4,6 +4,7 @@ import frappe
from frappe import _
from frappe.utils import cint, cstr, flt, get_datetime, get_request_session, getdate, nowdate
+from erpnext import get_company_currency
from erpnext.erpnext_integrations.doctype.shopify_log.shopify_log import (
dump_request_data,
make_shopify_log,
@@ -143,6 +144,10 @@ def create_sales_order(shopify_order, shopify_settings, company=None):
"taxes": get_order_taxes(shopify_order, shopify_settings),
"apply_discount_on": "Grand Total",
"discount_amount": get_discounted_amount(shopify_order),
+ "currency": frappe.get_value(
+ "Customer", customer or shopify_settings.default_customer, "default_currency"
+ )
+ or get_company_currency(shopify_settings.company),
}
)
@@ -178,6 +183,7 @@ def create_sales_invoice(shopify_order, shopify_settings, so, old_order_sync=Fal
si.set_posting_time = 1
si.posting_date = posting_date
si.due_date = posting_date
+ si.currency = so.currency
si.naming_series = shopify_settings.sales_invoice_series or "SI-Shopify-"
si.flags.ignore_mandatory = True
set_cost_center(si.items, shopify_settings.cost_center)
diff --git a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py
index 7cc45d2115f..47d6d438b06 100644
--- a/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py
+++ b/erpnext/erpnext_integrations/doctype/shopify_settings/test_shopify_settings.py
@@ -58,6 +58,7 @@ class ShopifySettings(unittest.TestCase):
"warehouse": "_Test Warehouse - _TC",
"cash_bank_account": "Cash - _TC",
"account": "Cash - _TC",
+ "company": "_Test Company",
"customer_group": "_Test Customer Group",
"cost_center": "Main - _TC",
"taxes": [{"shopify_tax": "International Shipping", "tax_account": "Legal Expenses - _TC"}],
diff --git a/erpnext/healthcare/doctype/lab_test/test_lab_test.py b/erpnext/healthcare/doctype/lab_test/test_lab_test.py
index 06c02d1ea07..c08820f36be 100644
--- a/erpnext/healthcare/doctype/lab_test/test_lab_test.py
+++ b/erpnext/healthcare/doctype/lab_test/test_lab_test.py
@@ -164,6 +164,7 @@ def create_sales_invoice():
sales_invoice.customer = frappe.db.get_value("Patient", patient, "customer")
sales_invoice.due_date = getdate()
sales_invoice.company = "_Test Company"
+ sales_invoice.currency = "INR"
sales_invoice.debit_to = get_receivable_account("_Test Company")
tests = [insulin_resistance_template, blood_test_template]
diff --git a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
index b6e30060437..0d98fff04ff 100755
--- a/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
+++ b/erpnext/healthcare/doctype/patient_appointment/patient_appointment.py
@@ -12,6 +12,7 @@ from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import flt, get_link_to_form, get_time, getdate
+from erpnext import get_company_currency
from erpnext.healthcare.doctype.healthcare_settings.healthcare_settings import (
get_income_account,
get_receivable_account,
@@ -252,6 +253,10 @@ def create_sales_invoice(appointment_doc):
sales_invoice = frappe.new_doc("Sales Invoice")
sales_invoice.patient = appointment_doc.patient
sales_invoice.customer = frappe.get_value("Patient", appointment_doc.patient, "customer")
+ sales_invoice.currency = frappe.get_value(
+ "Customer", sales_invoice.customer, "default_currency"
+ ) or get_company_currency(appointment_doc.company)
+
sales_invoice.appointment = appointment_doc.name
sales_invoice.due_date = getdate()
sales_invoice.company = appointment_doc.company
diff --git a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py
index 048547a9322..05e6b9cfe0d 100644
--- a/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py
+++ b/erpnext/healthcare/doctype/patient_appointment/test_patient_appointment.py
@@ -379,6 +379,7 @@ def create_patient(
patient.mobile = mobile
patient.email = email
patient.customer = customer
+ patient.default_currency = "INR"
patient.invite_user = create_user
patient.save(ignore_permissions=True)
diff --git a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py
index 44f0a9785c4..6cb2a24e6af 100644
--- a/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py
+++ b/erpnext/healthcare/doctype/therapy_plan/therapy_plan.py
@@ -6,6 +6,8 @@ import frappe
from frappe.model.document import Document
from frappe.utils import flt
+from erpnext import get_company_currency
+
class TherapyPlan(Document):
def validate(self):
@@ -72,6 +74,9 @@ def make_sales_invoice(reference_name, patient, company, therapy_plan_template):
si.company = company
si.patient = patient
si.customer = frappe.db.get_value("Patient", patient, "customer")
+ si.currency = frappe.get_value(
+ "Customer", si.customer, "default_currency"
+ ) or get_company_currency(si.company)
item = frappe.db.get_value("Therapy Plan Template", therapy_plan_template, "linked_item")
price_list, price_list_currency = frappe.db.get_values(
diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index 058bc93d72a..677a84100d0 100644
--- a/erpnext/hr/doctype/attendance/test_attendance.py
+++ b/erpnext/hr/doctype/attendance/test_attendance.py
@@ -3,7 +3,15 @@
import frappe
from frappe.tests.utils import FrappeTestCase
-from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
+from frappe.utils import (
+ add_days,
+ add_months,
+ get_last_day,
+ get_year_ending,
+ get_year_start,
+ getdate,
+ nowdate,
+)
from erpnext.hr.doctype.attendance.attendance import (
get_month_map,
@@ -35,63 +43,64 @@ class TestAttendance(FrappeTestCase):
self.assertEqual(attendance, fetch_attendance)
def test_unmarked_days(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ attendance_date = add_days(first_sunday, 1)
employee = make_employee(
- "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
+ "test_unmarked_days@example.com", date_of_joining=add_days(attendance_date, -1)
)
frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
- mark_attendance(employee, first_day, "Present")
- month_name = get_month_name(first_day)
+ mark_attendance(employee, attendance_date, "Present")
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day
- self.assertNotIn(first_day, unmarked_days)
+ self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked
- self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+ self.assertIn(getdate(add_days(attendance_date, 1)), unmarked_days)
# holiday considered in unmarked days
self.assertIn(first_sunday, unmarked_days)
def test_unmarked_days_excluding_holidays(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ attendance_date = add_days(first_sunday, 1)
employee = make_employee(
- "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1)
+ "test_unmarked_days@example.com", date_of_joining=add_days(attendance_date, -1)
)
frappe.db.delete("Attendance", {"employee": employee})
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- first_sunday = get_first_sunday(self.holiday_list, for_date=first_day)
- mark_attendance(employee, first_day, "Present")
- month_name = get_month_name(first_day)
+ mark_attendance(employee, attendance_date, "Present")
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True)
unmarked_days = [getdate(date) for date in unmarked_days]
# attendance already marked for the day
- self.assertNotIn(first_day, unmarked_days)
+ self.assertNotIn(attendance_date, unmarked_days)
# attendance unmarked
- self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+ self.assertIn(getdate(add_days(attendance_date, 1)), unmarked_days)
# holidays not considered in unmarked days
self.assertNotIn(first_sunday, unmarked_days)
def test_unmarked_days_as_per_joining_and_relieving_dates(self):
- now = now_datetime()
- previous_month = now.month - 1
- first_day = now.replace(day=1).replace(month=previous_month).date()
+ first_sunday = get_first_sunday(
+ self.holiday_list, for_date=get_last_day(add_months(getdate(), -1))
+ )
+ date = add_days(first_sunday, 1)
- doj = add_days(first_day, 1)
- relieving_date = add_days(first_day, 5)
+ doj = add_days(date, 1)
+ relieving_date = add_days(date, 5)
employee = make_employee(
"test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date
)
@@ -99,9 +108,9 @@ class TestAttendance(FrappeTestCase):
frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list)
- attendance_date = add_days(first_day, 2)
+ attendance_date = add_days(date, 2)
mark_attendance(employee, attendance_date, "Present")
- month_name = get_month_name(first_day)
+ month_name = get_month_name(attendance_date)
unmarked_days = get_unmarked_days(employee, month_name)
unmarked_days = [getdate(date) for date in unmarked_days]
diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 1829bc4f2fc..f09d7ff75a7 100644
--- a/erpnext/hr/doctype/employee/employee_reminders.py
+++ b/erpnext/hr/doctype/employee/employee_reminders.py
@@ -230,7 +230,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
persons_name = anniversary_person
# Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]["date_of_joining"].year
- anniversary_person += f" completed {completed_years} year(s)"
+ anniversary_person += f" completed {get_pluralized_years(completed_years)}"
else:
person_names_with_years = []
names = []
@@ -239,7 +239,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text)
# Number of years completed at the company
completed_years = getdate().year - person["date_of_joining"].year
- person_text += f" completed {completed_years} year(s)"
+ person_text += f" completed {get_pluralized_years(completed_years)}"
person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
@@ -254,6 +254,12 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
return reminder_text, message
+def get_pluralized_years(years):
+ if years == 1:
+ return "1 year"
+ return f"{years} years"
+
+
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail(
recipients=recipients,
diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.json b/erpnext/hr/doctype/employee_advance/employee_advance.json
index 3a561216cca..8b2eea11337 100644
--- a/erpnext/hr/doctype/employee_advance/employee_advance.json
+++ b/erpnext/hr/doctype/employee_advance/employee_advance.json
@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"autoname": "naming_series:",
- "creation": "2017-10-09 14:26:29.612365",
+ "creation": "2022-01-17 18:36:51.450395",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -121,7 +121,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
- "options": "Draft\nPaid\nUnpaid\nClaimed\nCancelled",
+ "options": "Draft\nPaid\nUnpaid\nClaimed\nReturned\nPartly Claimed and Returned\nCancelled",
"read_only": 1
},
{
@@ -200,7 +200,7 @@
],
"is_submittable": 1,
"links": [],
- "modified": "2021-09-11 18:38:38.617478",
+ "modified": "2022-05-23 19:33:52.345823",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Advance",
@@ -236,5 +236,6 @@
"search_fields": "employee,employee_name",
"sort_field": "modified",
"sort_order": "DESC",
+ "title_field": "employee_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_advance/employee_advance.py b/erpnext/hr/doctype/employee_advance/employee_advance.py
index 3d4023d3195..c1876b11757 100644
--- a/erpnext/hr/doctype/employee_advance/employee_advance.py
+++ b/erpnext/hr/doctype/employee_advance/employee_advance.py
@@ -29,19 +29,43 @@ class EmployeeAdvance(Document):
def on_cancel(self):
self.ignore_linked_doctypes = "GL Entry"
+ self.set_status(update=True)
+
+ def set_status(self, update=False):
+ precision = self.precision("paid_amount")
+ total_amount = flt(flt(self.claimed_amount) + flt(self.return_amount), precision)
+ status = None
- def set_status(self):
if self.docstatus == 0:
- self.status = "Draft"
- if self.docstatus == 1:
- if self.claimed_amount and flt(self.claimed_amount) == flt(self.paid_amount):
- self.status = "Claimed"
- elif self.paid_amount and self.advance_amount == flt(self.paid_amount):
- self.status = "Paid"
+ status = "Draft"
+ elif self.docstatus == 1:
+ if flt(self.claimed_amount) > 0 and flt(self.claimed_amount, precision) == flt(
+ self.paid_amount, precision
+ ):
+ status = "Claimed"
+ elif flt(self.return_amount) > 0 and flt(self.return_amount, precision) == flt(
+ self.paid_amount, precision
+ ):
+ status = "Returned"
+ elif (
+ flt(self.claimed_amount) > 0
+ and (flt(self.return_amount) > 0)
+ and total_amount == flt(self.paid_amount, precision)
+ ):
+ status = "Partly Claimed and Returned"
+ elif flt(self.paid_amount) > 0 and flt(self.advance_amount, precision) == flt(
+ self.paid_amount, precision
+ ):
+ status = "Paid"
else:
- self.status = "Unpaid"
+ status = "Unpaid"
elif self.docstatus == 2:
- self.status = "Cancelled"
+ status = "Cancelled"
+
+ if update:
+ self.db_set("status", status)
+ else:
+ self.status = status
def set_total_advance_paid(self):
gle = frappe.qb.DocType("GL Entry")
@@ -89,8 +113,7 @@ class EmployeeAdvance(Document):
self.db_set("paid_amount", paid_amount)
self.db_set("return_amount", return_amount)
- self.set_status()
- frappe.db.set_value("Employee Advance", self.name, "status", self.status)
+ self.set_status(update=True)
def update_claimed_amount(self):
claimed_amount = (
@@ -112,8 +135,7 @@ class EmployeeAdvance(Document):
frappe.db.set_value("Employee Advance", self.name, "claimed_amount", flt(claimed_amount))
self.reload()
- self.set_status()
- frappe.db.set_value("Employee Advance", self.name, "status", self.status)
+ self.set_status(update=True)
@frappe.whitelist()
@@ -265,6 +287,7 @@ def make_return_entry(
"party_type": "Employee",
"party": employee,
"is_advance": "Yes",
+ "cost_center": erpnext.get_default_cost_center(company),
},
)
@@ -282,6 +305,7 @@ def make_return_entry(
"account_currency": bank_cash_account.account_currency,
"account_type": bank_cash_account.account_type,
"exchange_rate": flt(exchange_rate) if bank_cash_account.account_currency == currency else 1,
+ "cost_center": erpnext.get_default_cost_center(company),
},
)
diff --git a/erpnext/hr/doctype/employee_advance/employee_advance_list.js b/erpnext/hr/doctype/employee_advance/employee_advance_list.js
new file mode 100644
index 00000000000..433669a71f3
--- /dev/null
+++ b/erpnext/hr/doctype/employee_advance/employee_advance_list.js
@@ -0,0 +1,15 @@
+frappe.listview_settings["Employee Advance"] = {
+ get_indicator: function(doc) {
+ let status_color = {
+ "Draft": "red",
+ "Submitted": "blue",
+ "Cancelled": "red",
+ "Paid": "green",
+ "Unpaid": "orange",
+ "Claimed": "blue",
+ "Returned": "gray",
+ "Partly Claimed and Returned": "yellow"
+ };
+ return [__(doc.status), status_color[doc.status], "status,=,"+doc.status];
+ }
+};
\ No newline at end of file
diff --git a/erpnext/hr/doctype/employee_advance/test_employee_advance.py b/erpnext/hr/doctype/employee_advance/test_employee_advance.py
index 9b006ffcffe..44d68c94833 100644
--- a/erpnext/hr/doctype/employee_advance/test_employee_advance.py
+++ b/erpnext/hr/doctype/employee_advance/test_employee_advance.py
@@ -12,13 +12,21 @@ from erpnext.hr.doctype.employee_advance.employee_advance import (
EmployeeAdvanceOverPayment,
create_return_through_additional_salary,
make_bank_entry,
+ make_return_entry,
)
from erpnext.hr.doctype.expense_claim.expense_claim import get_advances
+from erpnext.hr.doctype.expense_claim.test_expense_claim import (
+ get_payable_account,
+ make_expense_claim,
+)
from erpnext.payroll.doctype.salary_component.test_salary_component import create_salary_component
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
class TestEmployeeAdvance(unittest.TestCase):
+ def setUp(self):
+ frappe.db.delete("Employee Advance")
+
def test_paid_amount_and_status(self):
employee_name = make_employee("_T@employe.advance")
advance = make_employee_advance(employee_name)
@@ -53,9 +61,108 @@ class TestEmployeeAdvance(unittest.TestCase):
self.assertEqual(advance.paid_amount, 0)
self.assertEqual(advance.status, "Unpaid")
+ advance.cancel()
+ advance.reload()
+ self.assertEqual(advance.status, "Cancelled")
+
+ def test_claimed_status(self):
+ # CLAIMED Status check, full amount claimed
+ payable_account = get_payable_account("_Test Company")
+ claim = make_expense_claim(
+ payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True
+ )
+
+ advance = make_employee_advance(claim.employee)
+ pe = make_payment_entry(advance)
+ pe.submit()
+
+ claim = get_advances_for_claim(claim, advance.name)
+ claim.save()
+ claim.submit()
+
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 1000)
+ self.assertEqual(advance.status, "Claimed")
+
+ # advance should not be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name not in advances)
+
+ # cancel claim; status should be Paid
+ claim.cancel()
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 0)
+ self.assertEqual(advance.status, "Paid")
+
+ def test_partly_claimed_and_returned_status(self):
+ payable_account = get_payable_account("_Test Company")
+ claim = make_expense_claim(
+ payable_account, 1000, 1000, "_Test Company", "Travel Expenses - _TC", do_not_submit=True
+ )
+
+ advance = make_employee_advance(claim.employee)
+ pe = make_payment_entry(advance)
+ pe.submit()
+
+ # PARTLY CLAIMED AND RETURNED status check
+ # 500 Claimed, 500 Returned
+ claim = make_expense_claim(
+ payable_account, 500, 500, "_Test Company", "Travel Expenses - _TC", do_not_submit=True
+ )
+
+ advance = make_employee_advance(claim.employee)
+ pe = make_payment_entry(advance)
+ pe.submit()
+
+ claim = get_advances_for_claim(claim, advance.name, amount=500)
+ claim.save()
+ claim.submit()
+
+ advance.reload()
+ self.assertEqual(advance.claimed_amount, 500)
+ self.assertEqual(advance.status, "Paid")
+
+ entry = make_return_entry(
+ employee=advance.employee,
+ company=advance.company,
+ employee_advance_name=advance.name,
+ return_amount=flt(advance.paid_amount - advance.claimed_amount),
+ advance_account=advance.advance_account,
+ mode_of_payment=advance.mode_of_payment,
+ currency=advance.currency,
+ exchange_rate=advance.exchange_rate,
+ )
+
+ entry = frappe.get_doc(entry)
+ entry.insert()
+ entry.submit()
+
+ advance.reload()
+ self.assertEqual(advance.return_amount, 500)
+ self.assertEqual(advance.status, "Partly Claimed and Returned")
+
+ # advance should not be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name not in advances)
+
+ # Cancel return entry; status should change to PAID
+ entry.cancel()
+ advance.reload()
+ self.assertEqual(advance.return_amount, 0)
+ self.assertEqual(advance.status, "Paid")
+
+ # advance should be shown in claims
+ advances = get_advances(claim.employee)
+ advances = [entry.name for entry in advances]
+ self.assertTrue(advance.name in advances)
+
def test_repay_unclaimed_amount_from_salary(self):
employee_name = make_employee("_T@employe.advance")
advance = make_employee_advance(employee_name, {"repay_unclaimed_amount_from_salary": 1})
+ pe = make_payment_entry(advance)
+ pe.submit()
args = {"type": "Deduction"}
create_salary_component("Advance Salary - Deduction", **args)
@@ -85,11 +192,13 @@ class TestEmployeeAdvance(unittest.TestCase):
advance.reload()
self.assertEqual(advance.return_amount, 1000)
+ self.assertEqual(advance.status, "Returned")
# update advance return amount on additional salary cancellation
additional_salary.cancel()
advance.reload()
self.assertEqual(advance.return_amount, 700)
+ self.assertEqual(advance.status, "Paid")
def tearDown(self):
frappe.db.rollback()
diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.js b/erpnext/hr/doctype/expense_claim/expense_claim.js
index 047945787d7..af80b63845e 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.js
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.js
@@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", {
['docstatus', '=', 1],
['employee', '=', frm.doc.employee],
['paid_amount', '>', 0],
- ['status', '!=', 'Claimed']
+ ['status', 'not in', ['Claimed', 'Returned', 'Partly Claimed and Returned']]
]
};
});
diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.py b/erpnext/hr/doctype/expense_claim/expense_claim.py
index 311a1eb81c8..89d86c1bc7c 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.py
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.py
@@ -414,25 +414,27 @@ def get_expense_claim_account(expense_claim_type, company):
@frappe.whitelist()
def get_advances(employee, advance_id=None):
+ advance = frappe.qb.DocType("Employee Advance")
+
+ query = frappe.qb.from_(advance).select(
+ advance.name,
+ advance.posting_date,
+ advance.paid_amount,
+ advance.claimed_amount,
+ advance.advance_account,
+ )
+
if not advance_id:
- condition = "docstatus=1 and employee={0} and paid_amount > 0 and paid_amount > claimed_amount + return_amount".format(
- frappe.db.escape(employee)
+ query = query.where(
+ (advance.docstatus == 1)
+ & (advance.employee == employee)
+ & (advance.paid_amount > 0)
+ & (advance.status.notin(["Claimed", "Returned", "Partly Claimed and Returned"]))
)
else:
- condition = "name={0}".format(frappe.db.escape(advance_id))
+ query = query.where(advance.name == advance_id)
- return frappe.db.sql(
- """
- select
- name, posting_date, paid_amount, claimed_amount, advance_account
- from
- `tabEmployee Advance`
- where {0}
- """.format(
- condition
- ),
- as_dict=1,
- )
+ return query.run(as_dict=True)
@frappe.whitelist()
diff --git a/erpnext/hr/doctype/job_opening/job_opening.py b/erpnext/hr/doctype/job_opening/job_opening.py
index c71407d71d4..ce7caa33c68 100644
--- a/erpnext/hr/doctype/job_opening/job_opening.py
+++ b/erpnext/hr/doctype/job_opening/job_opening.py
@@ -6,6 +6,7 @@
import frappe
from frappe import _
+from frappe.utils import get_link_to_form
from frappe.website.website_generator import WebsiteGenerator
from erpnext.hr.doctype.staffing_plan.staffing_plan import (
@@ -33,26 +34,32 @@ class JobOpening(WebsiteGenerator):
self.staffing_plan = staffing_plan[0].name
self.planned_vacancies = staffing_plan[0].vacancies
elif not self.planned_vacancies:
- planned_vacancies = frappe.db.sql(
- """
- select vacancies from `tabStaffing Plan Detail`
- where parent=%s and designation=%s""",
- (self.staffing_plan, self.designation),
+ self.planned_vacancies = frappe.db.get_value(
+ "Staffing Plan Detail",
+ {"parent": self.staffing_plan, "designation": self.designation},
+ "vacancies",
)
- self.planned_vacancies = planned_vacancies[0][0] if planned_vacancies else None
if self.staffing_plan and self.planned_vacancies:
staffing_plan_company = frappe.db.get_value("Staffing Plan", self.staffing_plan, "company")
- lft, rgt = frappe.get_cached_value("Company", staffing_plan_company, ["lft", "rgt"])
- designation_counts = get_designation_counts(self.designation, self.company)
+ designation_counts = get_designation_counts(self.designation, self.company, self.name)
current_count = designation_counts["employee_count"] + designation_counts["job_openings"]
- if self.planned_vacancies <= current_count:
+ number_of_positions = frappe.db.get_value(
+ "Staffing Plan Detail",
+ {"parent": self.staffing_plan, "designation": self.designation},
+ "number_of_positions",
+ )
+
+ if number_of_positions <= current_count:
frappe.throw(
_(
- "Job Openings for designation {0} already open or hiring completed as per Staffing Plan {1}"
- ).format(self.designation, self.staffing_plan)
+ "Job Openings for the designation {0} are already open or the hiring is complete as per the Staffing Plan {1}"
+ ).format(
+ frappe.bold(self.designation), get_link_to_form("Staffing Plan", self.staffing_plan)
+ ),
+ title=_("Vacancies fulfilled"),
)
def get_context(self, context):
diff --git a/erpnext/hr/doctype/job_opening/test_job_opening.py b/erpnext/hr/doctype/job_opening/test_job_opening.py
index a72a6eb3384..e991054f62d 100644
--- a/erpnext/hr/doctype/job_opening/test_job_opening.py
+++ b/erpnext/hr/doctype/job_opening/test_job_opening.py
@@ -3,8 +3,77 @@
import unittest
-# test_records = frappe.get_test_records('Job Opening')
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, getdate
+
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.staffing_plan.test_staffing_plan import make_company
-class TestJobOpening(unittest.TestCase):
- pass
+class TestJobOpening(FrappeTestCase):
+ def setUp(self):
+ frappe.db.delete("Staffing Plan")
+ frappe.db.delete("Staffing Plan Detail")
+ frappe.db.delete("Job Opening")
+
+ make_company("_Test Opening Company", "_TOC")
+ frappe.db.delete("Employee", {"company": "_Test Opening Company"})
+
+ def test_vacancies_fulfilled(self):
+ make_employee(
+ "test_job_opening@example.com", company="_Test Opening Company", designation="Designer"
+ )
+
+ staffing_plan = frappe.get_doc(
+ {
+ "doctype": "Staffing Plan",
+ "company": "_Test Opening Company",
+ "name": "Test",
+ "from_date": getdate(),
+ "to_date": add_days(getdate(), 10),
+ }
+ )
+
+ staffing_plan.append(
+ "staffing_details",
+ {"designation": "Designer", "vacancies": 1, "estimated_cost_per_position": 50000},
+ )
+ staffing_plan.insert()
+ staffing_plan.submit()
+
+ self.assertEqual(staffing_plan.staffing_details[0].number_of_positions, 2)
+
+ # allows creating 1 job opening as per vacancy
+ opening_1 = get_job_opening()
+ opening_1.insert()
+
+ # vacancies as per staffing plan already fulfilled via job opening and existing employee count
+ opening_2 = get_job_opening(job_title="Designer New")
+ self.assertRaises(frappe.ValidationError, opening_2.insert)
+
+ # allows updating existing job opening
+ opening_1.status = "Closed"
+ opening_1.save()
+
+
+def get_job_opening(**args):
+ args = frappe._dict(args)
+
+ opening = frappe.db.exists("Job Opening", {"job_title": args.job_title or "Designer"})
+ if opening:
+ return frappe.get_doc("Job Opening", opening)
+
+ opening = frappe.get_doc(
+ {
+ "doctype": "Job Opening",
+ "job_title": "Designer",
+ "designation": "Designer",
+ "company": "_Test Opening Company",
+ "status": "Open",
+ }
+ )
+
+ opening.update(args)
+
+ return opening
diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js
index 85997a4087f..ee00e6719c0 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.js
+++ b/erpnext/hr/doctype/leave_application/leave_application.js
@@ -173,7 +173,7 @@ frappe.ui.form.on("Leave Application", {
date: frm.doc.from_date,
to_date: frm.doc.to_date,
leave_type: frm.doc.leave_type,
- consider_all_leaves_in_the_allocation_period: true
+ consider_all_leaves_in_the_allocation_period: 1
},
callback: function (r) {
if (!r.exc && r.message) {
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index e6fc2e6fc06..7edcd516fcb 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -88,7 +88,7 @@ class LeaveApplication(Document):
share_doc_with_approver(self, self.leave_approver)
def on_submit(self):
- if self.status == "Open":
+ if self.status in ["Open", "Cancelled"]:
frappe.throw(
_("Only Leave Applications with status 'Approved' and 'Rejected' can be submitted")
)
@@ -758,22 +758,6 @@ def get_leave_details(employee, date):
leave_allocation = {}
for d in allocation_records:
allocation = allocation_records.get(d, frappe._dict())
-
- total_allocated_leaves = (
- frappe.db.get_value(
- "Leave Allocation",
- {
- "from_date": ("<=", date),
- "to_date": (">=", date),
- "employee": employee,
- "leave_type": allocation.leave_type,
- "docstatus": 1,
- },
- "SUM(total_leaves_allocated)",
- )
- or 0
- )
-
remaining_leaves = get_leave_balance_on(
employee, d, date, to_date=allocation.to_date, consider_all_leaves_in_the_allocation_period=True
)
@@ -783,10 +767,11 @@ def get_leave_details(employee, date):
leaves_pending = get_leaves_pending_approval_for_period(
employee, d, allocation.from_date, end_date
)
+ expired_leaves = allocation.total_leaves_allocated - (remaining_leaves + leaves_taken)
leave_allocation[d] = {
- "total_leaves": total_allocated_leaves,
- "expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken),
+ "total_leaves": allocation.total_leaves_allocated,
+ "expired_leaves": expired_leaves if expired_leaves > 0 else 0,
"leaves_taken": leaves_taken,
"leaves_pending_approval": leaves_pending,
"remaining_leaves": remaining_leaves,
@@ -831,7 +816,7 @@ def get_leave_balance_on(
allocation_records = get_leave_allocation_records(employee, date, leave_type)
allocation = allocation_records.get(leave_type, frappe._dict())
- end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date
+ end_date = allocation.to_date if cint(consider_all_leaves_in_the_allocation_period) else date
cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date)
leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date)
@@ -1118,7 +1103,7 @@ def add_leaves(events, start, end, filter_conditions=None):
WHERE
from_date <= %(end)s AND to_date >= %(start)s <= to_date
AND docstatus < 2
- AND status != 'Rejected'
+ AND status in ('Approved', 'Open')
"""
if conditions:
@@ -1202,24 +1187,32 @@ def get_mandatory_approval(doctype):
def get_approved_leaves_for_period(employee, leave_type, from_date, to_date):
- query = """
- select employee, leave_type, from_date, to_date, total_leave_days
- from `tabLeave Application`
- where employee=%(employee)s
- and docstatus=1
- and (from_date between %(from_date)s and %(to_date)s
- or to_date between %(from_date)s and %(to_date)s
- or (from_date < %(from_date)s and to_date > %(to_date)s))
- """
- if leave_type:
- query += "and leave_type=%(leave_type)s"
-
- leave_applications = frappe.db.sql(
- query,
- {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type},
- as_dict=1,
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .select(
+ LeaveApplication.employee,
+ LeaveApplication.leave_type,
+ LeaveApplication.from_date,
+ LeaveApplication.to_date,
+ LeaveApplication.total_leave_days,
+ )
+ .where(
+ (LeaveApplication.employee == employee)
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (
+ (LeaveApplication.from_date.between(from_date, to_date))
+ | (LeaveApplication.to_date.between(from_date, to_date))
+ | ((LeaveApplication.from_date < from_date) & (LeaveApplication.to_date > to_date))
+ )
+ )
)
+ if leave_type:
+ query = query.where(LeaveApplication.leave_type == leave_type)
+ leave_applications = query.run(as_dict=True)
+
leave_days = 0
for leave_app in leave_applications:
if leave_app.from_date >= getdate(from_date) and leave_app.to_date <= getdate(to_date):
diff --git a/erpnext/hr/doctype/leave_application/leave_application_list.js b/erpnext/hr/doctype/leave_application/leave_application_list.js
index a3c03b1bec7..157271a5a0e 100644
--- a/erpnext/hr/doctype/leave_application/leave_application_list.js
+++ b/erpnext/hr/doctype/leave_application/leave_application_list.js
@@ -1,13 +1,14 @@
-frappe.listview_settings['Leave Application'] = {
+frappe.listview_settings["Leave Application"] = {
add_fields: ["leave_type", "employee", "employee_name", "total_leave_days", "from_date", "to_date"],
has_indicator_for_draft: 1,
get_indicator: function (doc) {
- if (doc.status === "Approved") {
- return [__("Approved"), "green", "status,=,Approved"];
- } else if (doc.status === "Rejected") {
- return [__("Rejected"), "red", "status,=,Rejected"];
- } else {
- return [__("Open"), "red", "status,=,Open"];
- }
+ let status_color = {
+ "Approved": "green",
+ "Rejected": "red",
+ "Open": "orange",
+ "Cancelled": "red",
+ "Submitted": "blue"
+ };
+ return [__(doc.status), status_color[doc.status], "status,=," + doc.status];
}
};
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 60c0491a509..99e001ab27a 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -76,7 +76,14 @@ _test_records = [
class TestLeaveApplication(unittest.TestCase):
def setUp(self):
- for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
+ for dt in [
+ "Leave Application",
+ "Leave Allocation",
+ "Salary Slip",
+ "Leave Ledger Entry",
+ "Leave Period",
+ "Leave Policy Assignment",
+ ]:
frappe.db.delete(dt)
frappe.set_user("Administrator")
@@ -702,58 +709,24 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(details.leave_balance, 30)
def test_earned_leaves_creation(self):
-
- frappe.db.sql("""delete from `tabLeave Period`""")
- frappe.db.sql("""delete from `tabLeave Policy Assignment`""")
- frappe.db.sql("""delete from `tabLeave Allocation`""")
- frappe.db.sql("""delete from `tabLeave Ledger Entry`""")
+ from erpnext.hr.utils import allocate_earned_leaves
leave_period = get_leave_period()
employee = get_employee()
leave_type = "Test Earned Leave Type"
- frappe.delete_doc_if_exists("Leave Type", "Test Earned Leave Type", force=1)
- frappe.get_doc(
- dict(
- leave_type_name=leave_type,
- doctype="Leave Type",
- is_earned_leave=1,
- earned_leave_frequency="Monthly",
- rounding=0.5,
- max_leaves_allowed=6,
- )
- ).insert()
+ make_policy_assignment(employee, leave_type, leave_period)
- leave_policy = frappe.get_doc(
- {
- "doctype": "Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
- }
- ).insert()
-
- data = {
- "assignment_based_on": "Leave Period",
- "leave_policy": leave_policy.name,
- "leave_period": leave_period.name,
- }
-
- leave_policy_assignments = create_assignment_for_multiple_employees(
- [employee.name], frappe._dict(data)
- )
-
- from erpnext.hr.utils import allocate_earned_leaves
-
- i = 0
- while i < 14:
+ for i in range(0, 14):
allocate_earned_leaves()
- i += 1
+
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
# validate earned leaves creation without maximum leaves
frappe.db.set_value("Leave Type", leave_type, "max_leaves_allowed", 0)
- i = 0
- while i < 6:
+
+ for i in range(0, 6):
allocate_earned_leaves()
- i += 1
+
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
# test to not consider current leave in leave balance while submitting
@@ -969,6 +942,54 @@ class TestLeaveApplication(unittest.TestCase):
self.assertEqual(leave_allocation["leaves_pending_approval"], 1)
self.assertEqual(leave_allocation["remaining_leaves"], 26)
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_get_earned_leave_details_for_dashboard(self):
+ from erpnext.hr.utils import allocate_earned_leaves
+
+ leave_period = get_leave_period()
+ employee = get_employee()
+ leave_type = "Test Earned Leave Type"
+ leave_policy_assignments = make_policy_assignment(employee, leave_type, leave_period)
+ allocation = frappe.db.get_value(
+ "Leave Allocation",
+ {"leave_policy_assignment": leave_policy_assignments[0]},
+ "name",
+ )
+ allocation = frappe.get_doc("Leave Allocation", allocation)
+ allocation.new_leaves_allocated = 2
+ allocation.save()
+
+ for i in range(0, 6):
+ allocate_earned_leaves()
+
+ first_sunday = get_first_sunday(self.holiday_list)
+ make_leave_application(
+ employee.name, add_days(first_sunday, 1), add_days(first_sunday, 1), leave_type
+ )
+
+ details = get_leave_details(employee.name, allocation.from_date)
+ leave_allocation = details["leave_allocation"][leave_type]
+ expected = {
+ "total_leaves": 2.0,
+ "expired_leaves": 0.0,
+ "leaves_taken": 1.0,
+ "leaves_pending_approval": 0.0,
+ "remaining_leaves": 1.0,
+ }
+ self.assertEqual(leave_allocation, expected)
+
+ details = get_leave_details(employee.name, getdate())
+ leave_allocation = details["leave_allocation"][leave_type]
+
+ expected = {
+ "total_leaves": 5.0,
+ "expired_leaves": 0.0,
+ "leaves_taken": 1.0,
+ "leaves_pending_approval": 0.0,
+ "remaining_leaves": 4.0,
+ }
+ self.assertEqual(leave_allocation, expected)
+
@set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_leave_allocation_records(self):
employee = get_employee()
@@ -1099,3 +1120,36 @@ def get_first_sunday(holiday_list, for_date=None):
)[0][0]
return first_sunday
+
+
+def make_policy_assignment(employee, leave_type, leave_period):
+ frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
+ frappe.get_doc(
+ dict(
+ leave_type_name=leave_type,
+ doctype="Leave Type",
+ is_earned_leave=1,
+ earned_leave_frequency="Monthly",
+ rounding=0.5,
+ max_leaves_allowed=6,
+ )
+ ).insert()
+
+ leave_policy = frappe.get_doc(
+ {
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type, "annual_allocation": 6}],
+ }
+ ).insert()
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name,
+ }
+
+ leave_policy_assignments = create_assignment_for_multiple_employees(
+ [employee.name], frappe._dict(data)
+ )
+ return leave_policy_assignments
diff --git a/erpnext/hr/doctype/leave_encashment/leave_encashment.py b/erpnext/hr/doctype/leave_encashment/leave_encashment.py
index 0f655e3e0fc..7c0f0db1975 100644
--- a/erpnext/hr/doctype/leave_encashment/leave_encashment.py
+++ b/erpnext/hr/doctype/leave_encashment/leave_encashment.py
@@ -7,7 +7,7 @@ from frappe import _
from frappe.model.document import Document
from frappe.utils import getdate, nowdate
-from erpnext.hr.doctype.leave_allocation.leave_allocation import get_unused_leaves
+from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry
from erpnext.hr.utils import set_employee_name, validate_active_employee
from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import (
@@ -107,7 +107,10 @@ class LeaveEncashment(Document):
self.leave_balance = (
allocation.total_leaves_allocated
- allocation.carry_forwarded_leaves_count
- - get_unused_leaves(self.employee, self.leave_type, allocation.from_date, self.encashment_date)
+ # adding this because the function returns a -ve number
+ + get_leaves_for_period(
+ self.employee, self.leave_type, allocation.from_date, self.encashment_date
+ )
)
encashable_days = self.leave_balance - frappe.db.get_value(
@@ -126,14 +129,25 @@ class LeaveEncashment(Document):
return True
def get_leave_allocation(self):
- leave_allocation = frappe.db.sql(
- """select name, to_date, total_leaves_allocated, carry_forwarded_leaves_count from `tabLeave Allocation` where '{0}'
- between from_date and to_date and docstatus=1 and leave_type='{1}'
- and employee= '{2}'""".format(
- self.encashment_date or getdate(nowdate()), self.leave_type, self.employee
- ),
- as_dict=1,
- ) # nosec
+ date = self.encashment_date or getdate()
+
+ LeaveAllocation = frappe.qb.DocType("Leave Allocation")
+ leave_allocation = (
+ frappe.qb.from_(LeaveAllocation)
+ .select(
+ LeaveAllocation.name,
+ LeaveAllocation.from_date,
+ LeaveAllocation.to_date,
+ LeaveAllocation.total_leaves_allocated,
+ LeaveAllocation.carry_forwarded_leaves_count,
+ )
+ .where(
+ ((LeaveAllocation.from_date <= date) & (date <= LeaveAllocation.to_date))
+ & (LeaveAllocation.docstatus == 1)
+ & (LeaveAllocation.leave_type == self.leave_type)
+ & (LeaveAllocation.employee == self.employee)
+ )
+ ).run(as_dict=True)
return leave_allocation[0] if leave_allocation else None
diff --git a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
index 83eb969feb0..d06b6a3764d 100644
--- a/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
+++ b/erpnext/hr/doctype/leave_encashment/test_leave_encashment.py
@@ -4,26 +4,42 @@
import unittest
import frappe
-from frappe.utils import add_months, today
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, get_year_ending, get_year_start, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_period.test_leave_period import create_leave_period
from erpnext.hr.doctype.leave_policy.test_leave_policy import create_leave_policy
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees,
)
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
-test_dependencies = ["Leave Type"]
+test_records = frappe.get_test_records("Leave Type")
-class TestLeaveEncashment(unittest.TestCase):
+class TestLeaveEncashment(FrappeTestCase):
def setUp(self):
- frappe.db.sql("""delete from `tabLeave Period`""")
- frappe.db.sql("""delete from `tabLeave Policy Assignment`""")
- frappe.db.sql("""delete from `tabLeave Allocation`""")
- frappe.db.sql("""delete from `tabLeave Ledger Entry`""")
- frappe.db.sql("""delete from `tabAdditional Salary`""")
+ frappe.db.delete("Leave Period")
+ frappe.db.delete("Leave Policy Assignment")
+ frappe.db.delete("Leave Allocation")
+ frappe.db.delete("Leave Ledger Entry")
+ frappe.db.delete("Additional Salary")
+ frappe.db.delete("Leave Encashment")
+
+ if not frappe.db.exists("Leave Type", "_Test Leave Type Encashment"):
+ frappe.get_doc(test_records[2]).insert()
+
+ date = getdate()
+ year_start = getdate(get_year_start(date))
+ year_end = getdate(get_year_ending(date))
+
+ make_holiday_list("_Test Leave Encashment", year_start, year_end)
# create the leave policy
leave_policy = create_leave_policy(
@@ -32,9 +48,9 @@ class TestLeaveEncashment(unittest.TestCase):
leave_policy.submit()
# create employee, salary structure and assignment
- self.employee = make_employee("test_employee_encashment@example.com")
+ self.employee = make_employee("test_employee_encashment@example.com", company="_Test Company")
- self.leave_period = create_leave_period(add_months(today(), -3), add_months(today(), 3))
+ self.leave_period = create_leave_period(year_start, year_end, "_Test Company")
data = {
"assignment_based_on": "Leave Period",
@@ -53,27 +69,15 @@ class TestLeaveEncashment(unittest.TestCase):
other_details={"leave_encashment_amount_per_day": 50},
)
- def tearDown(self):
- for dt in [
- "Leave Period",
- "Leave Allocation",
- "Leave Ledger Entry",
- "Additional Salary",
- "Leave Encashment",
- "Salary Structure",
- "Leave Policy",
- ]:
- frappe.db.sql("delete from `tab%s`" % dt)
-
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
def test_leave_balance_value_and_amount(self):
- frappe.db.sql("""delete from `tabLeave Encashment`""")
leave_encashment = frappe.get_doc(
dict(
doctype="Leave Encashment",
employee=self.employee,
leave_type="_Test Leave Type Encashment",
leave_period=self.leave_period.name,
- payroll_date=today(),
+ encashment_date=self.leave_period.to_date,
currency="INR",
)
).insert()
@@ -88,15 +92,46 @@ class TestLeaveEncashment(unittest.TestCase):
add_sal = frappe.get_all("Additional Salary", filters={"ref_docname": leave_encashment.name})[0]
self.assertTrue(add_sal)
- def test_creation_of_leave_ledger_entry_on_submit(self):
- frappe.db.sql("""delete from `tabLeave Encashment`""")
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
+ def test_leave_balance_value_with_leaves_and_amount(self):
+ date = self.leave_period.from_date
+ leave_application = make_leave_application(
+ self.employee, date, add_days(date, 3), "_Test Leave Type Encashment"
+ )
+ leave_application.reload()
+
leave_encashment = frappe.get_doc(
dict(
doctype="Leave Encashment",
employee=self.employee,
leave_type="_Test Leave Type Encashment",
leave_period=self.leave_period.name,
- payroll_date=today(),
+ encashment_date=self.leave_period.to_date,
+ currency="INR",
+ )
+ ).insert()
+
+ self.assertEqual(leave_encashment.leave_balance, 10 - leave_application.total_leave_days)
+ # encashable days threshold is 5, total leaves are 6, so encashable days = 6-5 = 1
+ # with charge of 50 per day
+ self.assertEqual(leave_encashment.encashable_days, leave_encashment.leave_balance - 5)
+ self.assertEqual(leave_encashment.encashment_amount, 50)
+
+ leave_encashment.submit()
+
+ # assert links
+ add_sal = frappe.get_all("Additional Salary", filters={"ref_docname": leave_encashment.name})[0]
+ self.assertTrue(add_sal)
+
+ @set_holiday_list("_Test Leave Encashment", "_Test Company")
+ def test_creation_of_leave_ledger_entry_on_submit(self):
+ leave_encashment = frappe.get_doc(
+ dict(
+ doctype="Leave Encashment",
+ employee=self.employee,
+ leave_type="_Test Leave Type Encashment",
+ leave_period=self.leave_period.name,
+ encashment_date=self.leave_period.to_date,
currency="INR",
)
).insert()
diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
index 93a493c9d25..3cb2066c4d5 100644
--- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py
+++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py
@@ -175,27 +175,24 @@ class StaffingPlan(Document):
@frappe.whitelist()
-def get_designation_counts(designation, company):
+def get_designation_counts(designation, company, job_opening=None):
if not designation:
return False
- employee_counts = {}
company_set = get_descendants_of("Company", company)
company_set.append(company)
- employee_counts["employee_count"] = frappe.db.get_value(
- "Employee",
- filters={"designation": designation, "status": "Active", "company": ("in", company_set)},
- fieldname=["count(name)"],
+ employee_count = frappe.db.count(
+ "Employee", {"designation": designation, "status": "Active", "company": ("in", company_set)}
)
- employee_counts["job_openings"] = frappe.db.get_value(
- "Job Opening",
- filters={"designation": designation, "status": "Open", "company": ("in", company_set)},
- fieldname=["count(name)"],
- )
+ filters = {"designation": designation, "status": "Open", "company": ("in", company_set)}
+ if job_opening:
+ filters["name"] = ("!=", job_opening)
- return employee_counts
+ job_openings = frappe.db.count("Job Opening", filters)
+
+ return {"employee_count": employee_count, "job_openings": job_openings}
@frappe.whitelist()
diff --git a/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py b/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
index a3adbbd56a5..ac69c219791 100644
--- a/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
+++ b/erpnext/hr/doctype/staffing_plan/test_staffing_plan.py
@@ -85,13 +85,16 @@ def _set_up():
make_company()
-def make_company():
- if frappe.db.exists("Company", "_Test Company 10"):
+def make_company(name=None, abbr=None):
+ if not name:
+ name = "_Test Company 10"
+
+ if frappe.db.exists("Company", name):
return
company = frappe.new_doc("Company")
- company.company_name = "_Test Company 10"
- company.abbr = "_TC10"
+ company.company_name = name
+ company.abbr = abbr or "_TC10"
company.parent_company = "_Test Company 3"
company.default_currency = "INR"
company.country = "Pakistan"
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index d779a5465f5..387c2ca5e5a 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -598,20 +598,18 @@ def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining
return False
-def get_salary_assignment(employee, date):
- assignment = frappe.db.sql(
- """
- select * from `tabSalary Structure Assignment`
- where employee=%(employee)s
- and docstatus = 1
- and %(on_date)s >= from_date order by from_date desc limit 1""",
- {
- "employee": employee,
- "on_date": date,
- },
- as_dict=1,
+def get_salary_assignments(employee, payroll_period):
+ start_date, end_date = frappe.db.get_value(
+ "Payroll Period", payroll_period, ["start_date", "end_date"]
)
- return assignment[0] if assignment else None
+ assignments = frappe.db.get_all(
+ "Salary Structure Assignment",
+ filters={"employee": employee, "docstatus": 1, "from_date": ["between", (start_date, end_date)]},
+ fields=["*"],
+ order_by="from_date",
+ )
+
+ return assignments
def get_sal_slip_total_benefit_given(employee, payroll_period, component=False):
diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json
index 4500ba4560c..000bc3efd0a 100644
--- a/erpnext/hr/workspace/hr/hr.json
+++ b/erpnext/hr/workspace/hr/hr.json
@@ -595,6 +595,46 @@
"onboard": 0,
"type": "Link"
},
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Type",
+ "link_count": 0,
+ "link_to": "Interview Type",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Round",
+ "link_count": 0,
+ "link_to": "Interview Round",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview",
+ "link_count": 0,
+ "link_to": "Interview",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Interview Feedback",
+ "link_count": 0,
+ "link_to": "Interview Feedback",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
{
"hidden": 0,
"is_query_report": 0,
@@ -841,7 +881,7 @@
"type": "Link"
}
],
- "modified": "2021-05-13 17:19:40.524444",
+ "modified": "2022-05-30 17:19:40.524444",
"modified_by": "Administrator",
"module": "HR",
"name": "HR",
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index 940a1bbc000..38328e69674 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -93,6 +93,12 @@ frappe.ui.form.on('Loan', {
frm.trigger("make_loan_refund");
},__('Create'));
}
+
+ if (frm.doc.status == "Loan Closure Requested" && frm.doc.is_term_loan && !frm.doc.is_secured_loan) {
+ frm.add_custom_button(__('Close Loan'), function() {
+ frm.trigger("close_unsecured_term_loan");
+ },__('Status'));
+ }
}
frm.trigger("toggle_fields");
},
@@ -174,6 +180,18 @@ frappe.ui.form.on('Loan', {
})
},
+ close_unsecured_term_loan: function(frm) {
+ frappe.call({
+ args: {
+ "loan": frm.doc.name
+ },
+ method: "erpnext.loan_management.doctype.loan.loan.close_unsecured_term_loan",
+ callback: function () {
+ frm.refresh();
+ }
+ })
+ },
+
request_loan_closure: function(frm) {
frappe.confirm(__("Do you really want to close this loan"),
function() {
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index 03ec4014eec..b66b8519238 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -61,6 +61,8 @@ class Loan(AccountsController):
def on_submit(self):
self.link_loan_security_pledge()
+ # Interest accrual for backdated term loans
+ self.accrue_loan_interest()
def on_cancel(self):
self.unlink_loan_security_pledge()
@@ -180,6 +182,16 @@ class Loan(AccountsController):
self.db_set("maximum_loan_amount", maximum_loan_value)
+ def accrue_loan_interest(self):
+ from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import (
+ process_loan_interest_accrual_for_term_loans,
+ )
+
+ if getdate(self.repayment_start_date) < getdate() and self.is_term_loan:
+ process_loan_interest_accrual_for_term_loans(
+ posting_date=getdate(), loan_type=self.loan_type, loan=self.name
+ )
+
def unlink_loan_security_pledge(self):
pledges = frappe.get_all("Loan Security Pledge", fields=["name"], filters={"loan": self.name})
pledge_list = [d.name for d in pledges]
@@ -323,6 +335,22 @@ def get_loan_application(loan_application):
return loan.as_dict()
+@frappe.whitelist()
+def close_unsecured_term_loan(loan):
+ loan_details = frappe.db.get_value(
+ "Loan", {"name": loan}, ["status", "is_term_loan", "is_secured_loan"], as_dict=1
+ )
+
+ if (
+ loan_details.status == "Loan Closure Requested"
+ and loan_details.is_term_loan
+ and not loan_details.is_secured_loan
+ ):
+ frappe.db.set_value("Loan", loan, "status", "Closed")
+ else:
+ frappe.throw(_("Cannot close this loan until full repayment"))
+
+
def close_loan(loan, total_amount_paid):
frappe.db.set_value("Loan", loan, "total_amount_paid", total_amount_paid)
frappe.db.set_value("Loan", loan, "status", "Closed")
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 9a43c2aec63..d3840bfb2e2 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -449,8 +449,6 @@ class LoanRepayment(AccountsController):
"remarks": remarks,
"cost_center": self.cost_center,
"posting_date": getdate(self.posting_date),
- "party_type": self.applicant_type if self.repay_from_salary else "",
- "party": self.applicant if self.repay_from_salary else "",
}
)
)
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index f24fd24d1ff..ef7a09c3aa7 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -93,6 +93,11 @@ frappe.ui.form.on("BOM", {
});
}
+ frm.add_custom_button(__("New Version"), function() {
+ let new_bom = frappe.model.copy_doc(frm.doc);
+ frappe.set_route("Form", "BOM", new_bom.name);
+ });
+
if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order");
@@ -331,7 +336,7 @@ frappe.ui.form.on("BOM", {
});
});
- if (has_template_rm) {
+ if (has_template_rm && has_template_rm.length) {
dialog.fields_dict.items.grid.refresh();
}
},
@@ -467,7 +472,8 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) {
"uom": d.uom,
"stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor,
- "sourced_by_supplier": d.sourced_by_supplier
+ "sourced_by_supplier": d.sourced_by_supplier,
+ "do_not_explode": d.do_not_explode
},
callback: function(r) {
d = locals[cdt][cdn];
@@ -640,6 +646,13 @@ frappe.ui.form.on("BOM Operation", "workstation", function(frm, cdt, cdn) {
});
});
+frappe.ui.form.on("BOM Item", {
+ do_not_explode: function(frm, cdt, cdn) {
+ get_bom_material_detail(frm.doc, cdt, cdn, false);
+ }
+})
+
+
frappe.ui.form.on("BOM Item", "qty", function(frm, cdt, cdn) {
var d = locals[cdt][cdn];
d.stock_qty = d.qty * d.conversion_factor;
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index 7969e8a9849..e9602312df9 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -22,6 +22,10 @@ from erpnext.stock.get_item_details import get_conversion_factor, get_price_list
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
+class BOMRecursionError(frappe.ValidationError):
+ pass
+
+
class BOMTree:
"""Full tree representation of a BOM"""
@@ -251,6 +255,9 @@ class BOM(WebsiteGenerator):
for item in self.get("items"):
self.validate_bom_currency(item)
+ if item.do_not_explode:
+ item.bom_no = ""
+
ret = self.get_bom_material_detail(
{
"company": self.company,
@@ -264,8 +271,10 @@ class BOM(WebsiteGenerator):
"stock_uom": item.stock_uom,
"conversion_factor": item.conversion_factor,
"sourced_by_supplier": item.sourced_by_supplier,
+ "do_not_explode": item.do_not_explode,
}
)
+
for r in ret:
if not item.get(r):
item.set(r, ret[r])
@@ -322,6 +331,9 @@ class BOM(WebsiteGenerator):
"sourced_by_supplier": args.get("sourced_by_supplier", 0),
}
+ if args.get("do_not_explode"):
+ ret_item["bom_no"] = ""
+
return ret_item
def validate_bom_currency(self, item):
@@ -520,35 +532,27 @@ class BOM(WebsiteGenerator):
"""Check whether recursion occurs in any bom"""
def _throw_error(bom_name):
- frappe.throw(_("BOM recursion: {0} cannot be parent or child of {0}").format(bom_name))
+ frappe.throw(
+ _("BOM recursion: {1} cannot be parent or child of {0}").format(self.name, bom_name),
+ exc=BOMRecursionError,
+ )
bom_list = self.traverse_tree()
- child_items = (
- frappe.get_all(
- "BOM Item",
- fields=["bom_no", "item_code"],
- filters={"parent": ("in", bom_list), "parenttype": "BOM"},
- )
- or []
+ child_items = frappe.get_all(
+ "BOM Item",
+ fields=["bom_no", "item_code"],
+ filters={"parent": ("in", bom_list), "parenttype": "BOM"},
)
- child_bom = {d.bom_no for d in child_items}
- child_items_codes = {d.item_code for d in child_items}
+ for item in child_items:
+ if self.name == item.bom_no:
+ _throw_error(self.name)
+ if self.item == item.item_code and item.bom_no:
+ # Same item but with different BOM should not be allowed.
+ # Same item can appear recursively once as long as it doesn't have BOM.
+ _throw_error(item.bom_no)
- if self.name in child_bom:
- _throw_error(self.name)
-
- if self.item in child_items_codes:
- _throw_error(self.item)
-
- bom_nos = (
- frappe.get_all(
- "BOM Item", fields=["parent"], filters={"bom_no": self.name, "parenttype": "BOM"}
- )
- or []
- )
-
- if self.name in {d.parent for d in bom_nos}:
+ if self.name in {d.bom_no for d in self.items}:
_throw_error(self.name)
def traverse_tree(self, bom_list=None):
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 47634d903d0..5ea5d1b8a45 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
-from erpnext.manufacturing.doctype.bom.bom import item_query
+from erpnext.manufacturing.doctype.bom.bom import BOMRecursionError, item_query
from erpnext.manufacturing.doctype.bom_update_log.test_bom_update_log import (
update_cost_in_all_boms_in_test,
)
@@ -266,43 +266,36 @@ class TestBOM(FrappeTestCase):
def test_bom_recursion_1st_level(self):
"""BOM should not allow BOM item again in child"""
- item_code = "_Test BOM Recursion"
- make_item(item_code, {"is_stock_item": 1})
+ item_code = make_item(properties={"is_stock_item": 1}).name
bom = frappe.new_doc("BOM")
bom.item = item_code
bom.append("items", frappe._dict(item_code=item_code))
- with self.assertRaises(frappe.ValidationError) as err:
+ bom.save()
+ with self.assertRaises(BOMRecursionError):
+ bom.items[0].bom_no = bom.name
bom.save()
- self.assertTrue("recursion" in str(err.exception).lower())
- frappe.delete_doc("BOM", bom.name, ignore_missing=True)
-
def test_bom_recursion_transitive(self):
- item1 = "_Test BOM Recursion"
- item2 = "_Test BOM Recursion 2"
- make_item(item1, {"is_stock_item": 1})
- make_item(item2, {"is_stock_item": 1})
+ item1 = make_item(properties={"is_stock_item": 1}).name
+ item2 = make_item(properties={"is_stock_item": 1}).name
bom1 = frappe.new_doc("BOM")
bom1.item = item1
bom1.append("items", frappe._dict(item_code=item2))
bom1.save()
- bom1.submit()
bom2 = frappe.new_doc("BOM")
bom2.item = item2
bom2.append("items", frappe._dict(item_code=item1))
+ bom2.save()
- with self.assertRaises(frappe.ValidationError) as err:
+ bom2.items[0].bom_no = bom1.name
+ bom1.items[0].bom_no = bom2.name
+
+ with self.assertRaises(BOMRecursionError):
+ bom1.save()
bom2.save()
- bom2.submit()
-
- self.assertTrue("recursion" in str(err.exception).lower())
-
- bom1.cancel()
- frappe.delete_doc("BOM", bom1.name, ignore_missing=True, force=True)
- frappe.delete_doc("BOM", bom2.name, ignore_missing=True, force=True)
def test_bom_with_process_loss_item(self):
fg_item_non_whole, fg_item_whole, bom_item = create_process_loss_bom_items()
@@ -508,6 +501,24 @@ class TestBOM(FrappeTestCase):
bom.submit()
self.assertEqual(bom.items[0].rate, 42)
+ def test_exclude_exploded_items_from_bom(self):
+ bom_no = get_default_bom()
+ new_bom = frappe.copy_doc(frappe.get_doc("BOM", bom_no))
+ for row in new_bom.items:
+ if row.item_code == "_Test Item Home Desktop Manufactured":
+ self.assertTrue(row.bom_no)
+ row.do_not_explode = True
+
+ new_bom.docstatus = 0
+ new_bom.save()
+ new_bom.load_from_db()
+
+ for row in new_bom.items:
+ if row.item_code == "_Test Item Home Desktop Manufactured" and row.do_not_explode:
+ self.assertFalse(row.bom_no)
+
+ new_bom.delete()
+
def get_default_bom(item_code="_Test FG Item 2"):
return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1})
diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json
index 4c9877f52b2..3406215cbbb 100644
--- a/erpnext/manufacturing/doctype/bom_item/bom_item.json
+++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json
@@ -10,6 +10,7 @@
"item_name",
"operation",
"column_break_3",
+ "do_not_explode",
"bom_no",
"source_warehouse",
"allow_alternative_item",
@@ -73,6 +74,7 @@
"fieldtype": "Column Break"
},
{
+ "depends_on": "eval:!doc.do_not_explode",
"fieldname": "bom_no",
"fieldtype": "Link",
"in_filter": 1,
@@ -284,18 +286,25 @@
"fieldname": "sourced_by_supplier",
"fieldtype": "Check",
"label": "Sourced by Supplier"
+ },
+ {
+ "default": "0",
+ "fieldname": "do_not_explode",
+ "fieldtype": "Check",
+ "label": "Do Not Explode"
}
],
"idx": 1,
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-10-08 14:19:37.563300",
+ "modified": "2022-01-24 16:57:57.020232",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "BOM Item",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 983b3b17e68..dadaaf9aa96 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -42,6 +42,10 @@ class JobCardCancelError(frappe.ValidationError):
pass
+class JobCardOverTransferError(frappe.ValidationError):
+ pass
+
+
class JobCard(Document):
def onload(self):
excess_transfer = frappe.db.get_single_value(
@@ -522,23 +526,50 @@ class JobCard(Document):
},
)
- def set_transferred_qty_in_job_card(self, ste_doc):
+ def set_transferred_qty_in_job_card_item(self, ste_doc):
+ from frappe.query_builder.functions import Sum
+
+ def _validate_over_transfer(row, transferred_qty):
+ "Block over transfer of items if not allowed in settings."
+ required_qty = frappe.db.get_value("Job Card Item", row.job_card_item, "required_qty")
+ is_excess = flt(transferred_qty) > flt(required_qty)
+ if is_excess:
+ frappe.throw(
+ _(
+ "Row #{0}: Cannot transfer more than Required Qty {1} for Item {2} against Job Card {3}"
+ ).format(
+ row.idx, frappe.bold(required_qty), frappe.bold(row.item_code), ste_doc.job_card
+ ),
+ title=_("Excess Transfer"),
+ exc=JobCardOverTransferError,
+ )
+
for row in ste_doc.items:
if not row.job_card_item:
continue
- qty = frappe.db.sql(
- """ SELECT SUM(qty) from `tabStock Entry Detail` sed, `tabStock Entry` se
- WHERE sed.job_card_item = %s and se.docstatus = 1 and sed.parent = se.name and
- se.purpose = 'Material Transfer for Manufacture'
- """,
- (row.job_card_item),
- )[0][0]
+ sed = frappe.qb.DocType("Stock Entry Detail")
+ se = frappe.qb.DocType("Stock Entry")
+ transferred_qty = (
+ frappe.qb.from_(sed)
+ .join(se)
+ .on(sed.parent == se.name)
+ .select(Sum(sed.qty))
+ .where(
+ (sed.job_card_item == row.job_card_item)
+ & (se.docstatus == 1)
+ & (se.purpose == "Material Transfer for Manufacture")
+ )
+ ).run()[0][0]
- frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(qty))
+ allow_excess = frappe.db.get_single_value("Manufacturing Settings", "job_card_excess_transfer")
+ if not allow_excess:
+ _validate_over_transfer(row, transferred_qty)
+
+ frappe.db.set_value("Job Card Item", row.job_card_item, "transferred_qty", flt(transferred_qty))
def set_transferred_qty(self, update_status=False):
- "Set total FG Qty for which RM was transferred."
+ "Set total FG Qty in Job Card for which RM was transferred."
if not self.items:
self.transferred_qty = self.for_quantity if self.docstatus == 1 else 0
@@ -590,7 +621,7 @@ class JobCard(Document):
self.set_status(update_status)
def set_status(self, update_status=False):
- if self.status == "On Hold":
+ if self.status == "On Hold" and self.docstatus == 0:
return
self.status = {0: "Open", 1: "Submitted", 2: "Cancelled"}[self.docstatus or 0]
@@ -866,6 +897,7 @@ def make_corrective_job_card(source_name, operation=None, for_operation=None, ta
target.set("time_logs", [])
target.set("employee", [])
target.set("items", [])
+ target.set("sub_operations", [])
target.set_sub_operations()
target.get_required_items()
target.validate_time_logs()
diff --git a/erpnext/manufacturing/doctype/job_card/test_job_card.py b/erpnext/manufacturing/doctype/job_card/test_job_card.py
index 4647ddf05f7..b5371af2ccb 100644
--- a/erpnext/manufacturing/doctype/job_card/test_job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/test_job_card.py
@@ -2,14 +2,21 @@
# See license.txt
import frappe
-from frappe.tests.utils import FrappeTestCase
+from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import random_string
+from frappe.utils.data import add_to_date, now
-from erpnext.manufacturing.doctype.job_card.job_card import OperationMismatchError, OverlapError
+from erpnext.manufacturing.doctype.job_card.job_card import (
+ JobCardOverTransferError,
+ OperationMismatchError,
+ OverlapError,
+ make_corrective_job_card,
+)
from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
)
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+from erpnext.manufacturing.doctype.work_order.work_order import WorkOrder
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@@ -17,34 +24,36 @@ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestJobCard(FrappeTestCase):
def setUp(self):
make_bom_for_jc_tests()
+ self.transfer_material_against = "Work Order"
+ self.source_warehouse = None
+ self._work_order = None
- transfer_material_against, source_warehouse = None, None
+ @property
+ def work_order(self) -> WorkOrder:
+ """Work Order lazily created for tests."""
+ if not self._work_order:
+ self._work_order = make_wo_order_test_record(
+ item="_Test FG Item 2",
+ qty=2,
+ transfer_material_against=self.transfer_material_against,
+ source_warehouse=self.source_warehouse,
+ )
+ return self._work_order
- tests_that_skip_setup = ("test_job_card_material_transfer_correctness",)
- tests_that_transfer_against_jc = (
- "test_job_card_multiple_materials_transfer",
- "test_job_card_excess_material_transfer",
- "test_job_card_partial_material_transfer",
- )
-
- if self._testMethodName in tests_that_skip_setup:
- return
-
- if self._testMethodName in tests_that_transfer_against_jc:
- transfer_material_against = "Job Card"
- source_warehouse = "Stores - _TC"
-
- self.work_order = make_wo_order_test_record(
- item="_Test FG Item 2",
- qty=2,
- transfer_material_against=transfer_material_against,
- source_warehouse=source_warehouse,
- )
+ def generate_required_stock(self, work_order: WorkOrder) -> None:
+ """Create twice the stock for all required items in work order."""
+ for item in work_order.required_items:
+ make_stock_entry(
+ item_code=item.item_code,
+ target=item.source_warehouse or self.source_warehouse,
+ qty=item.required_qty * 2,
+ basic_rate=100,
+ )
def tearDown(self):
frappe.db.rollback()
- def test_job_card(self):
+ def test_job_card_operations(self):
job_cards = frappe.get_all(
"Job Card", filters={"work_order": self.work_order.name}, fields=["operation_id", "name"]
@@ -58,9 +67,6 @@ class TestJobCard(FrappeTestCase):
doc.operation_id = "Test Data"
self.assertRaises(OperationMismatchError, doc.save)
- for d in job_cards:
- frappe.delete_doc("Job Card", d.name)
-
def test_job_card_with_different_work_station(self):
job_cards = frappe.get_all(
"Job Card",
@@ -96,19 +102,11 @@ class TestJobCard(FrappeTestCase):
)
self.assertEqual(completed_qty, job_card.for_quantity)
- doc.cancel()
-
- for d in job_cards:
- frappe.delete_doc("Job Card", d.name)
-
def test_job_card_overlap(self):
wo2 = make_wo_order_test_record(item="_Test FG Item 2", qty=2)
- jc1_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- jc2_name = frappe.db.get_value("Job Card", {"work_order": wo2.name})
-
- jc1 = frappe.get_doc("Job Card", jc1_name)
- jc2 = frappe.get_doc("Job Card", jc2_name)
+ jc1 = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+ jc2 = frappe.get_last_doc("Job Card", {"work_order": wo2.name})
employee = "_T-Employee-00001" # from test records
@@ -137,10 +135,10 @@ class TestJobCard(FrappeTestCase):
def test_job_card_multiple_materials_transfer(self):
"Test transferring RMs separately against Job Card with multiple RMs."
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=6, basic_rate=100
- )
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
job_card = frappe.get_doc("Job Card", job_card_name)
@@ -165,16 +163,58 @@ class TestJobCard(FrappeTestCase):
# transfer was made for 2 fg qty in first transfer Stock Entry
self.assertEqual(transfer_entry_2.fg_completed_qty, 0)
+ @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 1})
def test_job_card_excess_material_transfer(self):
"Test transferring more than required RM against Job Card."
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
+
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+ self.assertEqual(job_card.status, "Open")
+
+ # fully transfer both RMs
+ transfer_entry_1 = make_stock_entry_from_jc(job_card.name)
+ transfer_entry_1.insert()
+ transfer_entry_1.submit()
+
+ # transfer extra qty of both RM due to previously damaged RM
+ transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
+ # deliberately change 'For Quantity'
+ transfer_entry_2.fg_completed_qty = 1
+ transfer_entry_2.items[0].qty = 5
+ transfer_entry_2.items[1].qty = 3
+ transfer_entry_2.insert()
+ transfer_entry_2.submit()
+
+ job_card.reload()
+ self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
+
+ # Check if 'For Quantity' is negative
+ # as 'transferred_qty' > Qty to Manufacture
+ transfer_entry_3 = make_stock_entry_from_jc(job_card.name)
+ self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
+
+ job_card.append(
+ "time_logs",
+ {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
)
+ job_card.save()
+ job_card.submit()
+
+ # JC is Completed with excess transfer
+ self.assertEqual(job_card.status, "Completed")
+
+ @change_settings("Manufacturing Settings", {"job_card_excess_transfer": 0})
+ def test_job_card_excess_material_transfer_block(self):
+
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
+
+ self.generate_required_stock(self.work_order)
job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- job_card = frappe.get_doc("Job Card", job_card_name)
- self.assertEqual(job_card.status, "Open")
# fully transfer both RMs
transfer_entry_1 = make_stock_entry_from_jc(job_card_name)
@@ -188,39 +228,19 @@ class TestJobCard(FrappeTestCase):
transfer_entry_2.items[0].qty = 5
transfer_entry_2.items[1].qty = 3
transfer_entry_2.insert()
- transfer_entry_2.submit()
-
- job_card.reload()
- self.assertGreater(job_card.transferred_qty, job_card.for_quantity)
-
- # Check if 'For Quantity' is negative
- # as 'transferred_qty' > Qty to Manufacture
- transfer_entry_3 = make_stock_entry_from_jc(job_card_name)
- self.assertEqual(transfer_entry_3.fg_completed_qty, 0)
-
- job_card.append(
- "time_logs",
- {"from_time": "2021-01-01 00:01:00", "to_time": "2021-01-01 06:00:00", "completed_qty": 2},
- )
- job_card.save()
- job_card.submit()
-
- # JC is Completed with excess transfer
- self.assertEqual(job_card.status, "Completed")
+ self.assertRaises(JobCardOverTransferError, transfer_entry_2.submit)
def test_job_card_partial_material_transfer(self):
"Test partial material transfer against Job Card"
+ self.transfer_material_against = "Job Card"
+ self.source_warehouse = "Stores - _TC"
- make_stock_entry(item_code="_Test Item", target="Stores - _TC", qty=25, basic_rate=100)
- make_stock_entry(
- item_code="_Test Item Home Desktop Manufactured", target="Stores - _TC", qty=15, basic_rate=100
- )
+ self.generate_required_stock(self.work_order)
- job_card_name = frappe.db.get_value("Job Card", {"work_order": self.work_order.name})
- job_card = frappe.get_doc("Job Card", job_card_name)
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
# partially transfer
- transfer_entry = make_stock_entry_from_jc(job_card_name)
+ transfer_entry = make_stock_entry_from_jc(job_card.name)
transfer_entry.fg_completed_qty = 1
transfer_entry.get_items()
transfer_entry.insert()
@@ -232,7 +252,7 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[1].qty, 3)
# transfer remaining
- transfer_entry_2 = make_stock_entry_from_jc(job_card_name)
+ transfer_entry_2 = make_stock_entry_from_jc(job_card.name)
self.assertEqual(transfer_entry_2.fg_completed_qty, 1)
self.assertEqual(transfer_entry_2.items[0].qty, 5)
@@ -277,7 +297,49 @@ class TestJobCard(FrappeTestCase):
self.assertEqual(transfer_entry.items[0].item_code, "_Test Item")
self.assertEqual(transfer_entry.items[0].qty, 2)
- # rollback via tearDown method
+ @change_settings(
+ "Manufacturing Settings", {"add_corrective_operation_cost_in_finished_good_valuation": 1}
+ )
+ def test_corrective_costing(self):
+ job_card = frappe.get_last_doc("Job Card", {"work_order": self.work_order.name})
+
+ job_card.append(
+ "time_logs",
+ {"from_time": now(), "to_time": add_to_date(now(), hours=1), "completed_qty": 2},
+ )
+ job_card.submit()
+
+ self.work_order.reload()
+ original_cost = self.work_order.total_operating_cost
+
+ # Create a corrective operation against it
+ corrective_action = frappe.get_doc(
+ doctype="Operation", is_corrective_operation=1, name=frappe.generate_hash()
+ ).insert()
+
+ corrective_job_card = make_corrective_job_card(
+ job_card.name, operation=corrective_action.name, for_operation=job_card.operation
+ )
+ corrective_job_card.hour_rate = 100
+ corrective_job_card.insert()
+ corrective_job_card.append(
+ "time_logs",
+ {
+ "from_time": add_to_date(now(), hours=2),
+ "to_time": add_to_date(now(), hours=2, minutes=30),
+ "completed_qty": 2,
+ },
+ )
+ corrective_job_card.submit()
+
+ self.work_order.reload()
+ cost_after_correction = self.work_order.total_operating_cost
+ self.assertGreater(cost_after_correction, original_cost)
+
+ corrective_job_card.cancel()
+ self.work_order.reload()
+ cost_after_cancel = self.work_order.total_operating_cost
+ self.assertEqual(cost_after_cancel, original_cost)
def create_bom_with_multiple_operations():
diff --git a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
index 0a79130f1b2..de96a6c0323 100644
--- a/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
+++ b/erpnext/manufacturing/report/quality_inspection_summary/quality_inspection_summary.py
@@ -34,8 +34,7 @@ def get_data(filters):
if filters.get(field):
query_filters[field] = ("in", filters.get(field))
- query_filters["report_date"] = (">=", filters.get("from_date"))
- query_filters["report_date"] = ("<=", filters.get("to_date"))
+ query_filters["report_date"] = ["between", [filters.get("from_date"), filters.get("to_date")]]
return frappe.get_all(
"Quality Inspection", fields=fields, filters=query_filters, order_by="report_date asc"
diff --git a/erpnext/non_profit/doctype/donation/donation.py b/erpnext/non_profit/doctype/donation/donation.py
index 8e5ac5b61bf..ed4b3d05b3f 100644
--- a/erpnext/non_profit/doctype/donation/donation.py
+++ b/erpnext/non_profit/doctype/donation/donation.py
@@ -100,7 +100,9 @@ def capture_razorpay_donations(*args, **kwargs):
return
# to avoid capturing subscription payments as donations
- if payment.description and "subscription" in str(payment.description).lower():
+ if payment.invoice_id or (
+ payment.description and "subscription" in str(payment.description).lower()
+ ):
return
donor = get_donor(payment.email)
diff --git a/erpnext/non_profit/doctype/member/member.js b/erpnext/non_profit/doctype/member/member.js
index e58ec0f5eea..40926c23633 100644
--- a/erpnext/non_profit/doctype/member/member.js
+++ b/erpnext/non_profit/doctype/member/member.js
@@ -44,21 +44,18 @@ frappe.ui.form.on('Member', {
frappe.contacts.clear_address_and_contact(frm);
}
- frappe.call({
- method:"frappe.client.get_value",
- args:{
- 'doctype':"Membership",
- 'filters':{'member': frm.doc.name},
- 'fieldname':[
- 'to_date'
- ]
- },
- callback: function (data) {
- if(data.message) {
- frappe.model.set_value(frm.doctype,frm.docname,
- "membership_expiry_date", data.message.to_date);
+ if (!frm.doc.membership_expiry_date && !frm.doc.__islocal) {
+ frappe.call({
+ method: "erpnext.get_last_membership",
+ args: {
+ member: frm.doc.member
+ },
+ callback: function(data) {
+ if (data.message) {
+ frappe.model.set_value(frm.doctype, frm.docname, "membership_expiry_date", data.message.to_date);
+ }
}
- }
- });
+ });
+ }
}
});
diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py
index f29005a6d4b..7f7abd06594 100644
--- a/erpnext/non_profit/doctype/membership/membership.py
+++ b/erpnext/non_profit/doctype/membership/membership.py
@@ -13,6 +13,7 @@ from frappe.model.document import Document
from frappe.utils import add_days, add_months, add_years, get_link_to_form, getdate, nowdate
import erpnext
+from erpnext import get_company_currency
from erpnext.non_profit.doctype.member.member import create_member
@@ -61,10 +62,6 @@ class Membership(Document):
frappe.throw(_("You can only renew if your membership expires within 30 days"))
self.from_date = add_days(last_membership.to_date, 1)
- elif frappe.session.user == "Administrator":
- self.from_date = self.from_date
- else:
- self.from_date = nowdate()
if frappe.db.get_single_value("Non Profit Settings", "billing_cycle") == "Yearly":
self.to_date = add_years(self.from_date, 1)
@@ -207,7 +204,7 @@ def make_invoice(membership, member, plan, settings):
"doctype": "Sales Invoice",
"customer": member.customer,
"debit_to": settings.membership_debit_account,
- "currency": membership.currency,
+ "currency": membership.currency or get_company_currency(settings.company),
"company": settings.company,
"is_pos": 0,
"items": [{"item_code": plan.linked_item, "rate": membership.amount, "qty": 1}],
diff --git a/erpnext/non_profit/doctype/membership/test_membership.py b/erpnext/non_profit/doctype/membership/test_membership.py
index aef34a69606..d73c2bed5f4 100644
--- a/erpnext/non_profit/doctype/membership/test_membership.py
+++ b/erpnext/non_profit/doctype/membership/test_membership.py
@@ -94,7 +94,7 @@ def make_membership(member, payload={}):
"member": member,
"membership_status": "Current",
"membership_type": "_rzpy_test_milythm",
- "currency": "INR",
+ "currency": "USD",
"paid": 1,
"from_date": nowdate(),
"amount": 100,
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 1ff33c7f7bf..63b146d99fe 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -248,7 +248,6 @@ execute:frappe.delete_doc("Report", "Quoted Item Comparison")
erpnext.patches.v13_0.update_member_email_address
erpnext.patches.v13_0.update_custom_fields_for_shopify
erpnext.patches.v13_0.updates_for_multi_currency_payroll
-erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy
erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log
erpnext.patches.v13_0.add_po_to_global_search
erpnext.patches.v13_0.update_returned_qty_in_pr_dn
@@ -366,3 +365,5 @@ erpnext.patches.v13_0.education_deprecation_warning
erpnext.patches.v13_0.requeue_recoverable_reposts
erpnext.patches.v13_0.create_accounting_dimensions_in_orders
erpnext.patches.v13_0.set_per_billed_in_return_delivery_note
+erpnext.patches.v13_0.update_employee_advance_status
+erpnext.patches.v13_0.job_card_status_on_hold
diff --git a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py b/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py
deleted file mode 100644
index 59b17eea9fe..00000000000
--- a/erpnext/patches/v13_0/create_leave_policy_assignment_based_on_employee_current_leave_policy.py
+++ /dev/null
@@ -1,94 +0,0 @@
-# Copyright (c) 2019, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-def execute():
- frappe.reload_doc("hr", "doctype", "leave_policy_assignment")
- frappe.reload_doc("hr", "doctype", "employee_grade")
- employee_with_assignment = []
- leave_policy = []
-
- if "leave_policy" in frappe.db.get_table_columns("Employee"):
- employees_with_leave_policy = frappe.db.sql(
- "SELECT name, leave_policy FROM `tabEmployee` WHERE leave_policy IS NOT NULL and leave_policy != ''",
- as_dict=1,
- )
-
- for employee in employees_with_leave_policy:
- alloc = frappe.db.exists(
- "Leave Allocation",
- {"employee": employee.name, "leave_policy": employee.leave_policy, "docstatus": 1},
- )
- if not alloc:
- create_assignment(employee.name, employee.leave_policy)
-
- employee_with_assignment.append(employee.name)
- leave_policy.append(employee.leave_policy)
-
- if "default_leave_policy" in frappe.db.get_table_columns("Employee Grade"):
- employee_grade_with_leave_policy = frappe.db.sql(
- "SELECT name, default_leave_policy FROM `tabEmployee Grade` WHERE default_leave_policy IS NOT NULL and default_leave_policy!=''",
- as_dict=1,
- )
-
- # for whole employee Grade
- for grade in employee_grade_with_leave_policy:
- employees = get_employee_with_grade(grade.name)
- for employee in employees:
-
- if employee not in employee_with_assignment: # Will ensure no duplicate
- alloc = frappe.db.exists(
- "Leave Allocation",
- {"employee": employee.name, "leave_policy": grade.default_leave_policy, "docstatus": 1},
- )
- if not alloc:
- create_assignment(employee.name, grade.default_leave_policy)
- leave_policy.append(grade.default_leave_policy)
-
- # for old Leave allocation and leave policy from allocation, which may got updated in employee grade.
- leave_allocations = frappe.db.sql(
- "SELECT leave_policy, leave_period, employee FROM `tabLeave Allocation` WHERE leave_policy IS NOT NULL and leave_policy != '' and docstatus = 1 ",
- as_dict=1,
- )
-
- for allocation in leave_allocations:
- if allocation.leave_policy not in leave_policy:
- create_assignment(
- allocation.employee,
- allocation.leave_policy,
- leave_period=allocation.leave_period,
- allocation_exists=True,
- )
-
-
-def create_assignment(employee, leave_policy, leave_period=None, allocation_exists=False):
- if frappe.db.get_value("Leave Policy", leave_policy, "docstatus") == 2:
- return
-
- filters = {"employee": employee, "leave_policy": leave_policy}
- if leave_period:
- filters["leave_period"] = leave_period
-
- if not frappe.db.exists("Leave Policy Assignment", filters):
- lpa = frappe.new_doc("Leave Policy Assignment")
- lpa.employee = employee
- lpa.leave_policy = leave_policy
-
- lpa.flags.ignore_mandatory = True
- if allocation_exists:
- lpa.assignment_based_on = "Leave Period"
- lpa.leave_period = leave_period
- lpa.leaves_allocated = 1
-
- lpa.save()
- if allocation_exists:
- lpa.submit()
- # Updating old Leave Allocation
- frappe.db.sql("Update `tabLeave Allocation` set leave_policy_assignment = %s", lpa.name)
-
-
-def get_employee_with_grade(grade):
- return frappe.get_list("Employee", filters={"grade": grade})
diff --git a/erpnext/patches/v13_0/job_card_status_on_hold.py b/erpnext/patches/v13_0/job_card_status_on_hold.py
new file mode 100644
index 00000000000..8c67c3c858e
--- /dev/null
+++ b/erpnext/patches/v13_0/job_card_status_on_hold.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ job_cards = frappe.get_all(
+ "Job Card",
+ {"status": "On Hold", "docstatus": ("!=", 0)},
+ pluck="name",
+ )
+
+ for idx, job_card in enumerate(job_cards):
+ try:
+ doc = frappe.get_doc("Job Card", job_card)
+ doc.set_status()
+ doc.db_set("status", doc.status, update_modified=False)
+ if idx % 100 == 0:
+ frappe.db.commit()
+ except Exception:
+ continue
diff --git a/erpnext/patches/v13_0/update_employee_advance_status.py b/erpnext/patches/v13_0/update_employee_advance_status.py
new file mode 100644
index 00000000000..fc9e05e836d
--- /dev/null
+++ b/erpnext/patches/v13_0/update_employee_advance_status.py
@@ -0,0 +1,29 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc("hr", "doctype", "employee_advance")
+
+ advance = frappe.qb.DocType("Employee Advance")
+ (
+ frappe.qb.update(advance)
+ .set(advance.status, "Returned")
+ .where(
+ (advance.docstatus == 1)
+ & ((advance.return_amount) & (advance.paid_amount == advance.return_amount))
+ & (advance.status == "Paid")
+ )
+ ).run()
+
+ (
+ frappe.qb.update(advance)
+ .set(advance.status, "Partly Claimed and Returned")
+ .where(
+ (advance.docstatus == 1)
+ & (
+ (advance.claimed_amount & advance.return_amount)
+ & (advance.paid_amount == (advance.return_amount + advance.claimed_amount))
+ )
+ & (advance.status == "Paid")
+ )
+ ).run()
diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.py b/erpnext/payroll/doctype/additional_salary/additional_salary.py
index f57d9d37cf1..18bd3b7733c 100644
--- a/erpnext/payroll/doctype/additional_salary/additional_salary.py
+++ b/erpnext/payroll/doctype/additional_salary/additional_salary.py
@@ -124,6 +124,8 @@ class AdditionalSalary(Document):
return_amount += self.amount
frappe.db.set_value("Employee Advance", self.ref_docname, "return_amount", return_amount)
+ advance = frappe.get_doc("Employee Advance", self.ref_docname)
+ advance.set_status(update=True)
def update_employee_referral(self, cancel=False):
if self.ref_doctype == "Employee Referral":
diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
index 0acd44711b0..8df1bb6e87e 100644
--- a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
+++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py
@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import add_days, cint, cstr, date_diff, getdate, rounded
+from frappe.utils import add_days, cstr, date_diff, flt, getdate, rounded
from erpnext.hr.utils import (
get_holiday_dates_for_employee,
@@ -27,11 +27,14 @@ class EmployeeBenefitApplication(Document):
validate_active_employee(self.employee)
self.validate_duplicate_on_payroll_period()
if not self.max_benefits:
- self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period)
+ self.max_benefits = flt(
+ get_max_benefits_remaining(self.employee, self.date, self.payroll_period),
+ self.precision("max_benefits"),
+ )
if self.max_benefits and self.max_benefits > 0:
self.validate_max_benefit_for_component()
self.validate_prev_benefit_claim()
- if self.remaining_benefit > 0:
+ if self.remaining_benefit and self.remaining_benefit > 0:
self.validate_remaining_benefit_amount()
else:
frappe.throw(
@@ -110,7 +113,7 @@ class EmployeeBenefitApplication(Document):
max_benefit_amount = 0
for employee_benefit in self.employee_benefits:
self.validate_max_benefit(employee_benefit.earning_component)
- max_benefit_amount += employee_benefit.amount
+ max_benefit_amount += flt(employee_benefit.amount)
if max_benefit_amount > self.max_benefits:
frappe.throw(
_("Maximum benefit amount of employee {0} exceeds {1}").format(
@@ -125,7 +128,8 @@ class EmployeeBenefitApplication(Document):
benefit_amount = 0
for employee_benefit in self.employee_benefits:
if employee_benefit.earning_component == earning_component_name:
- benefit_amount += employee_benefit.amount
+ benefit_amount += flt(employee_benefit.amount)
+
prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given(
self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name
)
@@ -207,26 +211,47 @@ def get_max_benefits_remaining(employee, on_date, payroll_period):
def calculate_lwp(employee, start_date, holidays, working_days):
lwp = 0
holidays = "','".join(holidays)
+
for d in range(working_days):
- dt = add_days(cstr(getdate(start_date)), d)
- leave = frappe.db.sql(
- """
- select t1.name, t1.half_day
- from `tabLeave Application` t1, `tabLeave Type` t2
- where t2.name = t1.leave_type
- and t2.is_lwp = 1
- and t1.docstatus = 1
- and t1.employee = %(employee)s
- and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
- WHEN t2.include_holiday THEN %(dt)s between from_date and to_date
- END
- """.format(
- holidays
- ),
- {"employee": employee, "dt": dt},
+ date = add_days(cstr(getdate(start_date)), d)
+
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ LeaveType = frappe.qb.DocType("Leave Type")
+
+ is_half_day = (
+ frappe.qb.terms.Case()
+ .when(
+ (
+ (LeaveApplication.half_day_date == date)
+ | (LeaveApplication.from_date == LeaveApplication.to_date)
+ ),
+ LeaveApplication.half_day,
+ )
+ .else_(0)
+ ).as_("is_half_day")
+
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .inner_join(LeaveType)
+ .on((LeaveType.name == LeaveApplication.leave_type))
+ .select(LeaveApplication.name, is_half_day)
+ .where(
+ (LeaveType.is_lwp == 1)
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (LeaveApplication.employee == employee)
+ & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
+ )
)
- if leave:
- lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1)
+
+ # if it's a holiday only include if leave type has "include holiday" enabled
+ if date in holidays:
+ query = query.where((LeaveType.include_holiday == "1"))
+ leaves = query.run(as_dict=True)
+
+ if leaves:
+ lwp += 0.5 if leaves[0].is_half_day else 1
+
return lwp
diff --git a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
index 02149adfce5..de8f9b6a7ad 100644
--- a/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
+++ b/erpnext/payroll/doctype/employee_benefit_application/test_employee_benefit_application.py
@@ -3,6 +3,82 @@
import unittest
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, date_diff, get_year_ending, get_year_start, getdate
-class TestEmployeeBenefitApplication(unittest.TestCase):
- pass
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
+from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
+from erpnext.hr.utils import get_holiday_dates_for_employee
+from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
+ calculate_lwp,
+)
+from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
+ create_payroll_period,
+)
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
+from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
+from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
+
+
+class TestEmployeeBenefitApplication(FrappeTestCase):
+ def setUp(self):
+ date = getdate()
+ make_holiday_list(from_date=get_year_start(date), to_date=get_year_ending(date))
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_employee_benefit_application(self):
+ payroll_period = create_payroll_period(name="_Test Payroll Period 1", company="_Test Company")
+ employee = make_employee("test_employee_benefits@salary.com", company="_Test Company")
+ first_sunday = get_first_sunday("Salary Slip Test Holiday List")
+
+ leave_application = make_leave_application(
+ employee,
+ add_days(first_sunday, 1),
+ add_days(first_sunday, 3),
+ "Leave Without Pay",
+ half_day=1,
+ half_day_date=add_days(first_sunday, 1),
+ submit=True,
+ )
+
+ frappe.db.set_value("Leave Type", "Leave Without Pay", "include_holiday", 0)
+ salary_structure = make_salary_structure(
+ "Test Employee Benefits",
+ "Monthly",
+ other_details={"max_benefits": 100000},
+ include_flexi_benefits=True,
+ employee=employee,
+ payroll_period=payroll_period,
+ )
+ salary_slip = make_salary_slip(salary_structure.name, employee=employee, posting_date=getdate())
+ salary_slip.insert()
+ salary_slip.submit()
+
+ application = make_employee_benefit_application(
+ employee, payroll_period.name, date=leave_application.to_date
+ )
+ self.assertEqual(application.employee_benefits[0].max_benefit_amount, 15000)
+
+ holidays = get_holiday_dates_for_employee(employee, payroll_period.start_date, application.date)
+ working_days = date_diff(application.date, payroll_period.start_date) + 1
+ lwp = calculate_lwp(employee, payroll_period.start_date, holidays, working_days)
+ self.assertEqual(lwp, 2.5)
+
+
+def make_employee_benefit_application(employee, payroll_period, date):
+ frappe.db.delete("Employee Benefit Application")
+
+ return frappe.get_doc(
+ {
+ "doctype": "Employee Benefit Application",
+ "employee": employee,
+ "date": date,
+ "payroll_period": payroll_period,
+ "employee_benefits": [{"earning_component": "Medical Allowance", "amount": 1500}],
+ }
+ ).insert()
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
index c0ef2eee78c..3d1d96598fc 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/employee_tax_exemption_declaration.py
@@ -33,7 +33,9 @@ class EmployeeTaxExemptionDeclaration(Document):
self.total_declared_amount += flt(d.amount)
def set_total_exemption_amount(self):
- self.total_exemption_amount = get_total_exemption_amount(self.declarations)
+ self.total_exemption_amount = flt(
+ get_total_exemption_amount(self.declarations), self.precision("total_exemption_amount")
+ )
def calculate_hra_exemption(self):
self.salary_structure_hra, self.annual_hra_exemption, self.monthly_hra_exemption = 0, 0, 0
@@ -41,9 +43,18 @@ class EmployeeTaxExemptionDeclaration(Document):
hra_exemption = calculate_annual_eligible_hra_exemption(self)
if hra_exemption:
self.total_exemption_amount += hra_exemption["annual_exemption"]
- self.salary_structure_hra = hra_exemption["hra_amount"]
- self.annual_hra_exemption = hra_exemption["annual_exemption"]
- self.monthly_hra_exemption = hra_exemption["monthly_exemption"]
+ self.total_exemption_amount = flt(
+ self.total_exemption_amount, self.precision("total_exemption_amount")
+ )
+ self.salary_structure_hra = flt(
+ hra_exemption["hra_amount"], self.precision("salary_structure_hra")
+ )
+ self.annual_hra_exemption = flt(
+ hra_exemption["annual_exemption"], self.precision("annual_hra_exemption")
+ )
+ self.monthly_hra_exemption = flt(
+ hra_exemption["monthly_exemption"], self.precision("monthly_hra_exemption")
+ )
@frappe.whitelist()
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
index 1d90e7383fe..2d8df350118 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_declaration/test_employee_tax_exemption_declaration.py
@@ -4,25 +4,28 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_months, getdate
import erpnext
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.utils import DuplicateDeclarationError
-class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
+class TestEmployeeTaxExemptionDeclaration(FrappeTestCase):
def setUp(self):
- make_employee("employee@taxexepmtion.com")
- make_employee("employee1@taxexepmtion.com")
- create_payroll_period()
+ make_employee("employee@taxexemption.com", company="_Test Company")
+ make_employee("employee1@taxexemption.com", company="_Test Company")
+ create_payroll_period(company="_Test Company")
create_exemption_category()
- frappe.db.sql("""delete from `tabEmployee Tax Exemption Declaration`""")
+ frappe.db.delete("Employee Tax Exemption Declaration")
+ frappe.db.delete("Salary Structure Assignment")
def test_duplicate_category_in_declaration(self):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -46,7 +49,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -68,7 +71,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
duplicate_declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -83,7 +86,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
)
self.assertRaises(DuplicateDeclarationError, duplicate_declaration.insert)
duplicate_declaration.employee = frappe.get_value(
- "Employee", {"user_id": "employee1@taxexepmtion.com"}, "name"
+ "Employee", {"user_id": "employee1@taxexemption.com"}, "name"
)
self.assertTrue(duplicate_declaration.insert)
@@ -91,7 +94,7 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
declaration = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Declaration",
- "employee": frappe.get_value("Employee", {"user_id": "employee@taxexepmtion.com"}, "name"),
+ "employee": frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name"),
"company": erpnext.get_default_company(),
"payroll_period": "_Test Payroll Period",
"currency": erpnext.get_default_currency(),
@@ -112,6 +115,298 @@ class TestEmployeeTaxExemptionDeclaration(unittest.TestCase):
self.assertEqual(declaration.total_exemption_amount, 100000)
+ def test_india_hra_exemption(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Monthly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ amount=80000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Monthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 3000)
+ self.assertEqual(declaration.annual_hra_exemption, 36000)
+ # 100000 Standard Exemption + 36000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 136000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_daily_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Daily")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Daily HRA received = 3000
+ # should set HRA exemption as per (rent - 10% of Basic Salary), that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 17916.67)
+ self.assertEqual(declaration.annual_hra_exemption, 215000)
+ # 50000 Standard Exemption + 215000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 265000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_weekly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Weekly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Weekly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 13000)
+ self.assertEqual(declaration.annual_hra_exemption, 156000)
+ # 50000 Standard Exemption + 156000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 206000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_fortnightly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Fortnightly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 170000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Fortnightly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 6500)
+ self.assertEqual(declaration.annual_hra_exemption, 78000)
+ # 50000 Standard Exemption + 78000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 128000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_bimonthly_payroll_frequency(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ setup_hra_exemption_prerequisites("Bimonthly")
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ amount=80000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Bimonthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 1500)
+ self.assertEqual(declaration.annual_hra_exemption, 18000)
+ # 100000 Standard Exemption + 18000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 118000)
+
+ # reset
+ frappe.flags.country = current_country
+
+ def test_india_hra_exemption_with_multiple_salary_structure_assignments(self):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import create_tax_slab
+ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
+ create_salary_structure_assignment,
+ make_salary_structure,
+ )
+
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ employee = make_employee("employee@taxexemption2.com", company="_Test Company")
+ payroll_period = create_payroll_period(name="_Test Payroll Period", company="_Test Company")
+
+ create_tax_slab(
+ payroll_period,
+ allow_tax_exemption=True,
+ currency="INR",
+ effective_date=getdate("2019-04-01"),
+ company="_Test Company",
+ )
+
+ frappe.db.set_value(
+ "Company", "_Test Company", {"basic_component": "Basic Salary", "hra_component": "HRA"}
+ )
+
+ # salary structure with base 50000, HRA 3000
+ make_salary_structure(
+ "Monthly Structure for HRA Exemption 1",
+ "Monthly",
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ from_date=payroll_period.start_date,
+ )
+
+ # salary structure with base 70000, HRA = base * 0.2 = 14000
+ salary_structure = make_salary_structure(
+ "Monthly Structure for HRA Exemption 2",
+ "Monthly",
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ from_date=payroll_period.start_date,
+ dont_submit=True,
+ )
+ for component_row in salary_structure.earnings:
+ if component_row.salary_component == "HRA":
+ component_row.amount = 0
+ component_row.amount_based_on_formula = 1
+ component_row.formula = "base * 0.2"
+ break
+
+ salary_structure.submit()
+
+ create_salary_structure_assignment(
+ employee,
+ salary_structure.name,
+ from_date=add_months(payroll_period.start_date, 6),
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period.name,
+ base=70000,
+ allow_duplicate=True,
+ )
+
+ declaration = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Declaration",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": payroll_period.name,
+ "currency": "INR",
+ "monthly_house_rent": 50000,
+ "rented_in_metro_city": 1,
+ "declarations": [
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ amount=60000,
+ ),
+ ],
+ }
+ ).insert()
+
+ # Monthly HRA received = 50000 * 6 months + 70000 * 6 months
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(declaration.monthly_hra_exemption, 8500)
+ self.assertEqual(declaration.annual_hra_exemption, 102000)
+ # 50000 Standard Exemption + 102000 HRA exemption
+ self.assertEqual(declaration.total_exemption_amount, 152000)
+
+ # reset
+ frappe.flags.country = current_country
+
def create_payroll_period(**args):
args = frappe._dict(args)
@@ -163,3 +458,33 @@ def create_exemption_category():
"is_active": 1,
}
).insert()
+
+
+def setup_hra_exemption_prerequisites(frequency, employee=None):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import create_tax_slab
+ from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
+
+ payroll_period = create_payroll_period(name="_Test Payroll Period", company="_Test Company")
+ if not employee:
+ employee = frappe.get_value("Employee", {"user_id": "employee@taxexemption.com"}, "name")
+
+ create_tax_slab(
+ payroll_period,
+ allow_tax_exemption=True,
+ currency="INR",
+ effective_date=getdate("2019-04-01"),
+ company="_Test Company",
+ )
+
+ make_salary_structure(
+ f"{frequency} Structure for HRA Exemption",
+ frequency,
+ employee=employee,
+ company="_Test Company",
+ currency="INR",
+ payroll_period=payroll_period,
+ )
+
+ frappe.db.set_value(
+ "Company", "_Test Company", {"basic_component": "Basic Salary", "hra_component": "HRA"}
+ )
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
index c52efaba592..b3b66b9e7b1 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/employee_tax_exemption_proof_submission.py
@@ -31,7 +31,9 @@ class EmployeeTaxExemptionProofSubmission(Document):
self.total_actual_amount += flt(d.amount)
def set_total_exemption_amount(self):
- self.exemption_amount = get_total_exemption_amount(self.tax_exemption_proofs)
+ self.exemption_amount = flt(
+ get_total_exemption_amount(self.tax_exemption_proofs), self.precision("exemption_amount")
+ )
def calculate_hra_exemption(self):
self.monthly_hra_exemption, self.monthly_house_rent, self.total_eligible_hra_exemption = 0, 0, 0
@@ -39,6 +41,13 @@ class EmployeeTaxExemptionProofSubmission(Document):
hra_exemption = calculate_hra_exemption_for_period(self)
if hra_exemption:
self.exemption_amount += hra_exemption["total_eligible_hra_exemption"]
- self.monthly_hra_exemption = hra_exemption["monthly_exemption"]
- self.monthly_house_rent = hra_exemption["monthly_house_rent"]
- self.total_eligible_hra_exemption = hra_exemption["total_eligible_hra_exemption"]
+ self.exemption_amount = flt(self.exemption_amount, self.precision("exemption_amount"))
+ self.monthly_hra_exemption = flt(
+ hra_exemption["monthly_exemption"], self.precision("monthly_hra_exemption")
+ )
+ self.monthly_house_rent = flt(
+ hra_exemption["monthly_house_rent"], self.precision("monthly_house_rent")
+ )
+ self.total_eligible_hra_exemption = flt(
+ hra_exemption["total_eligible_hra_exemption"], self.precision("total_eligible_hra_exemption")
+ )
diff --git a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
index 58b2c1af058..416cf316c97 100644
--- a/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
+++ b/erpnext/payroll/doctype/employee_tax_exemption_proof_submission/test_employee_tax_exemption_proof_submission.py
@@ -4,22 +4,26 @@
import unittest
import frappe
+from frappe.tests.utils import FrappeTestCase
+from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration import (
create_exemption_category,
create_payroll_period,
+ setup_hra_exemption_prerequisites,
)
-class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
- def setup(self):
- make_employee("employee@proofsubmission.com")
- create_payroll_period()
+class TestEmployeeTaxExemptionProofSubmission(FrappeTestCase):
+ def setUp(self):
+ make_employee("employee@proofsubmission.com", company="_Test Company")
+ create_payroll_period(company="_Test Company")
create_exemption_category()
- frappe.db.sql("""delete from `tabEmployee Tax Exemption Proof Submission`""")
+ frappe.db.delete("Employee Tax Exemption Proof Submission")
+ frappe.db.delete("Salary Structure Assignment")
def test_exemption_amount_lesser_than_category_max(self):
- declaration = frappe.get_doc(
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"),
@@ -34,8 +38,8 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertRaises(frappe.ValidationError, declaration.save)
- declaration = frappe.get_doc(
+ self.assertRaises(frappe.ValidationError, proof.save)
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"payroll_period": "Test Payroll Period",
@@ -50,11 +54,11 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertTrue(declaration.save)
- self.assertTrue(declaration.submit)
+ self.assertTrue(proof.save)
+ self.assertTrue(proof.submit)
def test_duplicate_category_in_proof_submission(self):
- declaration = frappe.get_doc(
+ proof = frappe.get_doc(
{
"doctype": "Employee Tax Exemption Proof Submission",
"employee": frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name"),
@@ -74,4 +78,59 @@ class TestEmployeeTaxExemptionProofSubmission(unittest.TestCase):
],
}
)
- self.assertRaises(frappe.ValidationError, declaration.save)
+ self.assertRaises(frappe.ValidationError, proof.save)
+
+ def test_india_hra_exemption(self):
+ # set country
+ current_country = frappe.flags.country
+ frappe.flags.country = "India"
+
+ employee = frappe.get_value("Employee", {"user_id": "employee@proofsubmission.com"}, "name")
+ setup_hra_exemption_prerequisites("Monthly", employee)
+ payroll_period = frappe.db.get_value(
+ "Payroll Period", "_Test Payroll Period", ["start_date", "end_date"], as_dict=True
+ )
+
+ proof = frappe.get_doc(
+ {
+ "doctype": "Employee Tax Exemption Proof Submission",
+ "employee": employee,
+ "company": "_Test Company",
+ "payroll_period": "_Test Payroll Period",
+ "currency": "INR",
+ "house_rent_payment_amount": 600000,
+ "rented_in_metro_city": 1,
+ "rented_from_date": payroll_period.start_date,
+ "rented_to_date": payroll_period.end_date,
+ "tax_exemption_proofs": [
+ dict(
+ exemption_sub_category="_Test Sub Category",
+ exemption_category="_Test Category",
+ type_of_proof="Test Proof",
+ amount=100000,
+ ),
+ dict(
+ exemption_sub_category="_Test1 Sub Category",
+ exemption_category="_Test Category",
+ type_of_proof="Test Proof",
+ amount=50000,
+ ),
+ ],
+ }
+ ).insert()
+
+ self.assertEqual(proof.monthly_house_rent, 50000)
+
+ # Monthly HRA received = 3000
+ # should set HRA exemption as per actual annual HRA because that's the minimum
+ self.assertEqual(proof.monthly_hra_exemption, 3000)
+ self.assertEqual(proof.total_eligible_hra_exemption, 36000)
+
+ # total exemptions + house rent payment amount
+ self.assertEqual(proof.total_actual_amount, 750000)
+
+ # 100000 Standard Exemption + 36000 HRA exemption
+ self.assertEqual(proof.exemption_amount, 136000)
+
+ # reset
+ frappe.flags.country = current_country
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json
index 5cffd7eebf9..0d823cac06c 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.json
+++ b/erpnext/payroll/doctype/gratuity/gratuity.json
@@ -90,9 +90,8 @@
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
- "options": "Draft\nUnpaid\nPaid",
- "read_only": 1,
- "reqd": 1
+ "options": "Draft\nUnpaid\nPaid\nSubmitted\nCancelled",
+ "read_only": 1
},
{
"depends_on": "eval: doc.pay_via_salary_slip == 0",
@@ -196,7 +195,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2020-11-02 18:21:11.971488",
+ "modified": "2022-05-27 13:56:14.349183",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Gratuity",
diff --git a/erpnext/payroll/doctype/gratuity/gratuity_list.js b/erpnext/payroll/doctype/gratuity/gratuity_list.js
new file mode 100644
index 00000000000..20e3d5b4e52
--- /dev/null
+++ b/erpnext/payroll/doctype/gratuity/gratuity_list.js
@@ -0,0 +1,12 @@
+frappe.listview_settings["Gratuity"] = {
+ get_indicator: function(doc) {
+ let status_color = {
+ "Draft": "red",
+ "Submitted": "blue",
+ "Cancelled": "red",
+ "Paid": "green",
+ "Unpaid": "orange",
+ };
+ return [__(doc.status), status_color[doc.status], "status,=,"+doc.status];
+ }
+};
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py
index 0e39dd36710..67313feb5a2 100644
--- a/erpnext/payroll/doctype/gratuity/test_gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py
@@ -4,58 +4,70 @@
import unittest
import frappe
-from frappe.utils import add_days, flt, get_datetime, getdate
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, add_months, floor, flt, get_datetime, get_first_day, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.expense_claim.test_expense_claim import get_payable_account
+from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.payroll.doctype.gratuity.gratuity import get_last_salary_slip
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_deduction_salary_component,
make_earning_salary_component,
make_employee_salary_slip,
+ make_holiday_list,
)
+from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
from erpnext.regional.united_arab_emirates.setup import create_gratuity_rule
test_dependencies = ["Salary Component", "Salary Slip", "Account"]
-class TestGratuity(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- make_earning_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
- make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
-
+class TestGratuity(FrappeTestCase):
def setUp(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
- frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'")
+ frappe.db.delete("Gratuity")
+ frappe.db.delete("Salary Slip")
+ frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
+ make_earning_salary_component(
+ setup=True, test_tax=True, company_list=["_Test Company"], include_flexi_benefits=True
+ )
+ make_deduction_salary_component(setup=True, test_tax=True, company_list=["_Test Company"])
+ make_holiday_list()
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
new_employee = make_employee("new_employee@salary.com", company="_Test Company")
salary_slip = get_last_salary_slip(new_employee)
- assert salary_slip is None
+ self.assertIsNone(salary_slip)
- def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self):
- employee, sal_slip = create_employee_and_get_last_salary_slip()
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_gratuity_based_on_current_slab_via_additional_salary(self):
+ """
+ Range | Fraction
+ 5-0 | 1
+ """
+ doj = add_days(getdate(), -(6 * 365))
+ relieving_date = getdate()
+
+ employee = make_employee(
+ "test_employee_gratuity@salary.com",
+ company="_Test Company",
+ date_of_joining=doj,
+ relieving_date=relieving_date,
+ )
+ sal_slip = create_salary_slip("test_employee_gratuity@salary.com")
rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)")
gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name)
# work experience calculation
- date_of_joining, relieving_date = frappe.db.get_value(
- "Employee", employee, ["date_of_joining", "relieving_date"]
- )
- employee_total_workings_days = (
- get_datetime(relieving_date) - get_datetime(date_of_joining)
- ).days
+ employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(doj)).days
+ experience = floor(employee_total_workings_days / rule.total_working_days_per_year)
+ self.assertEqual(gratuity.current_work_experience, experience)
- experience = employee_total_workings_days / rule.total_working_days_per_year
- gratuity.reload()
- from math import floor
-
- self.assertEqual(floor(experience), gratuity.current_work_experience)
-
- # amount Calculation
+ # amount calculation
component_amount = frappe.get_all(
"Salary Detail",
filters={
@@ -65,20 +77,44 @@ class TestGratuity(unittest.TestCase):
"salary_component": "Basic Salary",
},
fields=["amount"],
+ limit=1,
)
-
- """ 5 - 0 fraction is 1 """
-
gratuity_amount = component_amount[0].amount * experience
- gratuity.reload()
-
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
# additional salary creation (Pay via salary slip)
self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name}))
- def test_check_gratuity_amount_based_on_all_previous_slabs(self):
- employee, sal_slip = create_employee_and_get_last_salary_slip()
+ # gratuity should be marked "Paid" on the next salary slip submission
+ salary_slip = make_salary_slip("Test Gratuity", employee=employee)
+ salary_slip.posting_date = getdate()
+ salary_slip.insert()
+ salary_slip.submit()
+
+ gratuity.reload()
+ self.assertEqual(gratuity.status, "Paid")
+
+ @set_holiday_list("Salary Slip Test Holiday List", "_Test Company")
+ def test_gratuity_based_on_all_previous_slabs_via_payment_entry(self):
+ """
+ Range | Fraction
+ 0-1 | 0
+ 1-5 | 0.7
+ 5-0 | 1
+ """
+ from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+
+ doj = add_days(getdate(), -(6 * 365))
+ relieving_date = getdate()
+
+ employee = make_employee(
+ "test_employee_gratuity@salary.com",
+ company="_Test Company",
+ date_of_joining=doj,
+ relieving_date=relieving_date,
+ )
+
+ sal_slip = create_salary_slip("test_employee_gratuity@salary.com")
rule = get_gratuity_rule("Rule Under Limited Contract (UAE)")
set_mode_of_payment_account()
@@ -87,22 +123,11 @@ class TestGratuity(unittest.TestCase):
)
# work experience calculation
- date_of_joining, relieving_date = frappe.db.get_value(
- "Employee", employee, ["date_of_joining", "relieving_date"]
- )
- employee_total_workings_days = (
- get_datetime(relieving_date) - get_datetime(date_of_joining)
- ).days
+ employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(doj)).days
+ experience = floor(employee_total_workings_days / rule.total_working_days_per_year)
+ self.assertEqual(gratuity.current_work_experience, experience)
- experience = employee_total_workings_days / rule.total_working_days_per_year
-
- gratuity.reload()
-
- from math import floor
-
- self.assertEqual(floor(experience), gratuity.current_work_experience)
-
- # amount Calculation
+ # amount calculation
component_amount = frappe.get_all(
"Salary Detail",
filters={
@@ -112,36 +137,22 @@ class TestGratuity(unittest.TestCase):
"salary_component": "Basic Salary",
},
fields=["amount"],
+ limit=1,
)
- """ range | Fraction
- 0-1 | 0
- 1-5 | 0.7
- 5-0 | 1
- """
-
gratuity_amount = ((0 * 1) + (4 * 0.7) + (1 * 1)) * component_amount[0].amount
- gratuity.reload()
-
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
self.assertEqual(gratuity.status, "Unpaid")
- from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+ pe = get_payment_entry("Gratuity", gratuity.name)
+ pe.reference_no = "123467"
+ pe.reference_date = getdate()
+ pe.submit()
- pay_entry = get_payment_entry("Gratuity", gratuity.name)
- pay_entry.reference_no = "123467"
- pay_entry.reference_date = getdate()
- pay_entry.save()
- pay_entry.submit()
gratuity.reload()
-
self.assertEqual(gratuity.status, "Paid")
self.assertEqual(flt(gratuity.paid_amount, 2), flt(gratuity.amount, 2))
- def tearDown(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
- frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'")
-
def get_gratuity_rule(name):
rule = frappe.db.exists("Gratuity Rule", name)
@@ -151,7 +162,6 @@ def get_gratuity_rule(name):
rule.applicable_earnings_component = []
rule.append("applicable_earnings_component", {"salary_component": "Basic Salary"})
rule.save()
- rule.reload()
return rule
@@ -206,23 +216,17 @@ def create_account():
).insert(ignore_permissions=True)
-def create_employee_and_get_last_salary_slip():
- employee = make_employee("test_employee@salary.com", company="_Test Company")
- frappe.db.set_value("Employee", employee, "relieving_date", getdate())
- frappe.db.set_value("Employee", employee, "date_of_joining", add_days(getdate(), -(6 * 365)))
+def create_salary_slip(employee):
if not frappe.db.exists("Salary Slip", {"employee": employee}):
- salary_slip = make_employee_salary_slip("test_employee@salary.com", "Monthly")
+ posting_date = get_first_day(add_months(getdate(), -1))
+ salary_slip = make_employee_salary_slip(
+ employee, "Monthly", "Test Gratuity", posting_date=posting_date
+ )
+ salary_slip.start_date = posting_date
+ salary_slip.end_date = None
salary_slip.submit()
salary_slip = salary_slip.name
else:
salary_slip = get_last_salary_slip(employee)
- if not frappe.db.get_value("Employee", "test_employee@salary.com", "holiday_list"):
- from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
-
- make_holiday_list()
- frappe.db.set_value(
- "Company", "_Test Company", "default_holiday_list", "Salary Slip Test Holiday List"
- )
-
- return employee, salary_slip
+ return salary_slip
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index 60d38f4ca49..5f2af74dca6 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -15,6 +15,7 @@ from frappe.utils import (
comma_and,
date_diff,
flt,
+ get_link_to_form,
getdate,
)
@@ -44,6 +45,7 @@ class PayrollEntry(Document):
def before_submit(self):
self.validate_employee_details()
+ self.validate_payroll_payable_account()
if self.validate_attendance:
if self.validate_employee_attendance():
frappe.throw(_("Cannot Submit, Employees left to mark attendance"))
@@ -65,6 +67,14 @@ class PayrollEntry(Document):
if len(emp_with_sal_slip):
frappe.throw(_("Salary Slip already exists for {0}").format(comma_and(emp_with_sal_slip)))
+ def validate_payroll_payable_account(self):
+ if frappe.db.get_value("Account", self.payroll_payable_account, "account_type"):
+ frappe.throw(
+ _(
+ "Account type cannot be set for payroll payable account {0}, please remove and try again"
+ ).format(frappe.bold(get_link_to_form("Account", self.payroll_payable_account)))
+ )
+
def on_cancel(self):
frappe.delete_doc(
"Salary Slip",
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index c0932c951bb..f3ed5f9e2b9 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -5,6 +5,7 @@ import unittest
import frappe
from dateutil.relativedelta import relativedelta
+from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_months
import erpnext
@@ -35,7 +36,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
test_dependencies = ["Holiday List"]
-class TestPayrollEntry(unittest.TestCase):
+class TestPayrollEntry(FrappeTestCase):
@classmethod
def setUpClass(cls):
frappe.db.set_value(
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index b3e4ad54aa5..fcb415c00ed 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -30,6 +30,9 @@ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
calculate_amounts,
create_repayment_entry,
)
+from erpnext.loan_management.doctype.process_loan_interest_accrual.process_loan_interest_accrual import (
+ process_loan_interest_accrual_for_term_loans,
+)
from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salaries
from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import (
get_benefit_component_amount,
@@ -117,10 +120,10 @@ class SalarySlip(TransactionBase):
self.update_payment_status_for_gratuity()
def update_payment_status_for_gratuity(self):
- add_salary = frappe.db.get_all(
+ additional_salary = frappe.db.get_all(
"Additional Salary",
filters={
- "payroll_date": ("BETWEEN", [self.start_date, self.end_date]),
+ "payroll_date": ("between", [self.start_date, self.end_date]),
"employee": self.employee,
"ref_doctype": "Gratuity",
"docstatus": 1,
@@ -129,10 +132,10 @@ class SalarySlip(TransactionBase):
limit=1,
)
- if len(add_salary):
+ if additional_salary:
status = "Paid" if self.docstatus == 1 else "Unpaid"
- if add_salary[0].name in [data.additional_salary for data in self.earnings]:
- frappe.db.set_value("Gratuity", add_salary.ref_docname, "status", status)
+ if additional_salary[0].name in [entry.additional_salary for entry in self.earnings]:
+ frappe.db.set_value("Gratuity", additional_salary[0].ref_docname, "status", status)
def on_cancel(self):
self.set_status()
@@ -463,37 +466,14 @@ class SalarySlip(TransactionBase):
)
for d in range(working_days):
- dt = add_days(cstr(getdate(self.start_date)), d)
- leave = frappe.db.sql(
- """
- SELECT t1.name,
- CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date)
- THEN t1.half_day else 0 END,
- t2.is_ppl,
- t2.fraction_of_daily_salary_per_leave
- FROM `tabLeave Application` t1, `tabLeave Type` t2
- WHERE t2.name = t1.leave_type
- AND (t2.is_lwp = 1 or t2.is_ppl = 1)
- AND t1.docstatus = 1
- AND t1.employee = %(employee)s
- AND ifnull(t1.salary_slip, '') = ''
- AND CASE
- WHEN t2.include_holiday != 1
- THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date
- WHEN t2.include_holiday
- THEN %(dt)s between from_date and to_date
- END
- """.format(
- holidays
- ),
- {"employee": self.employee, "dt": dt},
- )
+ date = add_days(cstr(getdate(self.start_date)), d)
+ leave = get_lwp_or_ppl_for_date(date, self.employee, holidays)
if leave:
equivalent_lwp_count = 0
- is_half_day_leave = cint(leave[0][1])
- is_partially_paid_leave = cint(leave[0][2])
- fraction_of_daily_salary_per_leave = flt(leave[0][3])
+ is_half_day_leave = cint(leave[0].is_half_day)
+ is_partially_paid_leave = cint(leave[0].is_ppl)
+ fraction_of_daily_salary_per_leave = flt(leave[0].fraction_of_daily_salary_per_leave)
equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1
@@ -1405,9 +1385,9 @@ class SalarySlip(TransactionBase):
self.total_loan_repayment += payment.total_payment
def get_loan_details(self):
- return frappe.get_all(
+ loan_details = frappe.get_all(
"Loan",
- fields=["name", "interest_income_account", "loan_account", "loan_type"],
+ fields=["name", "interest_income_account", "loan_account", "loan_type", "is_term_loan"],
filters={
"applicant": self.employee,
"docstatus": 1,
@@ -1416,6 +1396,15 @@ class SalarySlip(TransactionBase):
},
)
+ if loan_details:
+ for loan in loan_details:
+ if loan.is_term_loan:
+ process_loan_interest_accrual_for_term_loans(
+ posting_date=self.posting_date, loan_type=loan.loan_type, loan=loan.name
+ )
+
+ return loan_details
+
def make_loan_repayment_entry(self):
payroll_payable_account = get_payroll_payable_account(self.company, self.payroll_entry)
for loan in self.loans:
@@ -1714,3 +1703,46 @@ def get_payroll_payable_account(company, payroll_entry):
)
return payroll_payable_account
+
+
+def get_lwp_or_ppl_for_date(date, employee, holidays):
+ LeaveApplication = frappe.qb.DocType("Leave Application")
+ LeaveType = frappe.qb.DocType("Leave Type")
+
+ is_half_day = (
+ frappe.qb.terms.Case()
+ .when(
+ (
+ (LeaveApplication.half_day_date == date)
+ | (LeaveApplication.from_date == LeaveApplication.to_date)
+ ),
+ LeaveApplication.half_day,
+ )
+ .else_(0)
+ ).as_("is_half_day")
+
+ query = (
+ frappe.qb.from_(LeaveApplication)
+ .inner_join(LeaveType)
+ .on((LeaveType.name == LeaveApplication.leave_type))
+ .select(
+ LeaveApplication.name,
+ LeaveType.is_ppl,
+ LeaveType.fraction_of_daily_salary_per_leave,
+ (is_half_day),
+ )
+ .where(
+ (((LeaveType.is_lwp == 1) | (LeaveType.is_ppl == 1)))
+ & (LeaveApplication.docstatus == 1)
+ & (LeaveApplication.status == "Approved")
+ & (LeaveApplication.employee == employee)
+ & ((LeaveApplication.salary_slip.isnull()) | (LeaveApplication.salary_slip == ""))
+ & ((LeaveApplication.from_date <= date) & (date <= LeaveApplication.to_date))
+ )
+ )
+
+ # if it's a holiday only include if leave type has "include holiday" enabled
+ if date in holidays:
+ query = query.where((LeaveType.include_holiday == "1"))
+
+ return query.run(as_dict=True)
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 0abf58b062c..1f17138d0c8 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -49,7 +49,7 @@ class TestSalarySlip(unittest.TestCase):
"Payroll Settings", {"payroll_based_on": "Attendance", "daily_wages_fraction_for_half_day": 0.75}
)
def test_payment_days_based_on_attendance(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_attendance@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -128,7 +128,7 @@ class TestSalarySlip(unittest.TestCase):
},
)
def test_payment_days_for_mid_joinee_including_holidays(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -196,7 +196,7 @@ class TestSalarySlip(unittest.TestCase):
# tests mid month joining and relieving along with unmarked days
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -236,7 +236,7 @@ class TestSalarySlip(unittest.TestCase):
def test_payment_days_for_mid_joinee_excluding_holidays(self):
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
@@ -267,7 +267,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"payroll_based_on": "Leave"})
def test_payment_days_based_on_leave_application(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com")
frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
@@ -366,7 +366,7 @@ class TestSalarySlip(unittest.TestCase):
salary_slip.submit()
salary_slip.reload()
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
days_in_month = no_of_days[0]
no_of_holidays = no_of_days[1]
@@ -387,7 +387,7 @@ class TestSalarySlip(unittest.TestCase):
create_salary_structure_assignment,
)
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
salary_structure = make_salary_structure_for_payment_days_based_component_dependency()
employee = make_employee("test_payment_days_based_component@salary.com", company="_Test Company")
@@ -445,7 +445,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 1})
def test_salary_slip_with_holidays_included(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_included@salary.com")
frappe.db.set_value(
"Employee",
@@ -477,7 +477,7 @@ class TestSalarySlip(unittest.TestCase):
@change_settings("Payroll Settings", {"include_holidays_in_total_working_days": 0})
def test_salary_slip_with_holidays_excluded(self):
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
make_employee("test_salary_slip_with_holidays_excluded@salary.com")
frappe.db.set_value(
"Employee",
@@ -514,7 +514,7 @@ class TestSalarySlip(unittest.TestCase):
create_salary_structure_assignment,
)
- no_of_days = self.get_no_of_days()
+ no_of_days = get_no_of_days()
# set joinng date in the same month
employee = make_employee("test_payment_days@salary.com")
@@ -842,6 +842,7 @@ class TestSalarySlip(unittest.TestCase):
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
+ include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@@ -945,6 +946,7 @@ class TestSalarySlip(unittest.TestCase):
"Monthly",
other_details={"max_benefits": 100000},
test_tax=True,
+ include_flexi_benefits=True,
employee=employee,
payroll_period=payroll_period,
)
@@ -986,20 +988,21 @@ class TestSalarySlip(unittest.TestCase):
activity_type.wage_rate = 25
activity_type.save()
- def get_no_of_days(self):
- no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
- no_of_holidays_in_month = len(
- [
- 1
- for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
- if i[6] != 0
- ]
- )
- return [no_of_days_in_month[1], no_of_holidays_in_month]
+def get_no_of_days():
+ no_of_days_in_month = calendar.monthrange(getdate(nowdate()).year, getdate(nowdate()).month)
+ no_of_holidays_in_month = len(
+ [
+ 1
+ for i in calendar.monthcalendar(getdate(nowdate()).year, getdate(nowdate()).month)
+ if i[6] != 0
+ ]
+ )
+
+ return [no_of_days_in_month[1], no_of_holidays_in_month]
-def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
+def make_employee_salary_slip(user, payroll_frequency, salary_structure=None, posting_date=None):
from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure
if not salary_structure:
@@ -1010,7 +1013,11 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
)
salary_structure_doc = make_salary_structure(
- salary_structure, payroll_frequency, employee=employee.name, company=employee.company
+ salary_structure,
+ payroll_frequency,
+ employee=employee.name,
+ company=employee.company,
+ from_date=posting_date,
)
salary_slip_name = frappe.db.get_value(
"Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}
@@ -1020,7 +1027,7 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None):
salary_slip = make_salary_slip(salary_structure_doc.name, employee=employee.name)
salary_slip.employee_name = employee.employee_name
salary_slip.payroll_frequency = payroll_frequency
- salary_slip.posting_date = nowdate()
+ salary_slip.posting_date = posting_date or nowdate()
salary_slip.insert()
else:
salary_slip = frappe.get_doc("Salary Slip", salary_slip_name)
@@ -1092,7 +1099,9 @@ def create_account(account_name, company, parent_account, account_type=None):
return account
-def make_earning_salary_component(setup=False, test_tax=False, company_list=None):
+def make_earning_salary_component(
+ setup=False, test_tax=False, company_list=None, include_flexi_benefits=False
+):
data = [
{
"salary_component": "Basic Salary",
@@ -1113,7 +1122,7 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
},
{"salary_component": "Leave Encashment", "abbr": "LE", "type": "Earning"},
]
- if test_tax:
+ if include_flexi_benefits:
data.extend(
[
{
@@ -1132,12 +1141,20 @@ def make_earning_salary_component(setup=False, test_tax=False, company_list=None
"pay_against_benefit_claim": 0,
"type": "Earning",
"max_benefit_amount": 15000,
+ "depends_on_payment_days": 1,
},
+ ]
+ )
+ if test_tax:
+ data.extend(
+ [
{"salary_component": "Performance Bonus", "abbr": "B", "type": "Earning"},
]
)
+
if setup or test_tax:
make_salary_component(data, test_tax, company_list)
+
data.append(
{
"salary_component": "Basic Salary",
@@ -1415,7 +1432,8 @@ def setup_test():
def make_holiday_list(list_name=None, from_date=None, to_date=None):
- fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company())
+ if not (from_date and to_date):
+ fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company())
name = list_name or "Salary Slip Test Holiday List"
frappe.delete_doc_if_exists("Holiday List", name, force=True)
diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.py b/erpnext/payroll/doctype/salary_structure/salary_structure.py
index c72ada630b0..ed2a863b3dc 100644
--- a/erpnext/payroll/doctype/salary_structure/salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/salary_structure.py
@@ -253,6 +253,7 @@ def make_salary_slip(
source_name,
target_doc=None,
employee=None,
+ posting_date=None,
as_print=False,
print_format=None,
for_preview=0,
@@ -277,6 +278,9 @@ def make_salary_slip(
"Department", target.department, "payroll_cost_center"
)
+ if posting_date:
+ target.posting_date = posting_date
+
target.run_method("process_salary_structure", for_preview=for_preview)
doc = get_mapped_doc(
diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
index def622bf80e..acc416fca3f 100644
--- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py
@@ -149,6 +149,7 @@ def make_salary_structure(
company=None,
currency=erpnext.get_default_currency(),
payroll_period=None,
+ include_flexi_benefits=False,
):
if test_tax:
frappe.db.sql("""delete from `tabSalary Structure` where name=%s""", (salary_structure))
@@ -161,7 +162,10 @@ def make_salary_structure(
"name": salary_structure,
"company": company or erpnext.get_default_company(),
"earnings": make_earning_salary_component(
- setup=True, test_tax=test_tax, company_list=["_Test Company"]
+ setup=True,
+ test_tax=test_tax,
+ company_list=["_Test Company"],
+ include_flexi_benefits=include_flexi_benefits,
),
"deductions": make_deduction_salary_component(
setup=True, test_tax=test_tax, company_list=["_Test Company"]
@@ -208,9 +212,12 @@ def create_salary_structure_assignment(
company=None,
currency=erpnext.get_default_currency(),
payroll_period=None,
+ base=None,
+ allow_duplicate=False,
):
-
- if frappe.db.exists("Salary Structure Assignment", {"employee": employee}):
+ if not allow_duplicate and frappe.db.exists(
+ "Salary Structure Assignment", {"employee": employee}
+ ):
frappe.db.sql("""delete from `tabSalary Structure Assignment` where employee=%s""", (employee))
if not payroll_period:
@@ -223,7 +230,7 @@ def create_salary_structure_assignment(
salary_structure_assignment = frappe.new_doc("Salary Structure Assignment")
salary_structure_assignment.employee = employee
- salary_structure_assignment.base = 50000
+ salary_structure_assignment.base = base or 50000
salary_structure_assignment.variable = 5000
if not from_date:
diff --git a/erpnext/projects/doctype/project/project.json b/erpnext/projects/doctype/project/project.json
index 2570df70261..4aeef81cbfb 100644
--- a/erpnext/projects/doctype/project/project.json
+++ b/erpnext/projects/doctype/project/project.json
@@ -234,7 +234,7 @@
},
{
"fieldname": "actual_start_date",
- "fieldtype": "Data",
+ "fieldtype": "Date",
"label": "Actual Start Date",
"read_only": 1
},
@@ -458,7 +458,7 @@
"index_web_pages_for_search": 1,
"links": [],
"max_attachments": 4,
- "modified": "2021-04-28 16:36:11.654632",
+ "modified": "2022-05-25 22:45:06.108499",
"modified_by": "Administrator",
"module": "Projects",
"name": "Project",
@@ -502,4 +502,4 @@
"timeline_field": "customer",
"title_field": "project_name",
"track_seen": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index 57bfd5b6074..7298c037a70 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -84,7 +84,9 @@ class TestTimesheet(unittest.TestCase):
emp = make_employee("test_employee_6@salary.com")
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
- sales_invoice = make_sales_invoice(timesheet.name, "_Test Item", "_Test Customer")
+ sales_invoice = make_sales_invoice(
+ timesheet.name, "_Test Item", "_Test Customer", currency="INR"
+ )
sales_invoice.due_date = nowdate()
sales_invoice.submit()
timesheet = frappe.get_doc("Timesheet", timesheet.name)
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index bf868e3a406..b86659dd2c2 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -74,6 +74,7 @@ erpnext.buying.BuyingController = erpnext.TransactionController.extend({
me.frm.set_query('supplier_address', erpnext.queries.address_query);
me.frm.set_query('billing_address', erpnext.queries.company_address_query);
+ erpnext.accounts.dimensions.setup_dimension_filters(me.frm, me.frm.doctype);
if(this.frm.fields_dict.supplier) {
this.frm.set_query("supplier", function() {
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index 2b1b0e3576b..fe23ff38126 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -767,11 +767,23 @@ erpnext.taxes_and_totals = erpnext.payments.extend({
if(this.frm.doc.is_pos && (update_paid_amount===undefined || update_paid_amount)) {
$.each(this.frm.doc['payments'] || [], function(index, data) {
if(data.default && payment_status && total_amount_to_pay > 0) {
- let base_amount = flt(total_amount_to_pay, precision("base_amount", data));
+ let base_amount, amount;
+
+ if (me.frm.doc.party_account_currency == me.frm.doc.currency) {
+ // if customer/supplier currency is same as company currency
+ // total_amount_to_pay is already in customer/supplier currency
+ // so base_amount has to be calculated using total_amount_to_pay
+ base_amount = flt(total_amount_to_pay * me.frm.doc.conversion_rate, precision("base_amount", data));
+ amount = flt(total_amount_to_pay, precision("amount", data));
+ } else {
+ base_amount = flt(total_amount_to_pay, precision("base_amount", data));
+ amount = flt(total_amount_to_pay / me.frm.doc.conversion_rate, precision("amount", data));
+ }
+
frappe.model.set_value(data.doctype, data.name, "base_amount", base_amount);
- let amount = flt(total_amount_to_pay / me.frm.doc.conversion_rate, precision("amount", data));
frappe.model.set_value(data.doctype, data.name, "amount", amount);
payment_status = false;
+
} else if(me.frm.doc.paid_amount) {
frappe.model.set_value(data.doctype, data.name, "amount", 0.0);
}
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index fa9a48020f2..2d6e15b15f7 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -1055,7 +1055,11 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
} else {
// company currency and doc currency is same
// this will prevent unnecessary conversion rate triggers
- this.frm.set_value("conversion_rate", 1.0);
+ if(this.frm.doc.currency === this.get_company_currency()) {
+ this.frm.set_value("conversion_rate", 1.0);
+ } else {
+ this.conversion_rate();
+ }
}
},
diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
index 8c891c886ab..579540993e2 100644
--- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
+++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
@@ -149,7 +149,6 @@ class GSTR3BReport(Document):
FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i
WHERE p.docstatus = 1 and p.name = i.parent
and p.is_opening = 'No'
- and p.gst_category != 'Registered Composition'
and (i.is_nil_exempt = 1 or i.is_non_gst = 1 or p.gst_category = 'Registered Composition') and
month(p.posting_date) = %s and year(p.posting_date) = %s
and p.company = %s and p.company_gstin = %s
diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js
index 4748b265dc2..580e6469e2c 100644
--- a/erpnext/regional/india/e_invoice/einvoice.js
+++ b/erpnext/regional/india/e_invoice/einvoice.js
@@ -11,7 +11,7 @@ erpnext.setup_einvoice_actions = (doctype) => {
if (!invoice_eligible) return;
- const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, __unsaved } = frm.doc;
+ const { doctype, irn, irn_cancelled, ewaybill, eway_bill_cancelled, name, qrcode_image, __unsaved } = frm.doc;
const add_custom_button = (label, action) => {
if (!frm.custom_buttons[label]) {
@@ -150,52 +150,72 @@ erpnext.setup_einvoice_actions = (doctype) => {
if (irn && ewaybill && !irn_cancelled && !eway_bill_cancelled) {
const action = () => {
- let message = __('Cancellation of e-way bill is currently not supported.') + ' ';
- message += '
';
- message += __('You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system.');
-
- const dialog = frappe.msgprint({
- title: __('Update E-Way Bill Cancelled Status?'),
- message: message,
- indicator: 'orange',
- primary_action: {
- action: function() {
- frappe.call({
- method: 'erpnext.regional.india.e_invoice.utils.cancel_eway_bill',
- args: { doctype, docname: name },
- freeze: true,
- callback: () => frm.reload_doc() && dialog.hide()
- });
- }
+ // This confirm is added to just reduce unnecesory API calls. All required logic is implemented on server side.
+ frappe.confirm(
+ __("Have you cancelled e-way bill on the portal?"),
+ () => {
+ frappe.call({
+ method: "erpnext.regional.india.e_invoice.utils.cancel_eway_bill",
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc(),
+ });
},
- primary_action_label: __('Yes')
- });
+ () => {
+ frappe.show_alert(
+ {
+ message: __(
+ "Please cancel e-way bill on the portal first."
+ ),
+ indicator: "orange",
+ },
+ 5
+ );
+ }
+ );
};
add_custom_button(__("Cancel E-Way Bill"), action);
}
if (irn && !irn_cancelled) {
- const action = () => {
- const dialog = frappe.msgprint({
- title: __("Generate QRCode"),
- message: __("Generate and attach QR Code using IRN?"),
- primary_action: {
- action: function() {
- frappe.call({
- method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode',
- args: { doctype, docname: name },
- freeze: true,
- callback: () => frm.reload_doc() || dialog.hide(),
- error: () => dialog.hide()
- });
+ let is_qrcode_attached = false;
+ if (qrcode_image && frm.attachments) {
+ let attachments = frm.attachments.get_attachments();
+ if (attachments.length != 0) {
+ for (let i = 0; i < attachments.length; i++) {
+ if (attachments[i].file_url == qrcode_image) {
+ is_qrcode_attached = true;
+ break;
}
- },
+ }
+ }
+ }
+ if (!is_qrcode_attached) {
+ const action = () => {
+ if (frm.doc.__unsaved) {
+ frappe.throw(__('Please save the document to generate QRCode.'));
+ }
+ const dialog = frappe.msgprint({
+ title: __("Generate QRCode"),
+ message: __("Generate and attach QR Code using IRN?"),
+ primary_action: {
+ action: function() {
+ frappe.call({
+ method: 'erpnext.regional.india.e_invoice.utils.generate_qrcode',
+ args: { doctype, docname: name },
+ freeze: true,
+ callback: () => frm.reload_doc() || dialog.hide(),
+ error: () => dialog.hide()
+ });
+ }
+ },
primary_action_label: __('Yes')
});
dialog.show();
};
add_custom_button(__("Generate QRCode"), action);
}
+ }
}
});
};
diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py
index e20a915bb22..871bf9027c1 100644
--- a/erpnext/regional/india/e_invoice/utils.py
+++ b/erpnext/regional/india/e_invoice/utils.py
@@ -802,6 +802,8 @@ class GSPConnector:
self.gstin_details_url = self.base_url + "/enriched/ei/api/master/gstin"
# cancel_ewaybill_url will only work if user have bought ewb api from adaequare.
self.cancel_ewaybill_url = self.base_url + "/enriched/ewb/ewayapi?action=CANEWB"
+ # ewaybill_details_url + ?irn={irn_number} will provide eway bill number and details.
+ self.ewaybill_details_url = self.base_url + "/enriched/ei/api/ewaybill/irn"
self.generate_ewaybill_url = self.base_url + "/enriched/ei/api/ewaybill"
self.get_qrcode_url = self.base_url + "/enriched/ei/others/qr/image"
@@ -1009,13 +1011,32 @@ class GSPConnector:
return failed
def fetch_and_attach_qrcode_from_irn(self):
- qrcode = self.get_qrcode_from_irn(self.invoice.irn)
- if qrcode:
- qrcode_file = self.create_qr_code_file(qrcode)
- frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url)
- frappe.msgprint(_("QR Code attached to the invoice"), alert=True)
+ is_qrcode_file_attached = self.invoice.qrcode_image and frappe.db.exists(
+ "File",
+ {
+ "attached_to_doctype": "Sales Invoice",
+ "attached_to_name": self.invoice.name,
+ "file_url": self.invoice.qrcode_image,
+ "attached_to_field": "qrcode_image",
+ },
+ )
+ if not is_qrcode_file_attached:
+ if self.invoice.signed_qr_code:
+ self.attach_qrcode_image()
+ frappe.db.set_value(
+ "Sales Invoice", self.invoice.name, "qrcode_image", self.invoice.qrcode_image
+ )
+ frappe.msgprint(_("QR Code attached to the invoice."), alert=True)
+ else:
+ qrcode = self.get_qrcode_from_irn(self.invoice.irn)
+ if qrcode:
+ qrcode_file = self.create_qr_code_file(qrcode)
+ frappe.db.set_value("Sales Invoice", self.invoice.name, "qrcode_image", qrcode_file.file_url)
+ frappe.msgprint(_("QR Code attached to the invoice."), alert=True)
+ else:
+ frappe.msgprint(_("QR Code not found for the IRN"), alert=True)
else:
- frappe.msgprint(_("QR Code not found for the IRN"), alert=True)
+ frappe.msgprint(_("QR Code is already Attached"), indicator="green", alert=True)
def get_qrcode_from_irn(self, irn):
import requests
@@ -1185,23 +1206,22 @@ class GSPConnector:
log_error(data)
self.raise_error(True)
- def cancel_eway_bill(self, eway_bill, reason, remark):
+ def get_ewb_details(self):
+ """
+ Get e-Waybill Details by IRN API documentaion for validation is not added yet.
+ https://einv-apisandbox.nic.in/version1.03/get-ewaybill-details-by-irn.html#validations
+ NOTE: if ewaybill Validity period lapsed or scanned by officer enroute (not tested yet) it will still return status as "ACT".
+ """
headers = self.get_headers()
- data = json.dumps({"ewbNo": eway_bill, "cancelRsnCode": reason, "cancelRmrk": remark}, indent=4)
- headers["username"] = headers["user_name"]
- del headers["user_name"]
- try:
- res = self.make_request("post", self.cancel_ewaybill_url, headers, data)
- if res.get("success"):
- self.invoice.ewaybill = ""
- self.invoice.eway_bill_cancelled = 1
- self.invoice.flags.updater_reference = {
- "doctype": self.invoice.doctype,
- "docname": self.invoice.name,
- "label": _("E-Way Bill Cancelled - {}").format(remark),
- }
- self.update_invoice()
+ irn = self.invoice.irn
+ if not irn:
+ frappe.throw(_("IRN is mandatory to get E-Waybill Details. Please generate IRN first."))
+ try:
+ params = "?irn={irn}".format(irn=irn)
+ res = self.make_request("get", self.ewaybill_details_url + params, headers)
+ if res.get("success"):
+ return res.get("result")
else:
raise RequestFailed
@@ -1210,9 +1230,65 @@ class GSPConnector:
self.raise_error(errors=errors)
except Exception:
- log_error(data)
+ log_error()
self.raise_error(True)
+ def update_ewb_details(self, ewb_details=None):
+ # for any reason user chooses to generate eway bill using portal this will allow to update ewaybill details in the invoice.
+ if not self.invoice.irn:
+ frappe.throw(_("IRN is mandatory to update E-Waybill Details. Please generate IRN first."))
+ if not ewb_details:
+ ewb_details = self.get_ewb_details()
+ if ewb_details:
+ self.invoice.ewaybill = ewb_details.get("EwbNo")
+ self.invoice.eway_bill_validity = ewb_details.get("EwbValidTill")
+ self.invoice.eway_bill_cancelled = 0 if ewb_details.get("Status") == "ACT" else 1
+ self.update_invoice()
+
+ def cancel_eway_bill(self):
+ ewb_details = self.get_ewb_details()
+ if ewb_details:
+ ewb_no = str(ewb_details.get("EwbNo"))
+ ewb_status = ewb_details.get("Status")
+ if ewb_status == "CNL":
+ self.invoice.ewaybill = ""
+ self.invoice.eway_bill_cancelled = 1
+ self.invoice.flags.updater_reference = {
+ "doctype": self.invoice.doctype,
+ "docname": self.invoice.name,
+ "label": _("E-Way Bill Cancelled"),
+ }
+ self.update_invoice()
+ frappe.msgprint(
+ _("E-Way Bill Cancelled successfully"),
+ indicator="green",
+ alert=True,
+ )
+ elif ewb_status == "ACT" and self.invoice.ewaybill == ewb_no:
+ msg = _("E-Way Bill {} is still active.").format(bold(ewb_no))
+ msg += "
"
+ msg += _(
+ "You must first use the portal to cancel the e-way bill and then update the cancelled status in the ERPNext system."
+ )
+ frappe.msgprint(msg)
+ elif ewb_status == "ACT" and self.invoice.ewaybill != ewb_no:
+ # if user cancelled the current eway bill and generated new eway bill using portal, then this will update new ewb number in sales invoice.
+ msg = _("E-Way Bill No. {0} doesn't match {1} saved in the invoice.").format(
+ bold(ewb_no), bold(self.invoice.ewaybill)
+ )
+ msg += "
.YYYY. - Year in 4 digits.YY. - Year in 2 digits.MM. - Month.DD. - Day of month.WW. - Week of the year.FY. - Fiscal Year.{fieldname}. - fieldname on the document e.g.\n branch\n {{ supplier_salutation if supplier_salutation else ''}} {{ supplier_name }},
{{ message }}
-{{_("The Request for Quotation can be accessed by clicking on the following button")}}:
-- -
{{_("Regards")}},
-{{ user_fullname }}
{{_("Please click on the following button to set your new password")}}:
-- -
- + + {{_("Set Password") }} + +
+ {{_("Regards")}},
+ {{ user_fullname }}
+