Merge pull request #30266 from deepeshgarg007/expense_income_account_validation
fix: Validate income/expense account in sales and purchase invoice
diff --git a/.github/workflows/patch.yml b/.github/workflows/patch.yml
index d05bbbe..afabe43 100644
--- a/.github/workflows/patch.yml
+++ b/.github/workflows/patch.yml
@@ -4,7 +4,10 @@
pull_request:
paths-ignore:
- '**.js'
+ - '**.css'
- '**.md'
+ - '**.html'
+ - '**.csv'
workflow_dispatch:
concurrency:
diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml
index 40f9365..69be765 100644
--- a/.github/workflows/server-tests-mariadb.yml
+++ b/.github/workflows/server-tests-mariadb.yml
@@ -4,8 +4,10 @@
pull_request:
paths-ignore:
- '**.js'
+ - '**.css'
- '**.md'
- '**.html'
+ - '**.csv'
push:
branches: [ develop ]
paths-ignore:
diff --git a/.mergify.yml b/.mergify.yml
index b7d1df4..315d90f 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -7,6 +7,8 @@
- author!=gavindsouza
- author!=rohitwaghchaure
- author!=nabinhait
+ - author!=ankush
+ - author!=deepeshgarg007
- or:
- base=version-13
- base=version-12
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index f3351dd..317fcc0 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -232,7 +232,7 @@
}), transaction.currency, company_account)
if total_amount > transaction.unallocated_amount:
- frappe.throw(_("The Sum Total of Amounts of All Selected Vouchers Should be Less than the Unallocated Amount of the Bank Transaction"))
+ frappe.throw(_("The sum total of amounts of all selected vouchers should be less than the unallocated amount of the bank transaction"))
account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
for voucher in vouchers:
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index a476cab..a924df7 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -110,8 +110,13 @@
paid_amount_field = "paid_amount"
if payment_entry.payment_document == 'Payment Entry':
doc = frappe.get_doc("Payment Entry", payment_entry.payment_entry)
- paid_amount_field = ("base_paid_amount"
- if doc.paid_to_account_currency == currency else "paid_amount")
+
+ if doc.payment_type == 'Receive':
+ paid_amount_field = ("received_amount"
+ if doc.paid_to_account_currency == currency else "base_received_amount")
+ elif doc.payment_type == 'Pay':
+ paid_amount_field = ("paid_amount"
+ if doc.paid_to_account_currency == currency else "base_paid_amount")
return frappe.db.get_value(payment_entry.payment_document,
payment_entry.payment_entry, paid_amount_field)
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 9b3b3aa..91c07ad 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -53,7 +53,7 @@
def on_submit(self):
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program
- if self.loyalty_program:
+ if not self.is_return and self.loyalty_program:
self.make_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
@@ -87,7 +87,7 @@
def on_cancel(self):
# run on cancel method of selling controller
super(SalesInvoice, self).on_cancel()
- if self.loyalty_program:
+ if not self.is_return and self.loyalty_program:
self.delete_loyalty_point_entry()
elif self.is_return and self.return_against and self.loyalty_program:
against_psi_doc = frappe.get_doc("POS Invoice", self.return_against)
diff --git a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
index 89f7f18..8909da9 100644
--- a/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
+++ b/erpnext/accounts/doctype/pos_invoice_merge_log/test_pos_invoice_merge_log.py
@@ -83,7 +83,10 @@
pos_inv_cn = make_sales_return(pos_inv.name)
pos_inv_cn.set("payments", [])
pos_inv_cn.append('payments', {
- 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -300
+ 'mode_of_payment': 'Cash', 'account': 'Cash - _TC', 'amount': -100
+ })
+ pos_inv_cn.append('payments', {
+ 'mode_of_payment': 'Bank Draft', 'account': '_Test Bank - _TC', 'amount': -200
})
pos_inv_cn.paid_amount = -300
pos_inv_cn.submit()
@@ -98,7 +101,12 @@
pos_inv_cn.load_from_db()
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv_cn.consolidated_invoice))
- self.assertTrue(frappe.db.get_value("Sales Invoice", pos_inv_cn.consolidated_invoice, "is_return"))
+ consolidated_credit_note = frappe.get_doc("Sales Invoice", pos_inv_cn.consolidated_invoice)
+ self.assertEqual(consolidated_credit_note.is_return, 1)
+ self.assertEqual(consolidated_credit_note.payments[0].mode_of_payment, 'Cash')
+ self.assertEqual(consolidated_credit_note.payments[0].amount, -100)
+ self.assertEqual(consolidated_credit_note.payments[1].mode_of_payment, 'Bank Draft')
+ self.assertEqual(consolidated_credit_note.payments[1].amount, -200)
finally:
frappe.set_user("Administrator")
diff --git a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
index 5aabc21..e107912 100644
--- a/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/sales_invoice.py
@@ -43,8 +43,11 @@
from erpnext.setup.doctype.company.company import update_company_current_month_sales
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
-from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
-from erpnext.stock.utils import calculate_mapped_packed_items_return
+from erpnext.stock.doctype.serial_no.serial_no import (
+ get_delivery_note_serial_no,
+ get_serial_nos,
+ update_serial_nos_after_submit,
+)
form_grid_templates = {
"items": "templates/form_grid/item_grid.html"
@@ -233,6 +236,9 @@
# because updating reserved qty in bin depends upon updated delivered qty in SO
if self.update_stock == 1:
self.update_stock_ledger()
+ if self.is_return and self.update_stock:
+ update_serial_nos_after_submit(self, "items")
+
# this sequence because outstanding may get -ve
self.make_gl_entries()
@@ -744,11 +750,8 @@
def update_packing_list(self):
if cint(self.update_stock) == 1:
- if cint(self.is_return) and self.return_against:
- calculate_mapped_packed_items_return(self)
- else:
- from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
- make_packing_list(self)
+ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
+ make_packing_list(self)
else:
self.set('packed_items', [])
@@ -1411,12 +1414,19 @@
frappe.db.set_value("Customer", self.customer, "loyalty_program_tier", lp_details.tier_name)
def get_returned_amount(self):
- returned_amount = frappe.db.sql("""
- select sum(grand_total)
- from `tabSales Invoice`
- where docstatus=1 and is_return=1 and ifnull(return_against, '')=%s
- """, self.name)
- return abs(flt(returned_amount[0][0])) if returned_amount else 0
+ from frappe.query_builder.functions import Coalesce, Sum
+ doc = frappe.qb.DocType(self.doctype)
+ returned_amount = (
+ frappe.qb.from_(doc)
+ .select(Sum(doc.grand_total))
+ .where(
+ (doc.docstatus == 1)
+ & (doc.is_return == 1)
+ & (Coalesce(doc.return_against, '') == self.name)
+ )
+ ).run()
+
+ return abs(returned_amount[0][0]) if returned_amount[0][0] else 0
# redeem the loyalty points.
def apply_loyalty_points(self):
@@ -1657,7 +1667,6 @@
@frappe.whitelist()
def make_delivery_note(source_name, target_doc=None):
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.run_method("set_missing_values")
target.run_method("set_po_nos")
target.run_method("calculate_taxes_and_totals")
diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
index 6d929e4..62c3508 100644
--- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
+++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py
@@ -2615,6 +2615,12 @@
frappe.db.set_value('Accounts Settings', None, 'acc_frozen_upto', None)
+ def test_standalone_serial_no_return(self):
+ si = create_sales_invoice(item_code="_Test Serialized Item With Series", update_stock=True, is_return=True, qty=-1)
+ si.reload()
+ self.assertTrue(si.items[0].serial_no)
+
+
def get_sales_invoice_for_e_invoice():
si = make_sales_invoice_for_ewaybill()
si.naming_series = 'INV-2020-.#####'
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 b72d266..fe644ed 100644
--- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
+++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
@@ -267,7 +267,7 @@
total_amount += flt(amount)
- return amount
+ return total_amount
def get_balance_row(label, amount, account_currency):
if amount > 0:
diff --git a/erpnext/accounts/report/general_ledger/test_general_ledger.py b/erpnext/accounts/report/general_ledger/test_general_ledger.py
new file mode 100644
index 0000000..2373c8c
--- /dev/null
+++ b/erpnext/accounts/report/general_ledger/test_general_ledger.py
@@ -0,0 +1,134 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import today
+
+from erpnext.accounts.report.general_ledger.general_ledger import execute
+
+
+class TestGeneralLedger(FrappeTestCase):
+
+ def test_foreign_account_balance_after_exchange_rate_revaluation(self):
+ """
+ Checks the correctness of balance after exchange rate revaluation
+ """
+ # create a new account with USD currency
+ account_name = "Test USD Account for Revalutation"
+ company = "_Test Company"
+ account = frappe.get_doc({
+ "account_name": account_name,
+ "is_group": 0,
+ "company": company,
+ "root_type": "Asset",
+ "report_type": "Balance Sheet",
+ "account_currency": "USD",
+ "inter_company_account": 0,
+ "parent_account": "Bank Accounts - _TC",
+ "account_type": "Bank",
+ "doctype": "Account"
+ })
+ account.insert(ignore_if_duplicate=True)
+ # create a JV to debit 1000 USD at 75 exchange rate
+ jv = frappe.new_doc("Journal Entry")
+ jv.posting_date = today()
+ jv.company = company
+ jv.multi_currency = 1
+ jv.cost_center = "_Test Cost Center - _TC"
+ jv.set("accounts", [
+ {
+ "account": account.name,
+ "debit_in_account_currency": 1000,
+ "credit_in_account_currency": 0,
+ "exchange_rate": 75,
+ "cost_center": "_Test Cost Center - _TC",
+ },
+ {
+ "account": "Cash - _TC",
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 75000,
+ "cost_center": "_Test Cost Center - _TC",
+ },
+ ])
+ jv.save()
+ jv.submit()
+ # create a JV to credit 900 USD at 100 exchange rate
+ jv = frappe.new_doc("Journal Entry")
+ jv.posting_date = today()
+ jv.company = company
+ jv.multi_currency = 1
+ jv.cost_center = "_Test Cost Center - _TC"
+ jv.set("accounts", [
+ {
+ "account": account.name,
+ "debit_in_account_currency": 0,
+ "credit_in_account_currency": 900,
+ "exchange_rate": 100,
+ "cost_center": "_Test Cost Center - _TC",
+ },
+ {
+ "account": "Cash - _TC",
+ "debit_in_account_currency": 90000,
+ "credit_in_account_currency": 0,
+ "cost_center": "_Test Cost Center - _TC",
+ },
+ ])
+ jv.save()
+ jv.submit()
+
+ # create an exchange rate revaluation entry at 77 exchange rate
+ revaluation = frappe.new_doc("Exchange Rate Revaluation")
+ revaluation.posting_date = today()
+ revaluation.company = company
+ revaluation.set("accounts", [
+ {
+ "account": account.name,
+ "account_currency": "USD",
+ "new_exchange_rate": 77,
+ "new_balance_in_base_currency": 7700,
+ "balance_in_base_currency": -15000,
+ "balance_in_account_currency": 100,
+ "current_exchange_rate": -150
+ }
+ ])
+ revaluation.save()
+ revaluation.submit()
+
+ # post journal entry to revaluate
+ frappe.db.set_value('Company', company, "unrealized_exchange_gain_loss_account", "_Test Exchange Gain/Loss - _TC")
+ revaluation_jv = revaluation.make_jv_entry()
+ revaluation_jv = frappe.get_doc(revaluation_jv)
+ revaluation_jv.cost_center = "_Test Cost Center - _TC"
+ for acc in revaluation_jv.get("accounts"):
+ acc.cost_center = "_Test Cost Center - _TC"
+ revaluation_jv.save()
+ revaluation_jv.submit()
+
+ # check the balance of the account
+ balance = frappe.db.sql(
+ """
+ select sum(debit_in_account_currency) - sum(credit_in_account_currency)
+ from `tabGL Entry`
+ where account = %s
+ group by account
+ """, account.name)
+
+ self.assertEqual(balance[0][0], 100)
+
+ # check if general ledger shows correct balance
+ columns, data = execute(frappe._dict({
+ "company": company,
+ "from_date": today(),
+ "to_date": today(),
+ "account": [account.name],
+ "group_by": "Group by Voucher (Consolidated)",
+ }))
+
+ self.assertEqual(data[1]["account"], account.name)
+ self.assertEqual(data[1]["debit"], 1000)
+ self.assertEqual(data[1]["credit"], 0)
+ self.assertEqual(data[2]["debit"], 0)
+ self.assertEqual(data[2]["credit"], 900)
+ self.assertEqual(data[3]["debit"], 100)
+ self.assertEqual(data[3]["credit"], 100)
\ No newline at end of file
diff --git a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
index d843dfd..70effce 100644
--- a/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
+++ b/erpnext/accounts/report/trial_balance_for_party/trial_balance_for_party.py
@@ -107,6 +107,7 @@
select party, sum(debit) as opening_debit, sum(credit) as opening_credit
from `tabGL Entry`
where company=%(company)s
+ and is_cancelled=0
and ifnull(party_type, '') = %(party_type)s and ifnull(party, '') != ''
and (posting_date < %(from_date)s or ifnull(is_opening, 'No') = 'Yes')
{account_filter}
@@ -133,6 +134,7 @@
select party, sum(debit) as debit, sum(credit) as credit
from `tabGL Entry`
where company=%(company)s
+ and is_cancelled = 0
and ifnull(party_type, '') = %(party_type)s and ifnull(party, '') != ''
and posting_date >= %(from_date)s and posting_date <= %(to_date)s
and ifnull(is_opening, 'No') = 'No'
diff --git a/erpnext/accounts/report/utils.py b/erpnext/accounts/report/utils.py
index c38e4b8..9e721fb 100644
--- a/erpnext/accounts/report/utils.py
+++ b/erpnext/accounts/report/utils.py
@@ -92,10 +92,10 @@
account_currency = entry['account_currency']
if len(account_currencies) == 1 and account_currency == presentation_currency:
- if entry.get('debit'):
+ if debit_in_account_currency:
entry['debit'] = debit_in_account_currency
- if entry.get('credit'):
+ if credit_in_account_currency:
entry['credit'] = credit_in_account_currency
else:
date = currency_info['report_date']
diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.py b/erpnext/buying/doctype/purchase_order/purchase_order.py
index 2e7d306..2c66542 100644
--- a/erpnext/buying/doctype/purchase_order/purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/purchase_order.py
@@ -402,7 +402,6 @@
frappe.local.message_log = []
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.run_method("set_missing_values")
target.run_method("calculate_taxes_and_totals")
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
index 023c95d..567e41f 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.json
@@ -72,8 +72,8 @@
"section_break_46",
"base_grand_total",
"base_rounding_adjustment",
- "base_in_words",
"base_rounded_total",
+ "base_in_words",
"column_break4",
"grand_total",
"rounding_adjustment",
@@ -635,6 +635,7 @@
"fieldname": "rounded_total",
"fieldtype": "Currency",
"label": "Rounded Total",
+ "options": "currency",
"read_only": 1
},
{
@@ -810,7 +811,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-12-11 06:43:20.924080",
+ "modified": "2022-03-14 16:13:20.284572",
"modified_by": "Administrator",
"module": "Buying",
"name": "Supplier Quotation",
@@ -875,6 +876,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "supplier",
"title_field": "title"
}
\ No newline at end of file
diff --git a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
index 171de78..81fc324 100644
--- a/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
+++ b/erpnext/buying/doctype/supplier_quotation/supplier_quotation.py
@@ -109,7 +109,6 @@
@frappe.whitelist()
def make_purchase_order(source_name, target_doc=None):
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.run_method("set_missing_values")
target.run_method("get_schedule_dates")
target.run_method("calculate_taxes_and_totals")
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index d362cdd..19acc10 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -636,7 +636,12 @@
self.doc.outstanding_amount = flt(total_amount_to_pay - flt(paid_amount) + flt(change_amount),
self.doc.precision("outstanding_amount"))
- if self.doc.doctype == 'Sales Invoice' and self.doc.get('is_pos') and self.doc.get('is_return'):
+ if (
+ self.doc.doctype == 'Sales Invoice'
+ and self.doc.get('is_pos')
+ and self.doc.get('is_return')
+ and not self.doc.get('is_consolidated')
+ ):
self.set_total_amount_to_default_mop(total_amount_to_pay)
self.calculate_paid_amount()
diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js
index 1b5c440..6dce79d 100644
--- a/erpnext/e_commerce/product_ui/views.js
+++ b/erpnext/e_commerce/product_ui/views.js
@@ -495,7 +495,7 @@
categories.forEach(category => {
sub_group_html += `
- <a href="${ category.route || '#' }" style="text-decoration: none;">
+ <a href="/${ category.route || '#' }" style="text-decoration: none;">
<div class="category-pill">
${ category.name }
</div>
diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index 6095413..585059f 100644
--- a/erpnext/hr/doctype/attendance/test_attendance.py
+++ b/erpnext/hr/doctype/attendance/test_attendance.py
@@ -3,7 +3,7 @@
import frappe
from frappe.tests.utils import FrappeTestCase
-from frappe.utils import add_days, get_first_day, getdate, now_datetime, nowdate
+from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate
from erpnext.hr.doctype.attendance.attendance import (
get_month_map,
@@ -16,6 +16,13 @@
test_records = frappe.get_test_records('Attendance')
class TestAttendance(FrappeTestCase):
+ def setUp(self):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
+
+ from_date = get_year_start(getdate())
+ to_date = get_year_ending(getdate())
+ self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+
def test_mark_absent(self):
employee = make_employee("test_mark_absent@example.com")
date = nowdate()
@@ -31,12 +38,9 @@
employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1))
frappe.db.delete('Attendance', {'employee': employee})
+ frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list)
- from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
- holiday_list = make_holiday_list()
- frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list)
-
- first_sunday = get_first_sunday(holiday_list, for_date=first_day)
+ 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)
@@ -58,11 +62,9 @@
employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1))
frappe.db.delete('Attendance', {'employee': employee})
- from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
- holiday_list = make_holiday_list()
- frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list)
+ frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list)
- first_sunday = get_first_sunday(holiday_list, for_date=first_day)
+ 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)
@@ -87,9 +89,7 @@
relieving_date=relieving_date)
frappe.db.delete('Attendance', {'employee': employee})
- from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
- holiday_list = make_holiday_list()
- frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list)
+ frappe.db.set_value('Employee', employee, 'holiday_list', self.holiday_list)
attendance_date = add_days(first_day, 2)
mark_attendance(employee, attendance_date, 'Present')
diff --git a/erpnext/hr/doctype/holiday_list/test_holiday_list.py b/erpnext/hr/doctype/holiday_list/test_holiday_list.py
index c9239ed..aed901a 100644
--- a/erpnext/hr/doctype/holiday_list/test_holiday_list.py
+++ b/erpnext/hr/doctype/holiday_list/test_holiday_list.py
@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import unittest
+from contextlib import contextmanager
from datetime import timedelta
import frappe
@@ -30,3 +31,24 @@
"holidays" : holiday_dates
}).insert()
return doc
+
+
+@contextmanager
+def set_holiday_list(holiday_list, company_name):
+ """
+ Context manager for setting holiday list in tests
+ """
+ try:
+ company = frappe.get_doc('Company', company_name)
+ previous_holiday_list = company.default_holiday_list
+
+ company.default_holiday_list = holiday_list
+ company.save()
+
+ yield
+
+ finally:
+ # restore holiday list setup
+ company = frappe.get_doc('Company', company_name)
+ company.default_holiday_list = previous_holiday_list
+ company.save()
diff --git a/erpnext/hr/doctype/leave_application/leave_application.js b/erpnext/hr/doctype/leave_application/leave_application.js
index 9e8cb55..85997a4 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.js
+++ b/erpnext/hr/doctype/leave_application/leave_application.js
@@ -52,7 +52,7 @@
make_dashboard: function(frm) {
var leave_details;
let lwps;
- if (frm.doc.employee) {
+ if (frm.doc.employee && frm.doc.from_date) {
frappe.call({
method: "erpnext.hr.doctype.leave_application.leave_application.get_leave_details",
async: false,
@@ -146,6 +146,7 @@
},
to_date: function(frm) {
+ frm.trigger("make_dashboard");
frm.trigger("half_day_datepicker");
frm.trigger("calculate_total_days");
},
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index 345d8dc..2987c1e 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+from typing import Dict, Optional, Tuple
import frappe
from frappe import _
@@ -35,6 +36,10 @@
class OverlapError(frappe.ValidationError): pass
class AttendanceAlreadyMarkedError(frappe.ValidationError): pass
class NotAnOptionalHoliday(frappe.ValidationError): pass
+class InsufficientLeaveBalanceError(frappe.ValidationError):
+ pass
+class LeaveAcrossAllocationsError(frappe.ValidationError):
+ pass
from frappe.model.document import Document
@@ -135,21 +140,35 @@
def validate_dates_across_allocation(self):
if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
return
- def _get_leave_allocation_record(date):
- allocation = frappe.db.sql("""select name from `tabLeave Allocation`
- where employee=%s and leave_type=%s and docstatus=1
- and %s between from_date and to_date""", (self.employee, self.leave_type, date))
- return allocation and allocation[0][0]
+ alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
+
+ if not (alloc_on_from_date or alloc_on_to_date):
+ frappe.throw(_("Application period cannot be outside leave allocation period"))
+ elif self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date):
+ frappe.throw(_("Application period cannot be across two allocation records"), exc=LeaveAcrossAllocationsError)
+
+ def get_allocation_based_on_application_dates(self) -> Tuple[Dict, Dict]:
+ """Returns allocation name, from and to dates for application dates"""
+ def _get_leave_allocation_record(date):
+ LeaveAllocation = frappe.qb.DocType("Leave Allocation")
+ allocation = (
+ frappe.qb.from_(LeaveAllocation)
+ .select(LeaveAllocation.name, LeaveAllocation.from_date, LeaveAllocation.to_date)
+ .where(
+ (LeaveAllocation.employee == self.employee)
+ & (LeaveAllocation.leave_type == self.leave_type)
+ & (LeaveAllocation.docstatus == 1)
+ & ((date >= LeaveAllocation.from_date) & (date <= LeaveAllocation.to_date))
+ )
+ ).run(as_dict=True)
+
+ return allocation and allocation[0]
allocation_based_on_from_date = _get_leave_allocation_record(self.from_date)
allocation_based_on_to_date = _get_leave_allocation_record(self.to_date)
- if not (allocation_based_on_from_date or allocation_based_on_to_date):
- frappe.throw(_("Application period cannot be outside leave allocation period"))
-
- elif allocation_based_on_from_date != allocation_based_on_to_date:
- frappe.throw(_("Application period cannot be across two allocation records"))
+ return allocation_based_on_from_date, allocation_based_on_to_date
def validate_back_dated_application(self):
future_allocation = frappe.db.sql("""select name, from_date from `tabLeave Allocation`
@@ -261,15 +280,29 @@
frappe.throw(_("The day(s) on which you are applying for leave are holidays. You need not apply for leave."))
if not is_lwp(self.leave_type):
- self.leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date,
- consider_all_leaves_in_the_allocation_period=True)
- if self.status != "Rejected" and (self.leave_balance < self.total_leave_days or not self.leave_balance):
- if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
- frappe.msgprint(_("Note: There is not enough leave balance for Leave Type {0}")
- .format(self.leave_type))
- else:
- frappe.throw(_("There is not enough leave balance for Leave Type {0}")
- .format(self.leave_type))
+ leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date,
+ consider_all_leaves_in_the_allocation_period=True, for_consumption=True)
+ self.leave_balance = leave_balance.get("leave_balance")
+ leave_balance_for_consumption = leave_balance.get("leave_balance_for_consumption")
+
+ if self.status != "Rejected" and (leave_balance_for_consumption < self.total_leave_days or not leave_balance_for_consumption):
+ self.show_insufficient_balance_message(leave_balance_for_consumption)
+
+ def show_insufficient_balance_message(self, leave_balance_for_consumption: float) -> None:
+ alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
+
+ if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
+ if leave_balance_for_consumption != self.leave_balance:
+ msg = _("Warning: Insufficient leave balance for Leave Type {0} in this allocation.").format(frappe.bold(self.leave_type))
+ msg += "<br><br>"
+ msg += _("Actual balances aren't available because the leave application spans over different leave allocations. You can still apply for leaves which would be compensated during the next allocation.")
+ else:
+ msg = _("Warning: Insufficient leave balance for Leave Type {0}.").format(frappe.bold(self.leave_type))
+
+ frappe.msgprint(msg, title=_("Warning"), indicator="orange")
+ else:
+ frappe.throw(_("Insufficient leave balance for Leave Type {0}").format(frappe.bold(self.leave_type)),
+ exc=InsufficientLeaveBalanceError, title=_("Insufficient Balance"))
def validate_leave_overlap(self):
if not self.name:
@@ -426,54 +459,111 @@
if self.status != 'Approved' and submit:
return
- expiry_date = get_allocation_expiry(self.employee, self.leave_type,
+ expiry_date = get_allocation_expiry_for_cf_leaves(self.employee, self.leave_type,
self.to_date, self.from_date)
-
lwp = frappe.db.get_value("Leave Type", self.leave_type, "is_lwp")
if expiry_date:
self.create_ledger_entry_for_intermediate_allocation_expiry(expiry_date, submit, lwp)
else:
- raise_exception = True
- if frappe.flags.in_patch:
- raise_exception=False
+ alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
+ if self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date):
+ # required only if negative balance is allowed for leave type
+ # else will be stopped in validation itself
+ self.create_separate_ledger_entries(alloc_on_from_date, alloc_on_to_date, submit, lwp)
+ else:
+ raise_exception = False if frappe.flags.in_patch else True
+ args = dict(
+ leaves=self.total_leave_days * -1,
+ from_date=self.from_date,
+ to_date=self.to_date,
+ is_lwp=lwp,
+ holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
+ )
+ create_leave_ledger_entry(self, args, submit)
- args = dict(
- leaves=self.total_leave_days * -1,
+ def is_separate_ledger_entry_required(self, alloc_on_from_date: Optional[Dict] = None, alloc_on_to_date: Optional[Dict] = None) -> bool:
+ """Checks if application dates fall in separate allocations"""
+ if ((alloc_on_from_date and not alloc_on_to_date)
+ or (not alloc_on_from_date and alloc_on_to_date)
+ or (alloc_on_from_date and alloc_on_to_date and alloc_on_from_date.name != alloc_on_to_date.name)):
+ return True
+ return False
+
+ def create_separate_ledger_entries(self, alloc_on_from_date, alloc_on_to_date, submit, lwp):
+ """Creates separate ledger entries for application period falling into separate allocations"""
+ # for creating separate ledger entries existing allocation periods should be consecutive
+ if submit and alloc_on_from_date and alloc_on_to_date and add_days(alloc_on_from_date.to_date, 1) != alloc_on_to_date.from_date:
+ frappe.throw(_("Leave Application period cannot be across two non-consecutive leave allocations {0} and {1}.").format(
+ get_link_to_form("Leave Allocation", alloc_on_from_date.name), get_link_to_form("Leave Allocation", alloc_on_to_date)))
+
+ raise_exception = False if frappe.flags.in_patch else True
+
+ if alloc_on_from_date:
+ first_alloc_end = alloc_on_from_date.to_date
+ second_alloc_start = add_days(alloc_on_from_date.to_date, 1)
+ else:
+ first_alloc_end = add_days(alloc_on_to_date.from_date, -1)
+ second_alloc_start = alloc_on_to_date.from_date
+
+ leaves_in_first_alloc = get_number_of_leave_days(self.employee, self.leave_type,
+ self.from_date, first_alloc_end, self.half_day, self.half_day_date)
+ leaves_in_second_alloc = get_number_of_leave_days(self.employee, self.leave_type,
+ second_alloc_start, self.to_date, self.half_day, self.half_day_date)
+
+ args = dict(
+ is_lwp=lwp,
+ holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
+ )
+
+ if leaves_in_first_alloc:
+ args.update(dict(
from_date=self.from_date,
+ to_date=first_alloc_end,
+ leaves=leaves_in_first_alloc * -1
+ ))
+ create_leave_ledger_entry(self, args, submit)
+
+ if leaves_in_second_alloc:
+ args.update(dict(
+ from_date=second_alloc_start,
to_date=self.to_date,
+ leaves=leaves_in_second_alloc * -1
+ ))
+ create_leave_ledger_entry(self, args, submit)
+
+ def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp):
+ """Splits leave application into two ledger entries to consider expiry of allocation"""
+ raise_exception = False if frappe.flags.in_patch else True
+
+ leaves = get_number_of_leave_days(self.employee, self.leave_type,
+ self.from_date, expiry_date, self.half_day, self.half_day_date)
+
+ if leaves:
+ args = dict(
+ from_date=self.from_date,
+ to_date=expiry_date,
+ leaves=leaves * -1,
is_lwp=lwp,
holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
)
create_leave_ledger_entry(self, args, submit)
- def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp):
- ''' splits leave application into two ledger entries to consider expiry of allocation '''
-
- raise_exception = True
- if frappe.flags.in_patch:
- raise_exception=False
-
- args = dict(
- from_date=self.from_date,
- to_date=expiry_date,
- leaves=(date_diff(expiry_date, self.from_date) + 1) * -1,
- is_lwp=lwp,
- holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
- )
- create_leave_ledger_entry(self, args, submit)
-
if getdate(expiry_date) != getdate(self.to_date):
start_date = add_days(expiry_date, 1)
- args.update(dict(
- from_date=start_date,
- to_date=self.to_date,
- leaves=date_diff(self.to_date, expiry_date) * -1
- ))
- create_leave_ledger_entry(self, args, submit)
+ leaves = get_number_of_leave_days(self.employee, self.leave_type,
+ start_date, self.to_date, self.half_day, self.half_day_date)
+
+ if leaves:
+ args.update(dict(
+ from_date=start_date,
+ to_date=self.to_date,
+ leaves=leaves * -1
+ ))
+ create_leave_ledger_entry(self, args, submit)
-def get_allocation_expiry(employee, leave_type, to_date, from_date):
+def get_allocation_expiry_for_cf_leaves(employee: str, leave_type: str, to_date: str, from_date: str) -> str:
''' Returns expiry of carry forward allocation in leave ledger entry '''
expiry = frappe.get_all("Leave Ledger Entry",
filters={
@@ -481,12 +571,17 @@
'leave_type': leave_type,
'is_carry_forward': 1,
'transaction_type': 'Leave Allocation',
- 'to_date': ['between', (from_date, to_date)]
+ 'to_date': ['between', (from_date, to_date)],
+ 'docstatus': 1
},fields=['to_date'])
- return expiry[0]['to_date'] if expiry else None
+ return expiry[0]['to_date'] if expiry else ''
+
@frappe.whitelist()
-def get_number_of_leave_days(employee, leave_type, from_date, to_date, half_day = None, half_day_date = None, holiday_list = None):
+def get_number_of_leave_days(employee: str, leave_type: str, from_date: str, to_date: str, half_day: Optional[int] = None,
+ half_day_date: Optional[str] = None, holiday_list: Optional[str] = None) -> float:
+ """Returns number of leave days between 2 dates after considering half day and holidays
+ (Based on the include_holiday setting in Leave Type)"""
number_of_days = 0
if cint(half_day) == 1:
if from_date == to_date:
@@ -502,6 +597,7 @@
number_of_days = flt(number_of_days) - flt(get_holidays(employee, from_date, to_date, holiday_list=holiday_list))
return number_of_days
+
@frappe.whitelist()
def get_leave_details(employee, date):
allocation_records = get_leave_allocation_records(employee, date)
@@ -514,6 +610,7 @@
'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,
@@ -521,29 +618,28 @@
end_date = allocation.to_date
leaves_taken = get_leaves_for_period(employee, d, allocation.from_date, end_date) * -1
- leaves_pending = get_pending_leaves_for_period(employee, d, allocation.from_date, end_date)
+ leaves_pending = get_leaves_pending_approval_for_period(employee, d, allocation.from_date, end_date)
leave_allocation[d] = {
"total_leaves": total_allocated_leaves,
"expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken),
"leaves_taken": leaves_taken,
- "pending_leaves": leaves_pending,
+ "leaves_pending_approval": leaves_pending,
"remaining_leaves": remaining_leaves}
#is used in set query
- lwps = frappe.get_list("Leave Type", filters = {"is_lwp": 1})
- lwps = [lwp.name for lwp in lwps]
+ lwp = frappe.get_list("Leave Type", filters={"is_lwp": 1}, pluck="name")
- ret = {
- 'leave_allocation': leave_allocation,
- 'leave_approver': get_leave_approver(employee),
- 'lwps': lwps
+ return {
+ "leave_allocation": leave_allocation,
+ "leave_approver": get_leave_approver(employee),
+ "lwps": lwp
}
- return ret
@frappe.whitelist()
-def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_leaves_in_the_allocation_period=False):
+def get_leave_balance_on(employee: str, leave_type: str, date: str, to_date: str = None,
+ consider_all_leaves_in_the_allocation_period: bool = False, for_consumption: bool = False):
'''
Returns leave balance till date
:param employee: employee name
@@ -551,6 +647,11 @@
:param date: date to check balance on
:param to_date: future date to check for allocation expiry
:param consider_all_leaves_in_the_allocation_period: consider all leaves taken till the allocation end date
+ :param for_consumption: flag to check if leave balance is required for consumption or display
+ eg: employee has leave balance = 10 but allocation is expiring in 1 day so employee can only consume 1 leave
+ in this case leave_balance = 10 but leave_balance_for_consumption = 1
+ if True, returns a dict eg: {'leave_balance': 10, 'leave_balance_for_consumption': 1}
+ else, returns leave_balance (in this case 10)
'''
if not to_date:
@@ -560,11 +661,17 @@
allocation = allocation_records.get(leave_type, frappe._dict())
end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date
- expiry = get_allocation_expiry(employee, leave_type, to_date, 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)
- return get_remaining_leaves(allocation, leaves_taken, date, expiry)
+ remaining_leaves = get_remaining_leaves(allocation, leaves_taken, date, cf_expiry)
+
+ if for_consumption:
+ return remaining_leaves
+ else:
+ return remaining_leaves.get('leave_balance')
+
def get_leave_allocation_records(employee, date, leave_type=None):
"""Returns the total allocated leaves and carry forwarded leaves based on ledger entries"""
@@ -613,8 +720,9 @@
}))
return allocated_leaves
-def get_pending_leaves_for_period(employee, leave_type, from_date, to_date):
- ''' Returns leaves that are pending approval '''
+
+def get_leaves_pending_approval_for_period(employee: str, leave_type: str, from_date: str, to_date: str) -> float:
+ ''' Returns leaves that are pending for approval '''
leaves = frappe.get_all("Leave Application",
filters={
"employee": employee,
@@ -627,38 +735,46 @@
}, fields=['SUM(total_leave_days) as leaves'])[0]
return leaves['leaves'] if leaves['leaves'] else 0.0
-def get_remaining_leaves(allocation, leaves_taken, date, expiry):
- ''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry '''
- def _get_remaining_leaves(remaining_leaves, end_date):
+def get_remaining_leaves(allocation: Dict, leaves_taken: float, date: str, cf_expiry: str) -> Dict[str, float]:
+ '''Returns a dict of leave_balance and leave_balance_for_consumption
+ leave_balance returns the available leave balance
+ leave_balance_for_consumption returns the minimum leaves remaining after comparing with remaining days for allocation expiry
+ '''
+ def _get_remaining_leaves(remaining_leaves, end_date):
+ ''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry '''
if remaining_leaves > 0:
remaining_days = date_diff(end_date, date) + 1
remaining_leaves = min(remaining_days, remaining_leaves)
return remaining_leaves
- total_leaves = flt(allocation.total_leaves_allocated) + flt(leaves_taken)
+ leave_balance = leave_balance_for_consumption = flt(allocation.total_leaves_allocated) + flt(leaves_taken)
- if expiry and allocation.unused_leaves:
- remaining_leaves = flt(allocation.unused_leaves) + flt(leaves_taken)
- remaining_leaves = _get_remaining_leaves(remaining_leaves, expiry)
+ # balance for carry forwarded leaves
+ if cf_expiry and allocation.unused_leaves:
+ cf_leaves = flt(allocation.unused_leaves) + flt(leaves_taken)
+ remaining_cf_leaves = _get_remaining_leaves(cf_leaves, cf_expiry)
- total_leaves = flt(allocation.new_leaves_allocated) + flt(remaining_leaves)
+ leave_balance = flt(allocation.new_leaves_allocated) + flt(cf_leaves)
+ leave_balance_for_consumption = flt(allocation.new_leaves_allocated) + flt(remaining_cf_leaves)
- return _get_remaining_leaves(total_leaves, allocation.to_date)
+ remaining_leaves = _get_remaining_leaves(leave_balance_for_consumption, allocation.to_date)
+ return frappe._dict(leave_balance=leave_balance, leave_balance_for_consumption=remaining_leaves)
-def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_expired_leaves=False):
+
+def get_leaves_for_period(employee: str, leave_type: str, from_date: str, to_date: str, skip_expired_leaves: bool = True) -> float:
leave_entries = get_leave_entries(employee, leave_type, from_date, to_date)
leave_days = 0
for leave_entry in leave_entries:
inclusive_period = leave_entry.from_date >= getdate(from_date) and leave_entry.to_date <= getdate(to_date)
- if inclusive_period and leave_entry.transaction_type == 'Leave Encashment':
+ if inclusive_period and leave_entry.transaction_type == 'Leave Encashment':
leave_days += leave_entry.leaves
elif inclusive_period and leave_entry.transaction_type == 'Leave Allocation' and leave_entry.is_expired \
- and (do_not_skip_expired_leaves or not skip_expiry_leaves(leave_entry, to_date)):
+ and not skip_expired_leaves:
leave_days += leave_entry.leaves
elif leave_entry.transaction_type == 'Leave Application':
@@ -680,11 +796,6 @@
return leave_days
-def skip_expiry_leaves(leave_entry, date):
- ''' Checks whether the expired leaves coincide with the to_date of leave balance check.
- This allows backdated leave entry creation for non carry forwarded allocation '''
- end_date = frappe.db.get_value("Leave Allocation", {'name': leave_entry.transaction_name}, ['to_date'])
- return True if end_date == date and not leave_entry.is_carry_forward else False
def get_leave_entries(employee, leave_type, from_date, to_date):
''' Returns leave entries between from_date and to_date. '''
@@ -707,6 +818,7 @@
"leave_type": leave_type
}, as_dict=1)
+
@frappe.whitelist()
def get_holidays(employee, from_date, to_date, holiday_list = None):
'''get holidays between two dates for the given employee'''
@@ -723,6 +835,7 @@
lwp = frappe.db.sql("select is_lwp from `tabLeave Type` where name = %s", leave_type)
return lwp and cint(lwp[0][0]) or 0
+
@frappe.whitelist()
def get_events(start, end, filters=None):
from frappe.desk.reportview import get_filters_cond
@@ -751,6 +864,7 @@
return events
+
def add_department_leaves(events, start, end, employee, company):
department = frappe.db.get_value("Employee", employee, "department")
@@ -831,6 +945,7 @@
})
cnt+=1
+
def add_holidays(events, start, end, employee, company):
applicable_holiday_list = get_holiday_list_for_employee(employee, company)
if not applicable_holiday_list:
@@ -847,6 +962,7 @@
"name": holiday.name
})
+
@frappe.whitelist()
def get_mandatory_approval(doctype):
mandatory = ""
@@ -859,6 +975,7 @@
return mandatory
+
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
@@ -894,6 +1011,7 @@
return leave_days
+
@frappe.whitelist()
def get_leave_approver(employee):
leave_approver, department = frappe.db.get_value("Employee",
diff --git a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html
index 9f667a6..e755322 100644
--- a/erpnext/hr/doctype/leave_application/leave_application_dashboard.html
+++ b/erpnext/hr/doctype/leave_application/leave_application_dashboard.html
@@ -4,11 +4,11 @@
<thead>
<tr>
<th style="width: 16%">{{ __("Leave Type") }}</th>
- <th style="width: 16%" class="text-right">{{ __("Total Allocated Leave") }}</th>
- <th style="width: 16%" class="text-right">{{ __("Expired Leave") }}</th>
- <th style="width: 16%" class="text-right">{{ __("Used Leave") }}</th>
- <th style="width: 16%" class="text-right">{{ __("Pending Leave") }}</th>
- <th style="width: 16%" class="text-right">{{ __("Available Leave") }}</th>
+ <th style="width: 16%" class="text-right">{{ __("Total Allocated Leave(s)") }}</th>
+ <th style="width: 16%" class="text-right">{{ __("Expired Leave(s)") }}</th>
+ <th style="width: 16%" class="text-right">{{ __("Used Leave(s)") }}</th>
+ <th style="width: 16%" class="text-right">{{ __("Leave(s) Pending Approval") }}</th>
+ <th style="width: 16%" class="text-right">{{ __("Available Leave(s)") }}</th>
</tr>
</thead>
<tbody>
@@ -18,7 +18,7 @@
<td class="text-right"> {%= value["total_leaves"] %} </td>
<td class="text-right"> {%= value["expired_leaves"] %} </td>
<td class="text-right"> {%= value["leaves_taken"] %} </td>
- <td class="text-right"> {%= value["pending_leaves"] %} </td>
+ <td class="text-right"> {%= value["leaves_pending_approval"] %} </td>
<td class="text-right"> {%= value["remaining_leaves"] %} </td>
</tr>
{% } %}
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 01e0ca0..7d32fd8 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -17,12 +17,16 @@
)
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_allocation.test_leave_allocation import create_leave_allocation
from erpnext.hr.doctype.leave_application.leave_application import (
+ InsufficientLeaveBalanceError,
+ LeaveAcrossAllocationsError,
LeaveDayBlockedError,
NotAnOptionalHoliday,
OverlapError,
get_leave_balance_on,
+ get_leave_details,
)
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees,
@@ -33,7 +37,7 @@
make_leave_application,
)
-test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"]
+test_dependencies = ["Leave Type", "Leave Allocation", "Leave Block List", "Employee"]
_test_records = [
{
@@ -72,15 +76,28 @@
class TestLeaveApplication(unittest.TestCase):
def setUp(self):
for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
- frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
+ frappe.db.delete(dt)
frappe.set_user("Administrator")
set_leave_approver()
- frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
+ frappe.db.delete("Attendance", {"employee": "_T-Employee-00001"})
+ frappe.db.set_value("Employee", "_T-Employee-00001", "holiday_list", "")
+
+ from_date = get_year_start(getdate())
+ to_date = get_year_ending(getdate())
+ self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date)
+
+ if not frappe.db.exists("Leave Type", "_Test Leave Type"):
+ frappe.get_doc(dict(
+ leave_type_name="_Test Leave Type",
+ doctype="Leave Type",
+ include_holiday=True
+ )).insert()
def tearDown(self):
frappe.db.rollback()
+ frappe.set_user("Administrator")
def _clear_roles(self):
frappe.db.sql("""delete from `tabHas Role` where parent in
@@ -95,6 +112,132 @@
application.to_date = "2013-01-05"
return application
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
+ def test_validate_application_across_allocations(self):
+ # Test validation for application dates when negative balance is disabled
+ frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
+ leave_type = frappe.get_doc(dict(
+ leave_type_name="Test Leave Validation",
+ doctype="Leave Type",
+ allow_negative=False
+ )).insert()
+
+ employee = get_employee()
+ date = getdate()
+ first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date))
+
+ leave_application = frappe.get_doc(dict(
+ doctype='Leave Application',
+ employee=employee.name,
+ leave_type=leave_type.name,
+ from_date=add_days(first_sunday, 1),
+ to_date=add_days(first_sunday, 4),
+ company="_Test Company",
+ status="Approved",
+ leave_approver = 'test@example.com'
+ ))
+ # Application period cannot be outside leave allocation period
+ self.assertRaises(frappe.ValidationError, leave_application.insert)
+
+ make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
+
+ leave_application = frappe.get_doc(dict(
+ doctype='Leave Application',
+ employee=employee.name,
+ leave_type=leave_type.name,
+ from_date=add_days(first_sunday, -10),
+ to_date=add_days(first_sunday, 1),
+ company="_Test Company",
+ status="Approved",
+ leave_approver = 'test@example.com'
+ ))
+
+ # Application period cannot be across two allocation records
+ self.assertRaises(LeaveAcrossAllocationsError, leave_application.insert)
+
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
+ def test_insufficient_leave_balance_validation(self):
+ # CASE 1: Validation when allow negative is disabled
+ frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
+ leave_type = frappe.get_doc(dict(
+ leave_type_name="Test Leave Validation",
+ doctype="Leave Type",
+ allow_negative=False
+ )).insert()
+
+ employee = get_employee()
+ date = getdate()
+ first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date))
+
+ # allocate 2 leaves, apply for more
+ make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date), leaves=2)
+ leave_application = frappe.get_doc(dict(
+ doctype='Leave Application',
+ employee=employee.name,
+ leave_type=leave_type.name,
+ from_date=add_days(first_sunday, 1),
+ to_date=add_days(first_sunday, 3),
+ company="_Test Company",
+ status="Approved",
+ leave_approver = 'test@example.com'
+ ))
+ self.assertRaises(InsufficientLeaveBalanceError, leave_application.insert)
+
+ # CASE 2: Allows creating application with a warning message when allow negative is enabled
+ frappe.db.set_value("Leave Type", "Test Leave Validation", "allow_negative", True)
+ make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 3), leave_type.name)
+
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
+ def test_separate_leave_ledger_entry_for_boundary_applications(self):
+ # When application falls in 2 different allocations and Allow Negative is enabled
+ # creates separate leave ledger entries
+ frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
+ leave_type = frappe.get_doc(dict(
+ leave_type_name="Test Leave Validation",
+ doctype="Leave Type",
+ allow_negative=True
+ )).insert()
+
+ employee = get_employee()
+ date = getdate()
+ year_start = getdate(get_year_start(date))
+ year_end = getdate(get_year_ending(date))
+
+ make_allocation_record(leave_type=leave_type.name, from_date=year_start, to_date=year_end)
+ # application across allocations
+
+ # CASE 1: from date has no allocation, to date has an allocation / both dates have allocation
+ application = make_leave_application(employee.name, add_days(year_start, -10), add_days(year_start, 3), leave_type.name)
+
+ # 2 separate leave ledger entries
+ ledgers = frappe.db.get_all("Leave Ledger Entry", {
+ "transaction_type": "Leave Application",
+ "transaction_name": application.name
+ }, ["leaves", "from_date", "to_date"], order_by="from_date")
+ self.assertEqual(len(ledgers), 2)
+
+ self.assertEqual(ledgers[0].from_date, application.from_date)
+ self.assertEqual(ledgers[0].to_date, add_days(year_start, -1))
+
+ self.assertEqual(ledgers[1].from_date, year_start)
+ self.assertEqual(ledgers[1].to_date, application.to_date)
+
+ # CASE 2: from date has an allocation, to date has no allocation
+ application = make_leave_application(employee.name, add_days(year_end, -3), add_days(year_end, 5), leave_type.name)
+
+ # 2 separate leave ledger entries
+ ledgers = frappe.db.get_all("Leave Ledger Entry", {
+ "transaction_type": "Leave Application",
+ "transaction_name": application.name
+ }, ["leaves", "from_date", "to_date"], order_by="from_date")
+ self.assertEqual(len(ledgers), 2)
+
+ self.assertEqual(ledgers[0].from_date, application.from_date)
+ self.assertEqual(ledgers[0].to_date, year_end)
+
+ self.assertEqual(ledgers[1].from_date, add_days(year_end, 1))
+ self.assertEqual(ledgers[1].to_date, application.to_date)
+
def test_overwrite_attendance(self):
'''check attendance is automatically created on leave approval'''
make_allocation_record()
@@ -119,6 +262,7 @@
for d in ('2018-01-01', '2018-01-02', '2018-01-03'):
self.assertTrue(getdate(d) in dates)
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_attendance_for_include_holidays(self):
# Case 1: leave type with 'Include holidays within leaves as leaves' enabled
frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1)
@@ -131,12 +275,8 @@
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
- holiday_list = make_holiday_list()
employee = get_employee()
- original_holiday_list = employee.holiday_list
- frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list)
-
- first_sunday = get_first_sunday(holiday_list)
+ first_sunday = get_first_sunday(self.holiday_list)
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
@@ -145,8 +285,7 @@
leave_application.cancel()
- frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
-
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_attendance_update_for_exclude_holidays(self):
# Case 2: leave type with 'Include holidays within leaves as leaves' disabled
frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1)
@@ -159,11 +298,8 @@
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
- holiday_list = make_holiday_list()
employee = get_employee()
- original_holiday_list = employee.holiday_list
- frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list)
- first_sunday = get_first_sunday(holiday_list)
+ first_sunday = get_first_sunday(self.holiday_list)
# already marked attendance on a holiday should be deleted in this case
config = {
@@ -184,6 +320,7 @@
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name, employee.company)
leave_application.reload()
+
# holiday should be excluded while marking attendance
self.assertEqual(leave_application.total_leave_days, 3)
self.assertEqual(frappe.db.count("Attendance", {"leave_application": leave_application.name}), 3)
@@ -194,8 +331,6 @@
# attendance on non-holiday updated
self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave")
- frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
-
def test_block_list(self):
self._clear_roles()
@@ -327,17 +462,14 @@
application.half_day_date = "2013-01-05"
application.insert()
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_optional_leave(self):
leave_period = get_leave_period()
today = nowdate()
holiday_list = 'Test Holiday List for Optional Holiday'
employee = get_employee()
- default_holiday_list = make_holiday_list()
- original_holiday_list = employee.holiday_list
- frappe.db.set_value("Employee", employee.name, "holiday_list", default_holiday_list)
- first_sunday = get_first_sunday(default_holiday_list)
-
+ first_sunday = get_first_sunday(self.holiday_list)
optional_leave_date = add_days(first_sunday, 1)
if not frappe.db.exists('Holiday List', holiday_list):
@@ -386,8 +518,6 @@
# check leave balance is reduced
self.assertEqual(get_leave_balance_on(employee.name, leave_type, optional_leave_date), 9)
- frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
-
def test_leaves_allowed(self):
employee = get_employee()
leave_period = get_leave_period()
@@ -513,11 +643,13 @@
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1,
expire_carry_forwarded_leaves_after_days=90)
- leave_type.submit()
+ leave_type.insert()
create_carry_forwarded_allocation(employee, leave_type)
+ details = get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8), for_consumption=True)
- self.assertEqual(get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8)), 21)
+ self.assertEqual(details.leave_balance_for_consumption, 21)
+ self.assertEqual(details.leave_balance, 30)
def test_earned_leaves_creation(self):
@@ -571,7 +703,14 @@
# test to not consider current leave in leave balance while submitting
def test_current_leave_on_submit(self):
employee = get_employee()
- leave_type = 'Sick leave'
+
+ leave_type = 'Sick Leave'
+ if not frappe.db.exists('Leave Type', leave_type):
+ frappe.get_doc(dict(
+ leave_type_name=leave_type,
+ doctype='Leave Type'
+ )).insert()
+
allocation = frappe.get_doc(dict(
doctype = 'Leave Allocation',
employee = employee.name,
@@ -714,6 +853,35 @@
employee.leave_approver = ""
employee.save()
+ @set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
+ def test_get_leave_details_for_dashboard(self):
+ employee = get_employee()
+ date = getdate()
+ year_start = getdate(get_year_start(date))
+ year_end = getdate(get_year_ending(date))
+
+ # ALLOCATION = 30
+ allocation = make_allocation_record(employee=employee.name, from_date=year_start, to_date=year_end)
+
+ # USED LEAVES = 4
+ first_sunday = get_first_sunday(self.holiday_list)
+ leave_application = make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
+ leave_application.reload()
+
+ # LEAVES PENDING APPROVAL = 1
+ leave_application = make_leave_application(employee.name, add_days(first_sunday, 5), add_days(first_sunday, 5),
+ '_Test Leave Type', submit=False)
+ leave_application.status = 'Open'
+ leave_application.save()
+
+ details = get_leave_details(employee.name, allocation.from_date)
+ leave_allocation = details['leave_allocation']['_Test Leave Type']
+ self.assertEqual(leave_allocation['total_leaves'], 30)
+ self.assertEqual(leave_allocation['leaves_taken'], 4)
+ self.assertEqual(leave_allocation['expired_leaves'], 0)
+ self.assertEqual(leave_allocation['leaves_pending_approval'], 1)
+ self.assertEqual(leave_allocation['remaining_leaves'], 26)
+
def create_carry_forwarded_allocation(employee, leave_type):
# initial leave allocation
@@ -735,19 +903,22 @@
carry_forward=1)
leave_allocation.submit()
-def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None):
+def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None, carry_forward=False, leaves=None):
allocation = frappe.get_doc({
"doctype": "Leave Allocation",
"employee": employee or "_T-Employee-00001",
"leave_type": leave_type or "_Test Leave Type",
"from_date": from_date or "2013-01-01",
"to_date": to_date or "2019-12-31",
- "new_leaves_allocated": 30
+ "new_leaves_allocated": leaves or 30,
+ "carry_forward": carry_forward
})
allocation.insert(ignore_permissions=True)
allocation.submit()
+ return allocation
+
def get_employee():
return frappe.get_doc("Employee", "_T-Employee-00001")
diff --git a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
index 5c5299e..a5923e0 100644
--- a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
+++ b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
@@ -171,7 +171,7 @@
''' Expires remaining leaves in the on carried forward allocation '''
from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period
leaves_taken = get_leaves_for_period(allocation.employee, allocation.leave_type,
- allocation.from_date, allocation.to_date, do_not_skip_expired_leaves=True)
+ allocation.from_date, allocation.to_date, skip_expired_leaves=False)
leaves = flt(allocation.leaves) + flt(leaves_taken)
# allow expired leaves entry to be created
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index a19ddce..27e4f14 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import add_months, get_first_day, get_last_day, getdate
+from frappe.utils import add_days, add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@@ -95,9 +95,12 @@
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
+
+ # second last day of the month
+ # leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
+ frappe.flags.current_date = add_days(get_last_day(getdate()), -1)
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
- # leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py
index b375b18..66c1d25 100644
--- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py
+++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py
@@ -3,18 +3,21 @@
from itertools import groupby
+from typing import Dict, List, Optional, Tuple
import frappe
from frappe import _
-from frappe.utils import add_days
+from frappe.utils import add_days, getdate
+from erpnext.hr.doctype.leave_allocation.leave_allocation import get_previous_allocation
from erpnext.hr.doctype.leave_application.leave_application import (
get_leave_balance_on,
get_leaves_for_period,
)
+Filters = frappe._dict
-def execute(filters=None):
+def execute(filters: Optional[Filters] = None) -> Tuple:
if filters.to_date <= filters.from_date:
frappe.throw(_('"From Date" can not be greater than or equal to "To Date"'))
@@ -23,8 +26,9 @@
charts = get_chart_data(data)
return columns, data, None, charts
-def get_columns():
- columns = [{
+
+def get_columns() -> List[Dict]:
+ return [{
'label': _('Leave Type'),
'fieldtype': 'Link',
'fieldname': 'leave_type',
@@ -46,32 +50,31 @@
'label': _('Opening Balance'),
'fieldtype': 'float',
'fieldname': 'opening_balance',
- 'width': 130,
+ 'width': 150,
}, {
- 'label': _('Leave Allocated'),
+ 'label': _('New Leave(s) Allocated'),
'fieldtype': 'float',
'fieldname': 'leaves_allocated',
- 'width': 130,
+ 'width': 200,
}, {
- 'label': _('Leave Taken'),
+ 'label': _('Leave(s) Taken'),
'fieldtype': 'float',
'fieldname': 'leaves_taken',
- 'width': 130,
+ 'width': 150,
}, {
- 'label': _('Leave Expired'),
+ 'label': _('Leave(s) Expired'),
'fieldtype': 'float',
'fieldname': 'leaves_expired',
- 'width': 130,
+ 'width': 150,
}, {
'label': _('Closing Balance'),
'fieldtype': 'float',
'fieldname': 'closing_balance',
- 'width': 130,
+ 'width': 150,
}]
- return columns
-def get_data(filters):
+def get_data(filters: Filters) -> List:
leave_types = frappe.db.get_list('Leave Type', pluck='name', order_by='name')
conditions = get_conditions(filters)
@@ -102,19 +105,18 @@
or ("HR Manager" in frappe.get_roles(user)):
if len(active_employees) > 1:
row = frappe._dict()
- row.employee = employee.name,
+ row.employee = employee.name
row.employee_name = employee.employee_name
leaves_taken = get_leaves_for_period(employee.name, leave_type,
filters.from_date, filters.to_date) * -1
- new_allocation, expired_leaves = get_allocated_and_expired_leaves(filters.from_date, filters.to_date, employee.name, leave_type)
-
-
- opening = get_leave_balance_on(employee.name, leave_type, add_days(filters.from_date, -1)) #allocation boundary condition
+ new_allocation, expired_leaves, carry_forwarded_leaves = get_allocated_and_expired_leaves(
+ filters.from_date, filters.to_date, employee.name, leave_type)
+ opening = get_opening_balance(employee.name, leave_type, filters, carry_forwarded_leaves)
row.leaves_allocated = new_allocation
- row.leaves_expired = expired_leaves - leaves_taken if expired_leaves - leaves_taken > 0 else 0
+ row.leaves_expired = expired_leaves
row.opening_balance = opening
row.leaves_taken = leaves_taken
@@ -125,7 +127,26 @@
return data
-def get_conditions(filters):
+
+def get_opening_balance(employee: str, leave_type: str, filters: Filters, carry_forwarded_leaves: float) -> float:
+ # allocation boundary condition
+ # opening balance is the closing leave balance 1 day before the filter start date
+ opening_balance_date = add_days(filters.from_date, -1)
+ allocation = get_previous_allocation(filters.from_date, leave_type, employee)
+
+ if allocation and allocation.get("to_date") and opening_balance_date and \
+ getdate(allocation.get("to_date")) == getdate(opening_balance_date):
+ # if opening balance date is same as the previous allocation's expiry
+ # then opening balance should only consider carry forwarded leaves
+ opening_balance = carry_forwarded_leaves
+ else:
+ # else directly get leave balance on the previous day
+ opening_balance = get_leave_balance_on(employee, leave_type, opening_balance_date)
+
+ return opening_balance
+
+
+def get_conditions(filters: Filters) -> Dict:
conditions={
'status': 'Active',
}
@@ -140,29 +161,26 @@
return conditions
-def get_department_leave_approver_map(department=None):
+def get_department_leave_approver_map(department: Optional[str] = None):
# get current department and all its child
department_list = frappe.get_list('Department',
- filters={
- 'disabled': 0
- },
- or_filters={
- 'name': department,
- 'parent_department': department
- },
- fields=['name'],
- pluck='name'
- )
+ filters={'disabled': 0},
+ or_filters={
+ 'name': department,
+ 'parent_department': department
+ },
+ pluck='name'
+ )
# retrieve approvers list from current department and from its subsequent child departments
approver_list = frappe.get_all('Department Approver',
- filters={
- 'parentfield': 'leave_approvers',
- 'parent': ('in', department_list)
- },
- fields=['parent', 'approver'],
- as_list=1
- )
+ filters={
+ 'parentfield': 'leave_approvers',
+ 'parent': ('in', department_list)
+ },
+ fields=['parent', 'approver'],
+ as_list=True
+ )
approvers = {}
@@ -171,41 +189,61 @@
return approvers
-def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type):
- from frappe.utils import getdate
-
+def get_allocated_and_expired_leaves(from_date: str, to_date: str, employee: str, leave_type: str) -> Tuple[float, float, float]:
new_allocation = 0
expired_leaves = 0
+ carry_forwarded_leaves = 0
- records= frappe.db.sql("""
- SELECT
- employee, leave_type, from_date, to_date, leaves, transaction_name,
- transaction_type, is_carry_forward, is_expired
- FROM `tabLeave Ledger Entry`
- WHERE employee=%(employee)s AND leave_type=%(leave_type)s
- AND docstatus=1
- AND transaction_type = 'Leave Allocation'
- 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))
- """, {
- "from_date": from_date,
- "to_date": to_date,
- "employee": employee,
- "leave_type": leave_type
- }, as_dict=1)
+ records = get_leave_ledger_entries(from_date, to_date, employee, leave_type)
for record in records:
+ # new allocation records with `is_expired=1` are created when leave expires
+ # these new records should not be considered, else it leads to negative leave balance
+ if record.is_expired:
+ continue
+
if record.to_date < getdate(to_date):
+ # leave allocations ending before to_date, reduce leaves taken within that period
+ # since they are already used, they won't expire
expired_leaves += record.leaves
+ expired_leaves += get_leaves_for_period(employee, leave_type,
+ record.from_date, record.to_date)
if record.from_date >= getdate(from_date):
- new_allocation += record.leaves
+ if record.is_carry_forward:
+ carry_forwarded_leaves += record.leaves
+ else:
+ new_allocation += record.leaves
- return new_allocation, expired_leaves
+ return new_allocation, expired_leaves, carry_forwarded_leaves
-def get_chart_data(data):
+
+def get_leave_ledger_entries(from_date: str, to_date: str, employee: str, leave_type: str) -> List[Dict]:
+ ledger = frappe.qb.DocType('Leave Ledger Entry')
+ records = (
+ frappe.qb.from_(ledger)
+ .select(
+ ledger.employee, ledger.leave_type, ledger.from_date, ledger.to_date,
+ ledger.leaves, ledger.transaction_name, ledger.transaction_type,
+ ledger.is_carry_forward, ledger.is_expired
+ ).where(
+ (ledger.docstatus == 1)
+ & (ledger.transaction_type == 'Leave Allocation')
+ & (ledger.employee == employee)
+ & (ledger.leave_type == leave_type)
+ & (
+ (ledger.from_date[from_date: to_date])
+ | (ledger.to_date[from_date: to_date])
+ | ((ledger.from_date < from_date) & (ledger.to_date > to_date))
+ )
+ )
+ ).run(as_dict=True)
+
+ return records
+
+
+def get_chart_data(data: List) -> Dict:
labels = []
datasets = []
employee_data = data
@@ -224,7 +262,8 @@
return chart
-def get_dataset_for_chart(employee_data, datasets, labels):
+
+def get_dataset_for_chart(employee_data: List, datasets: List, labels: List) -> List:
leaves = []
employee_data = sorted(employee_data, key=lambda k: k['employee_name'])
diff --git a/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py
new file mode 100644
index 0000000..b2ed72c
--- /dev/null
+++ b/erpnext/hr/report/employee_leave_balance/test_employee_leave_balance.py
@@ -0,0 +1,161 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+
+import unittest
+
+import frappe
+from frappe.utils import add_days, add_months, flt, 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_application.test_leave_application import (
+ get_first_sunday,
+ make_allocation_record,
+)
+from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
+from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
+from erpnext.hr.report.employee_leave_balance.employee_leave_balance import execute
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
+
+test_records = frappe.get_test_records('Leave Type')
+
+class TestEmployeeLeaveBalance(unittest.TestCase):
+ def setUp(self):
+ for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']:
+ frappe.db.delete(dt)
+
+ frappe.set_user('Administrator')
+
+ self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
+
+ self.date = getdate()
+ self.year_start = getdate(get_year_start(self.date))
+ self.mid_year = add_months(self.year_start, 6)
+ self.year_end = getdate(get_year_ending(self.date))
+
+ self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end)
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
+ def test_employee_leave_balance(self):
+ frappe.get_doc(test_records[0]).insert()
+
+ # 5 leaves
+ allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11),
+ to_date=add_days(self.year_start, -1), leaves=5)
+ # 30 leaves
+ allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
+ # expires 5 leaves
+ process_expired_allocation()
+
+ # 4 days leave
+ first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
+ leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
+ leave_application.reload()
+
+ filters = frappe._dict({
+ 'from_date': allocation1.from_date,
+ 'to_date': allocation2.to_date,
+ 'employee': self.employee_id
+ })
+
+ report = execute(filters)
+
+ expected_data = [{
+ 'leave_type': '_Test Leave Type',
+ 'employee': self.employee_id,
+ 'employee_name': 'test_emp_leave_balance@example.com',
+ 'leaves_allocated': flt(allocation1.new_leaves_allocated + allocation2.new_leaves_allocated),
+ 'leaves_expired': flt(allocation1.new_leaves_allocated),
+ 'opening_balance': flt(0),
+ 'leaves_taken': flt(leave_application.total_leave_days),
+ 'closing_balance': flt(allocation2.new_leaves_allocated - leave_application.total_leave_days),
+ 'indent': 1
+ }]
+
+ self.assertEqual(report[1], expected_data)
+
+ @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
+ def test_opening_balance_on_alloc_boundary_dates(self):
+ frappe.get_doc(test_records[0]).insert()
+
+ # 30 leaves allocated
+ allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
+ # 4 days leave application in the first allocation
+ first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
+ leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
+ leave_application.reload()
+
+ # Case 1: opening balance for first alloc boundary
+ filters = frappe._dict({
+ 'from_date': self.year_start,
+ 'to_date': self.year_end,
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+ self.assertEqual(report[1][0].opening_balance, 0)
+
+ # Case 2: opening balance after leave application date
+ filters = frappe._dict({
+ 'from_date': add_days(leave_application.to_date, 1),
+ 'to_date': self.year_end,
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+ self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days))
+
+ # Case 3: leave balance shows actual balance and not consumption balance as per remaining days near alloc end date
+ # eg: 3 days left for alloc to end, leave balance should still be 26 and not 3
+ filters = frappe._dict({
+ 'from_date': add_days(self.year_end, -3),
+ 'to_date': self.year_end,
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+ self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days))
+
+ @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
+ def test_opening_balance_considers_carry_forwarded_leaves(self):
+ leave_type = create_leave_type(
+ leave_type_name="_Test_CF_leave_expiry",
+ is_carry_forward=1)
+ leave_type.insert()
+
+ # 30 leaves allocated for first half of the year
+ allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start,
+ to_date=self.mid_year, leave_type=leave_type.name)
+ # 4 days leave application in the first allocation
+ first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
+ leave_application = make_leave_application(self.employee_id, first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application.reload()
+ # 30 leaves allocated for second half of the year + carry forward leaves (26) from the previous allocation
+ allocation2 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.mid_year, 1), to_date=self.year_end,
+ carry_forward=True, leave_type=leave_type.name)
+
+ # Case 1: carry forwarded leaves considered in opening balance for second alloc
+ filters = frappe._dict({
+ 'from_date': add_days(self.mid_year, 1),
+ 'to_date': self.year_end,
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+ # available leaves from old alloc
+ opening_balance = allocation1.new_leaves_allocated - leave_application.total_leave_days
+ self.assertEqual(report[1][0].opening_balance, opening_balance)
+
+ # Case 2: opening balance one day after alloc boundary = carry forwarded leaves + new leaves alloc
+ filters = frappe._dict({
+ 'from_date': add_days(self.mid_year, 2),
+ 'to_date': self.year_end,
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+ # available leaves from old alloc
+ opening_balance = allocation2.new_leaves_allocated + (allocation1.new_leaves_allocated - leave_application.total_leave_days)
+ self.assertEqual(report[1][0].opening_balance, opening_balance)
diff --git a/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py b/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py
new file mode 100644
index 0000000..6f16a8d
--- /dev/null
+++ b/erpnext/hr/report/employee_leave_balance_summary/test_employee_leave_balance_summary.py
@@ -0,0 +1,117 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+
+import unittest
+
+import frappe
+from frappe.utils import add_days, flt, 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_application.test_leave_application import (
+ get_first_sunday,
+ make_allocation_record,
+)
+from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
+from erpnext.hr.report.employee_leave_balance_summary.employee_leave_balance_summary import execute
+from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
+ make_holiday_list,
+ make_leave_application,
+)
+
+test_records = frappe.get_test_records('Leave Type')
+
+class TestEmployeeLeaveBalance(unittest.TestCase):
+ def setUp(self):
+ for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']:
+ frappe.db.delete(dt)
+
+ frappe.set_user('Administrator')
+
+ self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
+ self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
+
+ self.date = getdate()
+ self.year_start = getdate(get_year_start(self.date))
+ self.year_end = getdate(get_year_ending(self.date))
+
+ self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end)
+
+ def tearDown(self):
+ frappe.db.rollback()
+
+ @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
+ def test_employee_leave_balance_summary(self):
+ frappe.get_doc(test_records[0]).insert()
+
+ # 5 leaves
+ allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11),
+ to_date=add_days(self.year_start, -1), leaves=5)
+ # 30 leaves
+ allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
+
+ # 2 days leave within the first allocation
+ leave_application1 = make_leave_application(self.employee_id, add_days(self.year_start, -11), add_days(self.year_start, -10),
+ '_Test Leave Type')
+ leave_application1.reload()
+
+ # expires 3 leaves
+ process_expired_allocation()
+
+ # 4 days leave within the second allocation
+ first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
+ leave_application2 = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
+ leave_application2.reload()
+
+ filters = frappe._dict({
+ 'date': add_days(leave_application2.to_date, 1),
+ 'company': '_Test Company',
+ 'employee': self.employee_id
+ })
+
+ report = execute(filters)
+
+ expected_data = [[
+ self.employee_id,
+ 'test_emp_leave_balance@example.com',
+ frappe.db.get_value('Employee', self.employee_id, 'department'),
+ flt(
+ allocation1.new_leaves_allocated # allocated = 5
+ + allocation2.new_leaves_allocated # allocated = 30
+ - leave_application1.total_leave_days # leaves taken in the 1st alloc = 2
+ - (allocation1.new_leaves_allocated - leave_application1.total_leave_days) # leaves expired from 1st alloc = 3
+ - leave_application2.total_leave_days # leaves taken in the 2nd alloc = 4
+ )
+ ]]
+
+ self.assertEqual(report[1], expected_data)
+
+ @set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
+ def test_get_leave_balance_near_alloc_expiry(self):
+ frappe.get_doc(test_records[0]).insert()
+
+ # 30 leaves allocated
+ allocation = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
+ # 4 days leave application in the first allocation
+ first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
+ leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
+ leave_application.reload()
+
+ # Leave balance should show actual balance, and not "consumption balance as per remaining days", near alloc end date
+ # eg: 3 days left for alloc to end, leave balance should still be 26 and not 3
+ filters = frappe._dict({
+ 'date': add_days(self.year_end, -3),
+ 'company': '_Test Company',
+ 'employee': self.employee_id
+ })
+ report = execute(filters)
+
+ expected_data = [[
+ self.employee_id,
+ 'test_emp_leave_balance@example.com',
+ frappe.db.get_value('Employee', self.employee_id, 'department'),
+ flt(allocation.new_leaves_allocated - leave_application.total_leave_days)
+ ]]
+
+ self.assertEqual(report[1], expected_data)
diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py
index a025ff7..15fa67b 100644
--- a/erpnext/manufacturing/doctype/bom/bom.py
+++ b/erpnext/manufacturing/doctype/bom/bom.py
@@ -170,6 +170,7 @@
frappe.throw(_("Please select a Company first."), title=_("Mandatory"))
self.clear_operations()
+ self.clear_inspection()
self.validate_main_item()
self.validate_currency()
self.set_conversion_rate()
@@ -425,6 +426,10 @@
if not self.with_operations:
self.set('operations', [])
+ def clear_inspection(self):
+ if not self.inspection_required:
+ self.quality_inspection_template = None
+
def validate_main_item(self):
""" Validate main FG item"""
item = self.get_item_det(self.item)
diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py
index 4417123..2f9b9de 100644
--- a/erpnext/manufacturing/doctype/bom/test_bom.py
+++ b/erpnext/manufacturing/doctype/bom/test_bom.py
@@ -6,7 +6,6 @@
from functools import partial
import frappe
-from frappe.test_runner import make_test_records
from frappe.tests.utils import FrappeTestCase
from frappe.utils import cstr, flt
@@ -20,12 +19,9 @@
from erpnext.tests.test_subcontracting import set_backflush_based_on
test_records = frappe.get_test_records('BOM')
+test_dependencies = ["Item", "Quality Inspection Template"]
class TestBOM(FrappeTestCase):
- def setUp(self):
- if not frappe.get_value('Item', '_Test Item'):
- make_test_records('Item')
-
def test_get_items(self):
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
items_dict = get_bom_items_as_dict(bom=get_default_bom(),
@@ -495,6 +491,24 @@
self.assertNotEqual(amendment.name, version.name)
self.assertEqual(int(version.name.split("-")[-1]), 2)
+ def test_clear_inpection_quality(self):
+
+ bom = frappe.copy_doc(test_records[2], ignore_no_copy=True)
+ bom.docstatus = 0
+ bom.is_default = 0
+ bom.quality_inspection_template = "_Test Quality Inspection Template"
+ bom.inspection_required = 1
+ bom.save()
+ bom.reload()
+
+ self.assertEqual(bom.quality_inspection_template, '_Test Quality Inspection Template')
+
+ bom.inspection_required = 0
+ bom.save()
+ bom.reload()
+
+ self.assertEqual(bom.quality_inspection_template, None)
+
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/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 9f4ace2..5f492d7 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -48,7 +48,7 @@
self.validate_work_order()
def set_sub_operations(self):
- if self.operation:
+ if not self.sub_operations and self.operation:
self.sub_operations = []
for row in frappe.get_all('Sub Operation',
filters = {'parent': self.operation}, fields=['operation', 'idx'], order_by='idx'):
diff --git a/erpnext/manufacturing/doctype/job_card/job_card_list.js b/erpnext/manufacturing/doctype/job_card/job_card_list.js
index 8017209..7f60bdc 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card_list.js
+++ b/erpnext/manufacturing/doctype/job_card/job_card_list.js
@@ -1,4 +1,5 @@
frappe.listview_settings['Job Card'] = {
+ has_indicator_for_draft: true,
get_indicator: function(doc) {
if (doc.status === "Work In Progress") {
return [__("Work In Progress"), "orange", "status,=,Work In Progress"];
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json
index 3bfb764..339c6af 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.json
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json
@@ -190,7 +190,7 @@
"label": "Select Items to Manufacture"
},
{
- "depends_on": "get_items_from",
+ "depends_on": "eval:doc.get_items_from && doc.docstatus == 0",
"fieldname": "get_items",
"fieldtype": "Button",
"label": "Get Finished Goods for Manufacture"
@@ -198,6 +198,7 @@
{
"fieldname": "po_items",
"fieldtype": "Table",
+ "label": "Assembly Items",
"no_copy": 1,
"options": "Production Plan Item",
"reqd": 1
@@ -350,6 +351,7 @@
"hide_border": 1
},
{
+ "depends_on": "get_items_from",
"fieldname": "sub_assembly_items",
"fieldtype": "Table",
"label": "Sub Assembly Items",
@@ -357,6 +359,7 @@
"options": "Production Plan Sub Assembly Item"
},
{
+ "depends_on": "eval:doc.po_items && doc.po_items.length && doc.docstatus == 0",
"fieldname": "get_sub_assembly_items",
"fieldtype": "Button",
"label": "Get Sub Assembly Items"
@@ -382,7 +385,7 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-02-23 17:16:10.629378",
+ "modified": "2022-03-14 03:56:23.209247",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Production Plan",
@@ -404,5 +407,6 @@
}
],
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index eeab788..6425374 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -655,6 +655,17 @@
]
self.assertFalse(pp.all_items_completed())
+ def test_production_plan_planned_qty(self):
+ pln = create_production_plan(item_code="_Test FG Item", planned_qty=0.55)
+ pln.make_work_order()
+ work_order = frappe.db.get_value('Work Order', {'production_plan': pln.name}, 'name')
+ wo_doc = frappe.get_doc('Work Order', work_order)
+ wo_doc.update({
+ 'wip_warehouse': 'Work In Progress - _TC',
+ 'fg_warehouse': 'Finished Goods - _TC'
+ })
+ wo_doc.submit()
+ self.assertEqual(wo_doc.qty, 0.55)
def create_production_plan(**args):
"""
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index bc07d22..6a8136d 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -2,7 +2,7 @@
# License: GNU General Public License v3. See license.txt
import frappe
-from frappe.tests.utils import FrappeTestCase, timeout
+from frappe.tests.utils import FrappeTestCase, change_settings, timeout
from frappe.utils import add_days, add_months, cint, flt, now, today
from erpnext.manufacturing.doctype.job_card.job_card import JobCardCancelError
@@ -355,7 +355,6 @@
wo_order = make_wo_order_test_record(planned_start_date=now(),
sales_order=so.name, qty=3)
- wo_order.submit()
self.assertEqual(wo_order.docstatus, 1)
allow_overproduction("overproduction_percentage_for_sales_order", 0)
@@ -976,6 +975,28 @@
frappe.db.set_value("Manufacturing Settings", None,
"backflush_raw_materials_based_on", "BOM")
+ @change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 1})
+ def test_auto_batch_creation(self):
+ from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
+
+ fg_item = frappe.generate_hash(length=20)
+ child_item = frappe.generate_hash(length=20)
+
+ bom_tree = {fg_item: {child_item: {}}}
+
+ create_nested_bom(bom_tree, prefix="")
+
+ item = frappe.get_doc("Item", fg_item)
+ item.has_batch_no = 1
+ item.create_new_batch = 0
+ item.save()
+
+ try:
+ make_wo_order_test_record(item=fg_item)
+ except frappe.MandatoryError:
+ self.fail("Batch generation causing failing in Work Order")
+
+
def update_job_card(job_card, jc_qty=None):
employee = frappe.db.get_value('Employee', {'status': 'Active'}, 'name')
job_card_doc = frappe.get_doc('Job Card', job_card)
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 374ab86..7eb40ec 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -333,6 +333,14 @@
if not self.batch_size:
self.batch_size = total_qty
+ batch_auto_creation = frappe.get_cached_value("Item", self.production_item, "create_new_batch")
+ if not batch_auto_creation:
+ frappe.msgprint(
+ _("Batch not created for item {} since it does not have a batch series.")
+ .format(frappe.bold(self.production_item)),
+ alert=True, indicator="orange")
+ return
+
while total_qty > 0:
qty = self.batch_size
if self.batch_size >= total_qty:
@@ -639,12 +647,12 @@
if self.production_plan and self.production_plan_item:
qty_dict = frappe.db.get_value("Production Plan Item", self.production_plan_item, ["planned_qty", "ordered_qty"], as_dict=1)
- allowance_qty =flt(frappe.db.get_single_value("Manufacturing Settings",
+ allowance_qty = flt(frappe.db.get_single_value("Manufacturing Settings",
"overproduction_percentage_for_work_order"))/100 * qty_dict.get("planned_qty", 0)
max_qty = qty_dict.get("planned_qty", 0) + allowance_qty - qty_dict.get("ordered_qty", 0)
- if max_qty < 1:
+ if not max_qty > 0:
frappe.throw(_("Cannot produce more item for {0}")
.format(self.production_item), OverProductionError)
elif self.qty > max_qty:
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index ebda805..16d8c73 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -358,4 +358,5 @@
erpnext.patches.v14_0.update_batch_valuation_flag
erpnext.patches.v14_0.delete_non_profit_doctypes
erpnext.patches.v14_0.update_employee_advance_status
-erpnext.patches.v13_0.add_cost_center_in_loans
\ No newline at end of file
+erpnext.patches.v13_0.add_cost_center_in_loans
+erpnext.patches.v13_0.remove_unknown_links_to_prod_plan_items
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
index 5512543..6f9031f 100644
--- 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
@@ -6,14 +6,14 @@
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)
- employee_with_assignment = []
- leave_policy =[]
-
- #for employee
-
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:
@@ -22,12 +22,10 @@
employee_with_assignment.append(employee.name)
leave_policy.append(employee.leave_policy)
-
- if "default_leave_policy" in frappe.db.get_table_columns("Employee"):
+ 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:
@@ -47,13 +45,13 @@
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
- frappe.reload_doc('hr', 'doctype', 'leave_policy_assignment')
-
if not frappe.db.exists("Leave Policy Assignment" , filters):
lpa = frappe.new_doc("Leave Policy Assignment")
lpa.employee = employee
diff --git a/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py b/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py
new file mode 100644
index 0000000..317e85e
--- /dev/null
+++ b/erpnext/patches/v13_0/remove_unknown_links_to_prod_plan_items.py
@@ -0,0 +1,34 @@
+import frappe
+
+
+def execute():
+ """
+ Remove "production_plan_item" field where linked field doesn't exist in tha table.
+ """
+
+ work_order = frappe.qb.DocType("Work Order")
+ pp_item = frappe.qb.DocType("Production Plan Item")
+
+ broken_work_orders = (
+ frappe.qb
+ .from_(work_order)
+ .left_join(pp_item).on(work_order.production_plan_item == pp_item.name)
+ .select(work_order.name)
+ .where(
+ (work_order.docstatus == 0)
+ & (work_order.production_plan_item.notnull())
+ & (work_order.production_plan_item.like("new-production-plan%"))
+ & (pp_item.name.isnull())
+ )
+ ).run(pluck=True)
+
+ if not broken_work_orders:
+ return
+
+ (frappe.qb
+ .update(work_order)
+ .set(work_order.production_plan_item, None)
+ .where(work_order.name.isin(broken_work_orders))
+ ).run()
+
+
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index a634dfe..32b0f0f 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -719,7 +719,7 @@
where reference_type="Payroll Entry")
order by name limit %(start)s, %(page_len)s"""
.format(key=searchfield), {
- 'txt': "%%%s%%" % frappe.db.escape(txt),
+ 'txt': "%%%s%%" % txt,
'start': start, 'page_len': page_len
})
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index b44dbb9..caaed1f 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -1002,7 +1002,7 @@
# apply rounding
if frappe.get_cached_value("Salary Component", row.salary_component, "round_to_the_nearest_integer"):
- amount, additional_amount = rounded(amount), rounded(additional_amount)
+ amount, additional_amount = rounded(amount or 0), rounded(additional_amount or 0)
return amount, additional_amount
@@ -1279,9 +1279,9 @@
def set_base_totals(self):
self.base_gross_pay = flt(self.gross_pay) * flt(self.exchange_rate)
self.base_total_deduction = flt(self.total_deduction) * flt(self.exchange_rate)
- self.rounded_total = rounded(self.net_pay)
+ self.rounded_total = rounded(self.net_pay or 0)
self.base_net_pay = flt(self.net_pay) * flt(self.exchange_rate)
- self.base_rounded_total = rounded(self.base_net_pay)
+ self.base_rounded_total = rounded(self.base_net_pay or 0)
self.set_net_total_in_words()
#calculate total working hours, earnings based on hourly wages and totals
@@ -1393,7 +1393,7 @@
'total_allocated_leaves': flt(leave_values.get('total_leaves')),
'expired_leaves': flt(leave_values.get('expired_leaves')),
'used_leaves': flt(leave_values.get('leaves_taken')),
- 'pending_leaves': flt(leave_values.get('pending_leaves')),
+ 'pending_leaves': flt(leave_values.get('leaves_pending_approval')),
'available_leaves': flt(leave_values.get('remaining_leaves'))
})
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index fe15f2d..5e41b66 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -411,7 +411,7 @@
def test_email_salary_slip(self):
frappe.db.sql("delete from `tabEmail Queue`")
- make_employee("test_email_salary_slip@salary.com")
+ make_employee("test_email_salary_slip@salary.com", company="_Test Company")
ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email")
ss.company = "_Test Company"
ss.save()
@@ -1057,7 +1057,7 @@
}).submit()
return salary_date
-def make_leave_application(employee, from_date, to_date, leave_type, company=None):
+def make_leave_application(employee, from_date, to_date, leave_type, company=None, submit=True):
leave_application = frappe.get_doc(dict(
doctype = 'Leave Application',
employee = employee,
@@ -1065,11 +1065,12 @@
from_date = from_date,
to_date = to_date,
company = company or erpnext.get_default_company() or "_Test Company",
- docstatus = 1,
status = "Approved",
leave_approver = 'test@example.com'
- ))
- leave_application.submit()
+ )).insert()
+
+ if submit:
+ leave_application.submit()
return leave_application
@@ -1087,20 +1088,22 @@
frappe.db.set_value('HR Settings', None, 'leave_status_notification_template', None)
frappe.db.set_value('HR Settings', None, 'leave_approval_notification_template', None)
-def make_holiday_list(holiday_list_name=None):
+def make_holiday_list(list_name=None, from_date=None, to_date=None):
fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company())
- holiday_list = frappe.db.exists("Holiday List", holiday_list_name or "Salary Slip Test Holiday List")
- if not holiday_list:
- holiday_list = frappe.get_doc({
- "doctype": "Holiday List",
- "holiday_list_name": holiday_list_name or "Salary Slip Test Holiday List",
- "from_date": fiscal_year[1],
- "to_date": fiscal_year[2],
- "weekly_off": "Sunday"
- }).insert()
- holiday_list.get_weekly_off_dates()
- holiday_list.save()
- holiday_list = holiday_list.name
+ name = list_name or "Salary Slip Test Holiday List"
+
+ frappe.delete_doc_if_exists("Holiday List", name, force=True)
+
+ holiday_list = frappe.get_doc({
+ "doctype": "Holiday List",
+ "holiday_list_name": name,
+ "from_date": from_date or fiscal_year[1],
+ "to_date": to_date or fiscal_year[2],
+ "weekly_off": "Sunday"
+ }).insert()
+ holiday_list.get_weekly_off_dates()
+ holiday_list.save()
+ holiday_list = holiday_list.name
return holiday_list
diff --git a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json
index 7ac453b..60ed453 100644
--- a/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json
+++ b/erpnext/payroll/doctype/salary_slip_leave/salary_slip_leave.json
@@ -26,7 +26,7 @@
"fieldname": "total_allocated_leaves",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Total Allocated Leave",
+ "label": "Total Allocated Leave(s)",
"no_copy": 1,
"read_only": 1
},
@@ -34,7 +34,7 @@
"fieldname": "expired_leaves",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Expired Leave",
+ "label": "Expired Leave(s)",
"no_copy": 1,
"read_only": 1
},
@@ -42,7 +42,7 @@
"fieldname": "used_leaves",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Used Leave",
+ "label": "Used Leave(s)",
"no_copy": 1,
"read_only": 1
},
@@ -50,7 +50,7 @@
"fieldname": "pending_leaves",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Pending Leave",
+ "label": "Leave(s) Pending Approval",
"no_copy": 1,
"read_only": 1
},
@@ -58,7 +58,7 @@
"fieldname": "available_leaves",
"fieldtype": "Float",
"in_list_view": 1,
- "label": "Available Leave",
+ "label": "Available Leave(s)",
"no_copy": 1,
"read_only": 1
}
@@ -66,7 +66,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-02-19 10:47:48.546724",
+ "modified": "2022-02-28 14:01:32.327204",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Slip Leave",
@@ -74,5 +74,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss
index 666043b..019496d 100644
--- a/erpnext/public/scss/shopping_cart.scss
+++ b/erpnext/public/scss/shopping_cart.scss
@@ -569,15 +569,12 @@
}
.scroll-categories {
- white-space: nowrap;
- overflow-x: auto;
-
.category-pill {
- margin: 0px 4px;
display: inline-block;
- padding: 6px 12px;
- background-color: #ecf5fe;
width: fit-content;
+ padding: 6px 12px;
+ margin-bottom: 8px;
+ background-color: #ecf5fe;
font-size: 14px;
border-radius: 18px;
color: var(--blue-500);
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 cb79cf8..d5ff88c 100644
--- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
+++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
@@ -127,7 +127,8 @@
def get_inward_nil_exempt(self, state):
inward_nil_exempt = frappe.db.sql("""
- SELECT p.place_of_supply, sum(i.base_amount) as base_amount, i.is_nil_exempt, i.is_non_gst
+ SELECT p.place_of_supply, p.supplier_address,
+ i.base_amount, i.is_nil_exempt, i.is_non_gst
FROM `tabPurchase Invoice` p , `tabPurchase Invoice Item` i
WHERE p.docstatus = 1 and p.name = i.parent
and p.is_opening = 'No'
@@ -135,7 +136,7 @@
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
- GROUP BY p.place_of_supply, i.is_nil_exempt, i.is_non_gst""",
+ """,
(self.month_no, self.year, self.company, self.gst_details.get("gstin")), as_dict=1)
inward_nil_exempt_details = {
@@ -149,18 +150,24 @@
}
}
+ address_state_map = get_address_state_map()
+
for d in inward_nil_exempt:
- if d.place_of_supply:
- if (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \
- and state == d.place_of_supply.split("-")[1]:
- inward_nil_exempt_details["gst"]["intra"] += d.base_amount
- elif (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \
- and state != d.place_of_supply.split("-")[1]:
- inward_nil_exempt_details["gst"]["inter"] += d.base_amount
- elif d.is_non_gst == 1 and state == d.place_of_supply.split("-")[1]:
- inward_nil_exempt_details["non_gst"]["intra"] += d.base_amount
- elif d.is_non_gst == 1 and state != d.place_of_supply.split("-")[1]:
- inward_nil_exempt_details["non_gst"]["inter"] += d.base_amount
+ if not d.place_of_supply:
+ d.place_of_supply = "00-" + cstr(state)
+
+ supplier_state = address_state_map.get(d.supplier_address) or state
+
+ if (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \
+ and cstr(supplier_state) == cstr(d.place_of_supply.split("-")[1]):
+ inward_nil_exempt_details["gst"]["intra"] += d.base_amount
+ elif (d.is_nil_exempt == 1 or d.get('gst_category') == 'Registered Composition') \
+ and cstr(supplier_state) != cstr(d.place_of_supply.split("-")[1]):
+ inward_nil_exempt_details["gst"]["inter"] += d.base_amount
+ elif d.is_non_gst == 1 and cstr(supplier_state) == cstr(d.place_of_supply.split("-")[1]):
+ inward_nil_exempt_details["non_gst"]["intra"] += d.base_amount
+ elif d.is_non_gst == 1 and cstr(supplier_state) != cstr(d.place_of_supply.split("-")[1]):
+ inward_nil_exempt_details["non_gst"]["inter"] += d.base_amount
return inward_nil_exempt_details
@@ -419,6 +426,11 @@
return ",".join(missing_field_invoices)
+def get_address_state_map():
+ return frappe._dict(
+ frappe.get_all('Address', fields=['name', 'gst_state'], as_list=1)
+ )
+
def get_json(template):
file_path = os.path.join(os.path.dirname(__file__), '{template}.json'.format(template=template))
with open(file_path, 'r') as f:
diff --git a/erpnext/regional/saudi_arabia/utils.py b/erpnext/regional/saudi_arabia/utils.py
index a03c3f0..515862d 100644
--- a/erpnext/regional/saudi_arabia/utils.py
+++ b/erpnext/regional/saudi_arabia/utils.py
@@ -90,7 +90,7 @@
tlv_array.append(''.join([tag, length, value]))
# VAT Amount
- vat_amount = str(doc.total_taxes_and_charges)
+ vat_amount = str(get_vat_amount(doc))
tag = bytes([5]).hex()
length = bytes([len(vat_amount)]).hex()
@@ -127,6 +127,22 @@
doc.db_set('ksa_einv_qr', _file.file_url)
doc.notify_update()
+def get_vat_amount(doc):
+ vat_settings = frappe.db.get_value('KSA VAT Setting', {'company': doc.company})
+ vat_accounts = []
+ vat_amount = 0
+
+ if vat_settings:
+ vat_settings_doc = frappe.get_cached_doc('KSA VAT Setting', vat_settings)
+
+ for row in vat_settings_doc.get('ksa_vat_sales_accounts'):
+ vat_accounts.append(row.account)
+
+ for tax in doc.get('taxes'):
+ if tax.account_head in vat_accounts:
+ vat_amount += tax.tax_amount
+
+ return vat_amount
def delete_qr_code_file(doc, method=None):
region = get_region(doc.company)
diff --git a/erpnext/regional/united_arab_emirates/utils.py b/erpnext/regional/united_arab_emirates/utils.py
index f350ec4..bdede84 100644
--- a/erpnext/regional/united_arab_emirates/utils.py
+++ b/erpnext/regional/united_arab_emirates/utils.py
@@ -26,9 +26,12 @@
elif row.item_code and itemised_tax.get(row.item_code):
tax_rate = sum([tax.get('tax_rate', 0) for d, tax in itemised_tax.get(row.item_code).items()])
- row.tax_rate = flt(tax_rate, row.precision("tax_rate"))
- row.tax_amount = flt((row.net_amount * tax_rate) / 100, row.precision("net_amount"))
- row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount"))
+ meta = frappe.get_meta(row.doctype)
+
+ if meta.has_field('tax_rate'):
+ row.tax_rate = flt(tax_rate, row.precision("tax_rate"))
+ row.tax_amount = flt((row.net_amount * tax_rate) / 100, row.precision("net_amount"))
+ row.total_amount = flt((row.net_amount + row.tax_amount), row.precision("total_amount"))
def get_account_currency(account):
"""Helper function to get account currency."""
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index eebde76..06b4ff1 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -165,7 +165,6 @@
if source.referral_sales_partner:
target.sales_partner=source.referral_sales_partner
target.commission_rate=frappe.get_value('Sales Partner', source.referral_sales_partner, 'commission_rate')
- target.ignore_pricing_rule = 1
target.flags.ignore_permissions = ignore_permissions
target.run_method("set_missing_values")
target.run_method("calculate_taxes_and_totals")
@@ -240,7 +239,7 @@
if customer:
target.customer = customer.name
target.customer_name = customer.customer_name
- target.ignore_pricing_rule = 1
+
target.flags.ignore_permissions = ignore_permissions
target.run_method("set_missing_values")
target.run_method("calculate_taxes_and_totals")
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index f80eaf2..87f277f 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -693,12 +693,12 @@
get_ordered_qty(item, so) {
let ordered_qty = item.ordered_qty;
- if (so.packed_items) {
+ if (so.packed_items && so.packed_items.length) {
// calculate ordered qty based on packed items in case of product bundle
let packed_items = so.packed_items.filter(
(pi) => pi.parent_detail_docname == item.name
);
- if (packed_items) {
+ if (packed_items && packed_items.length) {
ordered_qty = packed_items.reduce(
(sum, pi) => sum + flt(pi.ordered_qty),
0
diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json
index 7e99a06..fe2f14e 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.json
+++ b/erpnext/selling/doctype/sales_order/sales_order.json
@@ -130,6 +130,7 @@
"per_delivered",
"column_break_81",
"per_billed",
+ "per_picked",
"billing_status",
"sales_team_section_break",
"sales_partner",
@@ -1514,13 +1515,19 @@
"fieldtype": "Currency",
"label": "Amount Eligible for Commission",
"read_only": 1
+ },
+ {
+ "fieldname": "per_picked",
+ "fieldtype": "Percent",
+ "label": "% Picked",
+ "read_only": 1
}
],
"icon": "fa fa-file-text",
"idx": 105,
"is_submittable": 1,
"links": [],
- "modified": "2021-10-05 12:16:40.775704",
+ "modified": "2022-03-15 21:38:31.437586",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order",
@@ -1594,6 +1601,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "customer",
"title_field": "customer_name",
"track_changes": 1,
diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py
index abbb3c9..73e3d19 100755
--- a/erpnext/selling/doctype/sales_order/sales_order.py
+++ b/erpnext/selling/doctype/sales_order/sales_order.py
@@ -520,7 +520,6 @@
@frappe.whitelist()
def make_delivery_note(source_name, target_doc=None, skip_item_mapping=False):
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.run_method("set_missing_values")
target.run_method("set_po_nos")
target.run_method("calculate_taxes_and_totals")
@@ -596,7 +595,6 @@
target.set_advances()
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.flags.ignore_permissions = True
target.run_method("set_missing_values")
target.run_method("set_po_nos")
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 7e55499..195e964 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -23,6 +23,7 @@
"quantity_and_rate",
"qty",
"stock_uom",
+ "picked_qty",
"col_break2",
"uom",
"conversion_factor",
@@ -798,12 +799,17 @@
"fieldtype": "Check",
"label": "Grant Commission",
"read_only": 1
+ },
+ {
+ "fieldname": "picked_qty",
+ "fieldtype": "Float",
+ "label": "Picked Qty"
}
],
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-24 14:41:57.325799",
+ "modified": "2022-03-15 20:17:33.984799",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py
index 5c7194b..2c53246 100644
--- a/erpnext/setup/doctype/item_group/item_group.py
+++ b/erpnext/setup/doctype/item_group/item_group.py
@@ -132,7 +132,8 @@
return frappe.get_all(
"Item Group",
filters=filters,
- fields=["name", "route"]
+ fields=["name", "route"],
+ order_by="name"
)
def get_child_item_groups(item_group_name):
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index 314e4ba..ffa2f93 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -14,7 +14,6 @@
from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
-from erpnext.stock.utils import calculate_mapped_packed_items_return
form_grid_templates = {
"items": "templates/form_grid/item_grid.html"
@@ -129,12 +128,8 @@
self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc()
- # Keeps mapped packed_items in case product bundle is updated.
- if self.is_return and self.return_against:
- calculate_mapped_packed_items_return(self)
- else:
- from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
- make_packing_list(self)
+ from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
+ make_packing_list(self)
if self._action != 'submit' and not self.is_return:
set_batch_nos(self, 'warehouse', throw=True)
@@ -439,7 +434,6 @@
invoiced_qty_map = get_invoiced_qty_map(source_name)
def set_missing_values(source, target):
- target.ignore_pricing_rule = 1
target.run_method("set_missing_values")
target.run_method("set_po_nos")
diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
index 16c8921..82f4e7d 100644
--- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py
@@ -386,7 +386,8 @@
self.assertEqual(actual_qty, 25)
# return bundled item
- dn1 = create_return_delivery_note(source_name=dn.name, rate=500, qty=-2)
+ dn1 = create_delivery_note(item_code='_Test Product Bundle Item', is_return=1,
+ return_against=dn.name, qty=-2, rate=500, company=company, warehouse="Stores - TCP1", expense_account="Cost of Goods Sold - TCP1", cost_center="Main - TCP1")
# qty after return
actual_qty = get_qty_after_transaction(warehouse="Stores - TCP1")
@@ -822,15 +823,6 @@
automatically_fetch_payment_terms(enable=0)
-def create_return_delivery_note(**args):
- args = frappe._dict(args)
- from erpnext.controllers.sales_and_purchase_return import make_return_doc
- doc = make_return_doc("Delivery Note", args.source_name, None)
- doc.items[0].rate = args.rate
- doc.items[0].qty = args.qty
- doc.submit()
- return doc
-
def create_delivery_note(**args):
dn = frappe.new_doc("Delivery Note")
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index d57308b..669cabc 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -30,7 +30,10 @@
test_ignore = ["BOM"]
test_dependencies = ["Warehouse", "Item Group", "Item Tax Template", "Brand", "Item Attribute"]
-def make_item(item_code, properties=None):
+def make_item(item_code=None, properties=None):
+ if not item_code:
+ item_code = frappe.generate_hash(length=16)
+
if frappe.db.exists("Item", item_code):
return frappe.get_doc("Item", item_code)
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index d6e2e9c..e94c34d 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -223,6 +223,7 @@
"fieldtype": "Currency",
"in_list_view": 1,
"label": "Rate",
+ "options": "currency",
"print_hide": 1,
"read_only": 1
},
@@ -239,7 +240,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-02-22 12:57:45.325488",
+ "modified": "2022-03-10 15:42:00.265915",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index 07c2f1f..f9c00c5 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -185,7 +185,8 @@
row_data.update({
"company": doc.get("company"),
"price_list": doc.get("selling_price_list"),
- "currency": doc.get("currency")
+ "currency": doc.get("currency"),
+ "conversion_rate": doc.get("conversion_rate"),
})
rate = get_price_list_rate(row_data, item_doc).get("price_list_rate")
diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
index 94268a8..5f1b954 100644
--- a/erpnext/stock/doctype/packed_item/test_packed_item.py
+++ b/erpnext/stock/doctype/packed_item/test_packed_item.py
@@ -17,15 +17,25 @@
@classmethod
def setUpClass(cls) -> None:
super().setUpClass()
+ cls.warehouse = "_Test Warehouse - _TC"
cls.bundle = "_Test Product Bundle X"
cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
+
+ cls.bundle2 = "_Test Product Bundle Y"
+ cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"]
+
make_item(cls.bundle, {"is_stock_item": 0})
- for item in cls.bundle_items:
+ make_item(cls.bundle2, {"is_stock_item": 0})
+ for item in cls.bundle_items + cls.bundle2_items:
make_item(item, {"is_stock_item": 1})
make_item("_Test Normal Stock Item", {"is_stock_item": 1})
make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
+ make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2)
+
+ for item in cls.bundle_items + cls.bundle2_items:
+ make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100)
def test_adding_bundle_item(self):
"Test impact on packed items if bundle item row is added."
@@ -156,3 +166,104 @@
credit_after_reposting = sum(gle.credit for gle in gles)
self.assertNotEqual(credit_before_repost, credit_after_reposting)
self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)
+
+ def assertReturns(self, original, returned):
+ self.assertEqual(len(original), len(returned))
+
+ sort_function = lambda p: (p.parent_item, p.item_code, p.qty)
+
+ for sent, returned in zip(
+ sorted(original, key=sort_function),
+ sorted(returned, key=sort_function)
+ ):
+ self.assertEqual(sent.item_code, returned.item_code)
+ self.assertEqual(sent.parent_item, returned.parent_item)
+ self.assertEqual(sent.qty, -1 * returned.qty)
+
+ def test_returning_full_bundles(self):
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+
+ item_list = [
+ {
+ "item_code": self.bundle,
+ "warehouse": self.warehouse,
+ "qty": 1,
+ "rate": 100,
+ },
+ {
+ "item_code": self.bundle2,
+ "warehouse": self.warehouse,
+ "qty": 1,
+ "rate": 100,
+ }
+ ]
+ so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
+
+ dn = make_delivery_note(so.name)
+ dn.save()
+ dn.submit()
+
+ # create return
+ dn_ret = make_sales_return(dn.name)
+ dn_ret.save()
+ dn_ret.submit()
+ self.assertReturns(dn.packed_items, dn_ret.packed_items)
+
+ def test_returning_partial_bundles(self):
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+
+ item_list = [
+ {
+ "item_code": self.bundle,
+ "warehouse": self.warehouse,
+ "qty": 1,
+ "rate": 100,
+ },
+ {
+ "item_code": self.bundle2,
+ "warehouse": self.warehouse,
+ "qty": 1,
+ "rate": 100,
+ }
+ ]
+ so = make_sales_order(item_list=item_list, warehouse=self.warehouse)
+
+ dn = make_delivery_note(so.name)
+ dn.save()
+ dn.submit()
+
+ # create return
+ dn_ret = make_sales_return(dn.name)
+ # remove bundle 2
+ dn_ret.items.pop()
+
+ dn_ret.save()
+ dn_ret.submit()
+ dn_ret.reload()
+
+ self.assertTrue(all(d.parent_item == self.bundle for d in dn_ret.packed_items))
+
+ expected_returns = [d for d in dn.packed_items if d.parent_item == self.bundle]
+ self.assertReturns(expected_returns, dn_ret.packed_items)
+
+
+ def test_returning_partial_bundle_qty(self):
+ from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
+
+ so = make_sales_order(item_code=self.bundle, warehouse=self.warehouse, qty = 2)
+
+ dn = make_delivery_note(so.name)
+ dn.save()
+ dn.submit()
+
+ # create return
+ dn_ret = make_sales_return(dn.name)
+ # halve the qty
+ dn_ret.items[0].qty = -1
+ dn_ret.save()
+ dn_ret.submit()
+
+ expected_returns = dn.packed_items
+ for d in expected_returns:
+ d.qty /= 2
+ self.assertReturns(expected_returns, dn_ret.packed_items)
diff --git a/erpnext/stock/doctype/pick_list/pick_list.js b/erpnext/stock/doctype/pick_list/pick_list.js
index 730fd7a..13b74b5 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.js
+++ b/erpnext/stock/doctype/pick_list/pick_list.js
@@ -146,10 +146,6 @@
customer: frm.doc.customer
};
frm.get_items_btn = frm.add_custom_button(__('Get Items'), () => {
- if (!frm.doc.customer) {
- frappe.msgprint(__('Please select Customer first'));
- return;
- }
erpnext.utils.map_current_doc({
method: 'erpnext.selling.doctype.sales_order.sales_order.create_pick_list',
source_doctype: 'Sales Order',
diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py
index b2eaecb..3a49686 100644
--- a/erpnext/stock/doctype/pick_list/pick_list.py
+++ b/erpnext/stock/doctype/pick_list/pick_list.py
@@ -3,6 +3,8 @@
import json
from collections import OrderedDict, defaultdict
+from itertools import groupby
+from operator import itemgetter
import frappe
from frappe import _
@@ -24,8 +26,21 @@
def before_save(self):
self.set_item_locations()
+ # set percentage picked in SO
+ for location in self.get('locations'):
+ if location.sales_order and frappe.db.get_value("Sales Order",location.sales_order,"per_picked") == 100:
+ frappe.throw("Row " + str(location.idx) + " has been picked already!")
+
def before_submit(self):
for item in self.locations:
+ # if the user has not entered any picked qty, set it to stock_qty, before submit
+ if item.picked_qty == 0:
+ item.picked_qty = item.stock_qty
+
+ if item.sales_order_item:
+ # update the picked_qty in SO Item
+ self.update_so(item.sales_order_item,item.picked_qty,item.item_code)
+
if not frappe.get_cached_value('Item', item.item_code, 'has_serial_no'):
continue
if not item.serial_no:
@@ -37,6 +52,32 @@
frappe.throw(_('For item {0} at row {1}, count of serial numbers does not match with the picked quantity')
.format(frappe.bold(item.item_code), frappe.bold(item.idx)), title=_("Quantity Mismatch"))
+ def before_cancel(self):
+ #update picked_qty in SO Item on cancel of PL
+ for location in self.get('locations'):
+ if location.sales_order_item:
+ self.update_so(location.sales_order_item,0,location.item_code)
+
+ def update_so(self,so_item,picked_qty,item_code):
+ so_doc = frappe.get_doc("Sales Order",frappe.db.get_value("Sales Order Item",so_item,"parent"))
+ already_picked,actual_qty = frappe.db.get_value("Sales Order Item",so_item,["picked_qty","qty"])
+
+ if self.docstatus == 1:
+ if (((already_picked + picked_qty)/ actual_qty)*100) > (100 + flt(frappe.db.get_single_value('Stock Settings', 'over_delivery_receipt_allowance'))):
+ frappe.throw('You are picking more than required quantity for ' + item_code + '. Check if there is any other pick list created for '+so_doc.name)
+
+ frappe.db.set_value("Sales Order Item",so_item,"picked_qty",already_picked+picked_qty)
+
+ total_picked_qty = 0
+ total_so_qty = 0
+ for item in so_doc.get('items'):
+ total_picked_qty += flt(item.picked_qty)
+ total_so_qty += flt(item.stock_qty)
+ total_picked_qty=total_picked_qty + picked_qty
+ per_picked = total_picked_qty/total_so_qty * 100
+
+ so_doc.db_set("per_picked", flt(per_picked) ,update_modified=False)
+
@frappe.whitelist()
def set_item_locations(self, save=False):
self.validate_for_qty()
@@ -64,10 +105,6 @@
item_doc.name = None
for row in locations:
- row.update({
- 'picked_qty': row.stock_qty
- })
-
location = item_doc.as_dict()
location.update(row)
self.append('locations', location)
@@ -340,63 +377,102 @@
def create_delivery_note(source_name, target_doc=None):
pick_list = frappe.get_doc('Pick List', source_name)
validate_item_locations(pick_list)
-
- sales_orders = [d.sales_order for d in pick_list.locations if d.sales_order]
- sales_orders = set(sales_orders)
-
+ sales_dict = dict()
+ sales_orders = []
delivery_note = None
- for sales_order in sales_orders:
- delivery_note = create_delivery_note_from_sales_order(sales_order,
- delivery_note, skip_item_mapping=True)
+ for location in pick_list.locations:
+ if location.sales_order:
+ sales_orders.append([frappe.db.get_value("Sales Order",location.sales_order,'customer'),location.sales_order])
+ # Group sales orders by customer
+ for key,keydata in groupby(sales_orders,key=itemgetter(0)):
+ sales_dict[key] = set([d[1] for d in keydata])
- # map rows without sales orders as well
- if not delivery_note:
+ if sales_dict:
+ delivery_note = create_dn_with_so(sales_dict,pick_list)
+
+ is_item_wo_so = 0
+ for location in pick_list.locations :
+ if not location.sales_order:
+ is_item_wo_so = 1
+ break
+ if is_item_wo_so == 1:
+ # Create a DN for items without sales orders as well
+ delivery_note = create_dn_wo_so(pick_list)
+
+ frappe.msgprint(_('Delivery Note(s) created for the Pick List'))
+ return delivery_note
+
+def create_dn_wo_so(pick_list):
delivery_note = frappe.new_doc("Delivery Note")
- item_table_mapper = {
- 'doctype': 'Delivery Note Item',
- 'field_map': {
- 'rate': 'rate',
- 'name': 'so_detail',
- 'parent': 'against_sales_order',
- },
- 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
- }
-
- item_table_mapper_without_so = {
- 'doctype': 'Delivery Note Item',
- 'field_map': {
- 'rate': 'rate',
- 'name': 'name',
- 'parent': '',
+ item_table_mapper_without_so = {
+ 'doctype': 'Delivery Note Item',
+ 'field_map': {
+ 'rate': 'rate',
+ 'name': 'name',
+ 'parent': '',
+ }
}
- }
+ map_pl_locations(pick_list,item_table_mapper_without_so,delivery_note)
+ delivery_note.insert(ignore_mandatory = True)
+
+ return delivery_note
+
+
+def create_dn_with_so(sales_dict,pick_list):
+ delivery_note = None
+
+ for customer in sales_dict:
+ for so in sales_dict[customer]:
+ delivery_note = None
+ delivery_note = create_delivery_note_from_sales_order(so,
+ delivery_note, skip_item_mapping=True)
+
+ item_table_mapper = {
+ 'doctype': 'Delivery Note Item',
+ 'field_map': {
+ 'rate': 'rate',
+ 'name': 'so_detail',
+ 'parent': 'against_sales_order',
+ },
+ 'condition': lambda doc: abs(doc.delivered_qty) < abs(doc.qty) and doc.delivered_by_supplier!=1
+ }
+ break
+ if delivery_note:
+ # map all items of all sales orders of that customer
+ for so in sales_dict[customer]:
+ map_pl_locations(pick_list,item_table_mapper,delivery_note,so)
+ delivery_note.insert(ignore_mandatory = True)
+
+ return delivery_note
+
+def map_pl_locations(pick_list,item_mapper,delivery_note,sales_order = None):
for location in pick_list.locations:
- if location.sales_order_item:
- sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item})
- else:
- sales_order_item = None
+ if location.sales_order == sales_order:
+ if location.sales_order_item:
+ sales_order_item = frappe.get_cached_doc('Sales Order Item', {'name':location.sales_order_item})
+ else:
+ sales_order_item = None
- source_doc, table_mapper = [sales_order_item, item_table_mapper] if sales_order_item \
- else [location, item_table_mapper_without_so]
+ source_doc, table_mapper = [sales_order_item, item_mapper] if sales_order_item \
+ else [location, item_mapper]
- dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
+ dn_item = map_child_doc(source_doc, delivery_note, table_mapper)
- if dn_item:
- dn_item.warehouse = location.warehouse
- dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
- dn_item.batch_no = location.batch_no
- dn_item.serial_no = location.serial_no
+ if dn_item:
+ dn_item.warehouse = location.warehouse
+ dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1)
+ dn_item.batch_no = location.batch_no
+ dn_item.serial_no = location.serial_no
- update_delivery_note_item(source_doc, dn_item, delivery_note)
-
+ update_delivery_note_item(source_doc, dn_item, delivery_note)
set_delivery_note_missing_values(delivery_note)
delivery_note.pick_list = pick_list.name
- delivery_note.customer = pick_list.customer if pick_list.customer else None
+ delivery_note.company = pick_list.company
+ delivery_note.customer = frappe.get_value("Sales Order",sales_order,"customer")
- return delivery_note
@frappe.whitelist()
def create_stock_entry(pick_list):
@@ -561,4 +637,4 @@
item.material_request = location.material_request
item.serial_no = location.serial_no
item.batch_no = location.batch_no
- item.material_request_item = location.material_request_item
+ item.material_request_item = location.material_request_item
\ No newline at end of file
diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py
index f3b6b89..f60104c 100644
--- a/erpnext/stock/doctype/pick_list/test_pick_list.py
+++ b/erpnext/stock/doctype/pick_list/test_pick_list.py
@@ -17,7 +17,6 @@
class TestPickList(FrappeTestCase):
-
def test_pick_list_picks_warehouse_for_each_item(self):
try:
frappe.get_doc({
@@ -188,7 +187,6 @@
}]
})
pick_list.set_item_locations()
-
self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no)
pr1.cancel()
@@ -311,6 +309,7 @@
'item_code': '_Test Item',
'qty': 1,
'conversion_factor': 5,
+ 'stock_qty':5,
'delivery_date': frappe.utils.today()
}, {
'item_code': '_Test Item',
@@ -329,9 +328,9 @@
'purpose': 'Delivery',
'locations': [{
'item_code': '_Test Item',
- 'qty': 1,
- 'stock_qty': 5,
- 'conversion_factor': 5,
+ 'qty': 2,
+ 'stock_qty': 1,
+ 'conversion_factor': 0.5,
'sales_order': sales_order.name,
'sales_order_item': sales_order.items[0].name ,
}, {
@@ -389,6 +388,95 @@
for expected_item, created_item in zip(expected_items, pl.locations):
_compare_dicts(expected_item, created_item)
+ def test_multiple_dn_creation(self):
+ sales_order_1 = frappe.get_doc({
+ 'doctype': 'Sales Order',
+ 'customer': '_Test Customer',
+ 'company': '_Test Company',
+ 'items': [{
+ 'item_code': '_Test Item',
+ 'qty': 1,
+ 'conversion_factor': 1,
+ 'delivery_date': frappe.utils.today()
+ }],
+ }).insert()
+ sales_order_1.submit()
+ sales_order_2 = frappe.get_doc({
+ 'doctype': 'Sales Order',
+ 'customer': '_Test Customer 1',
+ 'company': '_Test Company',
+ 'items': [{
+ 'item_code': '_Test Item 2',
+ 'qty': 1,
+ 'conversion_factor': 1,
+ 'delivery_date': frappe.utils.today()
+ },
+ ],
+ }).insert()
+ sales_order_2.submit()
+ pick_list = frappe.get_doc({
+ 'doctype': 'Pick List',
+ 'company': '_Test Company',
+ 'items_based_on': 'Sales Order',
+ 'purpose': 'Delivery',
+ 'picker':'P001',
+ 'locations': [{
+ 'item_code': '_Test Item ',
+ 'qty': 1,
+ 'stock_qty': 1,
+ 'conversion_factor': 1,
+ 'sales_order': sales_order_1.name,
+ 'sales_order_item': sales_order_1.items[0].name ,
+ }, {
+ 'item_code': '_Test Item 2',
+ 'qty': 1,
+ 'stock_qty': 1,
+ 'conversion_factor': 1,
+ 'sales_order': sales_order_2.name,
+ 'sales_order_item': sales_order_2.items[0].name ,
+ }
+ ]
+ })
+ pick_list.set_item_locations()
+ pick_list.submit()
+ create_delivery_note(pick_list.name)
+ for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer"},fields={"name"}):
+ for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+ self.assertEqual(dn_item.item_code, '_Test Item')
+ self.assertEqual(dn_item.against_sales_order,sales_order_1.name)
+ for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list.name,"customer":"_Test Customer 1"},fields={"name"}):
+ for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+ self.assertEqual(dn_item.item_code, '_Test Item 2')
+ self.assertEqual(dn_item.against_sales_order,sales_order_2.name)
+ #test DN creation without so
+ pick_list_1 = frappe.get_doc({
+ 'doctype': 'Pick List',
+ 'company': '_Test Company',
+ 'purpose': 'Delivery',
+ 'picker':'P001',
+ 'locations': [{
+ 'item_code': '_Test Item ',
+ 'qty': 1,
+ 'stock_qty': 1,
+ 'conversion_factor': 1,
+ }, {
+ 'item_code': '_Test Item 2',
+ 'qty': 2,
+ 'stock_qty': 2,
+ 'conversion_factor': 1,
+ }
+ ]
+ })
+ pick_list_1.set_item_locations()
+ pick_list_1.submit()
+ create_delivery_note(pick_list_1.name)
+ for dn in frappe.get_all("Delivery Note",filters={"pick_list":pick_list_1.name},fields={"name"}):
+ for dn_item in frappe.get_doc("Delivery Note",dn.name).get("items"):
+ if dn_item.item_code == '_Test Item':
+ self.assertEqual(dn_item.qty,1)
+ if dn_item.item_code == '_Test Item 2':
+ self.assertEqual(dn_item.qty,2)
+
# def test_pick_list_skips_items_in_expired_batch(self):
# pass
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 52f10ea..5bb337e 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -762,7 +762,6 @@
frappe.throw(_("All items have already been Invoiced/Returned"))
doc = frappe.get_doc(target)
- doc.ignore_pricing_rule = 1
doc.payment_terms_template = get_payment_terms_template(source.supplier, "Supplier", source.company)
doc.run_method("onload")
doc.run_method("set_missing_values")
diff --git a/erpnext/stock/doctype/quality_inspection_template/test_records.json b/erpnext/stock/doctype/quality_inspection_template/test_records.json
new file mode 100644
index 0000000..980f49a
--- /dev/null
+++ b/erpnext/stock/doctype/quality_inspection_template/test_records.json
@@ -0,0 +1,13 @@
+[
+ {
+ "quality_inspection_template_name" : "_Test Quality Inspection Template",
+ "doctype": "Quality Inspection Template",
+ "item_quality_inspection_parameter" : [
+ {
+ "specification": "_Test Param",
+ "doctype": "Item Quality Inspection Parameter",
+ "parentfield": "item_quality_inspection_parameter"
+ }
+ ]
+ }
+]
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index f4d52ad..f8ec784 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -45,11 +45,21 @@
self.db_set('status', self.status)
def on_submit(self):
- if not frappe.flags.in_test or self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts:
+ """During tests reposts are executed immediately.
+
+ Exceptions:
+ 1. "Repost Item Valuation" document has self.flags.dont_run_in_test
+ 2. global flag frappe.flags.dont_execute_stock_reposts is set
+
+ These flags are useful for asserting real time behaviour like quantity updates.
+ """
+
+ if not frappe.flags.in_test:
+ return
+ if self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts:
return
- frappe.enqueue(repost, timeout=1800, queue='long',
- job_name='repost_sle', now=frappe.flags.in_test, doc=self)
+ repost(self)
@frappe.whitelist()
def restart_reposting(self):
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index ee08e38..bf62f50 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -394,7 +394,7 @@
if not sle.is_cancelled and not sle.serial_no and cint(sle.actual_qty) > 0 \
and item_det.has_serial_no == 1 and item_det.serial_no_series:
serial_nos = get_auto_serial_nos(item_det.serial_no_series, sle.actual_qty)
- frappe.db.set(sle, "serial_no", serial_nos)
+ sle.db_set("serial_no", serial_nos)
validate_serial_no(sle, item_det)
if sle.serial_no:
auto_make_serial_nos(sle)
@@ -516,13 +516,16 @@
if controller.doctype == "Stock Entry":
warehouse = d.t_warehouse
qty = d.transfer_qty
+ elif controller.doctype in ("Sales Invoice", "Delivery Note"):
+ warehouse = d.warehouse
+ qty = d.stock_qty
else:
warehouse = d.warehouse
qty = (d.qty if controller.doctype == "Stock Reconciliation"
else d.stock_qty)
for sle in stock_ledger_entries:
if sle.voucher_detail_no==d.name:
- if not accepted_serial_nos_updated and qty and abs(sle.actual_qty)==qty \
+ if not accepted_serial_nos_updated and qty and abs(sle.actual_qty) == abs(qty) \
and sle.warehouse == warehouse and sle.serial_no != d.serial_no:
d.serial_no = sle.serial_no
frappe.db.set_value(d.doctype, d.name, "serial_no", sle.serial_no)
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index 684a8d4..2352235 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -10,10 +10,7 @@
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, today
-from erpnext.stock.doctype.delivery_note.test_delivery_note import (
- create_delivery_note,
- create_return_delivery_note,
-)
+from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher,
@@ -239,7 +236,8 @@
self.assertEqual(outgoing_rate, 100)
# Return Entry: Qty = -2, Rate = 150
- return_dn = create_return_delivery_note(source_name=dn.name, rate=150, qty=-2)
+ return_dn = create_delivery_note(is_return=1, return_against=dn.name, item_code=bundled_item, qty=-2, rate=150,
+ company=company, warehouse="Stores - _TC", expense_account="Cost of Goods Sold - _TC", cost_center="Main - _TC")
# check incoming rate for Return entry
incoming_rate, stock_value_difference = frappe.db.get_value("Stock Ledger Entry",
@@ -739,6 +737,30 @@
{"incoming_rate": sum(rates) * 10}
], sle_filters={"item_code": packed.name})
+ def test_negative_fifo_valuation(self):
+ """
+ When stock goes negative discard FIFO queue.
+ Only pervailing valuation rate should be used for making transactions in such cases.
+ """
+ item = make_item(properties={"allow_negative_stock": 1}).name
+ warehouse = "_Test Warehouse - _TC"
+
+ receipt = make_stock_entry(item_code=item, target=warehouse, qty=10, rate=10)
+ consume1 = make_stock_entry(item_code=item, source=warehouse, qty=15)
+
+ self.assertSLEs(consume1, [
+ {"stock_value": -5 * 10, "stock_queue": [[-5, 10]]}
+ ])
+
+ consume2 = make_stock_entry(item_code=item, source=warehouse, qty=5)
+ self.assertSLEs(consume2, [
+ {"stock_value": -10 * 10, "stock_queue": [[-10, 10]]}
+ ])
+
+ receipt2 = make_stock_entry(item_code=item, target=warehouse, qty=15, rate=15)
+ self.assertSLEs(receipt2, [
+ {"stock_queue": [[5, 15]], "stock_value_difference": 175}
+ ])
def create_repack_entry(**args):
args = frappe._dict(args)
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.js b/erpnext/stock/doctype/stock_settings/stock_settings.js
index cc0e2cf..89ac4b5 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.js
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.js
@@ -13,5 +13,24 @@
frm.set_query("default_warehouse", filters);
frm.set_query("sample_retention_warehouse", filters);
+ },
+ allow_negative_stock: function(frm) {
+ if (!frm.doc.allow_negative_stock) {
+ return;
+ }
+
+ let msg = __("Using negative stock disables FIFO/Moving average valuation when inventory is negative.");
+ msg += " ";
+ msg += __("This is considered dangerous from accounting point of view.")
+ msg += "<br>";
+ msg += ("Do you still want to enable negative inventory?");
+
+ frappe.confirm(
+ msg,
+ () => {},
+ () => {
+ frm.set_value("allow_negative_stock", 0);
+ }
+ );
}
});
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index 81fa045..9fde47e 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -7,6 +7,7 @@
from frappe.utils import cint, flt
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import get_stock_balance_for
from erpnext.stock.utils import (
is_reposting_item_valuation_in_progress,
update_included_uom_in_report,
@@ -70,7 +71,10 @@
serial_nos = get_serial_nos(sle.serial_no)
key = (sle.item_code, sle.warehouse)
if key not in available_serial_nos:
- available_serial_nos.setdefault(key, [])
+ stock_balance = get_stock_balance_for(sle.item_code, sle.warehouse, sle.date.split(' ')[0],
+ sle.date.split(' ')[1])
+ serials = get_serial_nos(stock_balance['serial_nos']) if stock_balance['serial_nos'] else []
+ available_serial_nos.setdefault(key, serials)
existing_serial_no = available_serial_nos[key]
for sn in serial_nos:
diff --git a/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py
new file mode 100644
index 0000000..163b205
--- /dev/null
+++ b/erpnext/stock/report/stock_ledger/test_stock_ledger_report.py
@@ -0,0 +1,41 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+from frappe.tests.utils import FrappeTestCase
+from frappe.utils import add_days, today
+
+from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
+ make_serial_item_with_serial,
+)
+from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+from erpnext.stock.report.stock_ledger.stock_ledger import execute
+
+
+class TestStockLedgerReeport(FrappeTestCase):
+ def setUp(self) -> None:
+ make_serial_item_with_serial("_Test Stock Report Serial Item")
+ self.filters = frappe._dict(
+ company="_Test Company", from_date=today(), to_date=add_days(today(), 30),
+ item_code="_Test Stock Report Serial Item"
+ )
+
+ def tearDown(self) -> None:
+ frappe.db.rollback()
+
+ def test_serial_balance(self):
+ item_code = "_Test Stock Report Serial Item"
+ # Checks serials which were added through stock in entry.
+ columns, data = execute(self.filters)
+ self.assertEqual(data[0].in_qty, 2)
+ serials_added = get_serial_nos(data[0].serial_no)
+ self.assertEqual(len(serials_added), 2)
+ # Stock out entry for one of the serials.
+ dn = create_delivery_note(item=item_code, serial_no=serials_added[1])
+ self.filters.voucher_no = dn.name
+ columns, data = execute(self.filters)
+ self.assertEqual(data[0].out_qty, -1)
+ self.assertEqual(data[0].serial_no, serials_added[1])
+ self.assertEqual(data[0].balance_serial_no, serials_added[0])
+
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 353bfa4..1aaaad2 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -1040,7 +1040,6 @@
if not allow_zero_rate and not valuation_rate and raise_error_if_no_rate \
and cint(erpnext.is_perpetual_inventory_enabled(company)):
- frappe.local.message_log = []
form_link = get_link_to_form("Item", item_code)
message = _("Valuation Rate for the Item {0}, is required to do accounting entries for {1} {2}.").format(form_link, voucher_type, voucher_no)
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index f85a04f..e205389 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -422,19 +422,6 @@
if reposting_in_progress:
frappe.msgprint(_("Item valuation reposting in progress. Report might show incorrect item valuation."), alert=1)
-
-def calculate_mapped_packed_items_return(return_doc):
- parent_items = set([item.parent_item for item in return_doc.packed_items])
- against_doc = frappe.get_doc(return_doc.doctype, return_doc.return_against)
-
- for original_bundle, returned_bundle in zip(against_doc.items, return_doc.items):
- if original_bundle.item_code in parent_items:
- for returned_packed_item, original_packed_item in zip(return_doc.packed_items, against_doc.packed_items):
- if returned_packed_item.parent_item == original_bundle.item_code:
- returned_packed_item.parent_detail_docname = returned_bundle.name
- returned_packed_item.qty = (original_packed_item.qty / original_bundle.qty) * returned_bundle.qty
-
-
def check_pending_reposting(posting_date: str, throw_error: bool = True) -> bool:
"""Check if there are pending reposting job till the specified posting date."""
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index b882b9d..1a24f2b 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -987,15 +987,15 @@
Expected End Date,Voraussichtliches Enddatum,
Expected Hrs,Erwartete Stunden,
Expected Start Date,Voraussichtliches Startdatum,
-Expense,Auslage,
+Expense,Aufwand,
Expense / Difference account ({0}) must be a 'Profit or Loss' account,"Aufwands-/Differenz-Konto ({0}) muss ein ""Gewinn oder Verlust""-Konto sein",
Expense Account,Aufwandskonto,
-Expense Claim,Aufwandsabrechnung,
+Expense Claim,Auslagenabrechnung,
Expense Claim for Vehicle Log {0},Auslagenabrechnung für Fahrtenbuch {0},
Expense Claim {0} already exists for the Vehicle Log,Auslagenabrechnung {0} existiert bereits für das Fahrzeug Log,
Expense Claims,Aufwandsabrechnungen,
Expense account is mandatory for item {0},Aufwandskonto ist zwingend für Artikel {0},
-Expenses,Ausgaben,
+Expenses,Aufwendungen,
Expenses Included In Asset Valuation,"Aufwendungen, die in der Vermögensbewertung enthalten sind",
Expenses Included In Valuation,In der Bewertung enthaltene Aufwendungen,
Expired Batches,Abgelaufene Chargen,
@@ -1541,7 +1541,7 @@
Marketing Expenses,Marketingkosten,
Marketplace,Marktplatz,
Marketplace Error,Marktplatzfehler,
-Masters,Stämme,
+Masters,Stammdaten,
Match Payments with Invoices,Zahlungen und Rechnungen abgleichen,
Match non-linked Invoices and Payments.,Nicht verknüpfte Rechnungen und Zahlungen verknüpfen,
Material,Material,
@@ -1621,7 +1621,7 @@
More than one selection for {0} not allowed,Mehr als eine Auswahl für {0} ist nicht zulässig,
More...,Mehr...,
Motion Picture & Video,Film & Fernsehen,
-Move,Bewegen,
+Move,Verschieben,
Move Item,Element verschieben,
Multi Currency,Unterschiedliche Währungen,
Multiple Item prices.,Mehrere verschiedene Artikelpreise,
@@ -1943,7 +1943,7 @@
Pharmaceuticals,Pharmaprodukte,
Physician,Arzt,
Piecework,Akkordarbeit,
-Pincode,Postleitzahl (PLZ),
+Pincode,Postleitzahl,
Place Of Supply (State/UT),Ort der Lieferung (Staat / UT),
Place Order,Bestellung aufgeben,
Plan Name,Planname,
@@ -2539,7 +2539,7 @@
Sales Partner,Vertriebspartner,
Sales Pipeline,Vertriebspipeline,
Sales Price List,Verkaufspreisliste,
-Sales Return,Rücklieferung,
+Sales Return,Retoure,
Sales Summary,Verkaufszusammenfassung,
Sales Tax Template,Umsatzsteuer-Vorlage,
Sales Team,Verkaufsteam,
@@ -9845,3 +9845,8 @@
Creating Purchase Order ...,Bestellung anlegen ...,
"Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Wählen Sie einen Lieferanten aus den Standardlieferanten der folgenden Artikel aus. Bei der Auswahl erfolgt eine Bestellung nur für Artikel, die dem ausgewählten Lieferanten gehören.",
Row #{}: You must select {} serial numbers for item {}.,Zeile # {}: Sie müssen {} Seriennummern für Artikel {} auswählen.,
+{} To Deliver,{} Zu liefern,
+{} To Receive,{} Zu erhalten,
+{} Available,{} Verfügbar,
+Report an Issue,Ein Problem melden,
+User Forum,Anwenderforum,
diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js
index 5553e44..b8203f4 100644
--- a/erpnext/utilities/doctype/rename_tool/rename_tool.js
+++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js
@@ -30,7 +30,16 @@
select_doctype: frm.doc.select_doctype
},
callback: function(r) {
- frm.get_field("rename_log").$wrapper.html(r.message.join("<br>"));
+ let html = r.message.join("<br>");
+
+ if (r.exc) {
+ r.exc = frappe.utils.parse_json(r.exc);
+ if (Array.isArray(r.exc)) {
+ html += "<br>" + r.exc.join("<br>");
+ }
+ }
+
+ frm.get_field("rename_log").$wrapper.html(html);
}
});
});