Merge branch 'develop' into fix-invoice-statuses
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index a6e16a0..8f93811 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yaml
@@ -25,20 +25,6 @@
required: true
- type: dropdown
- id: version
- attributes:
- label: Version
- description: Affected versions.
- multiple: true
- options:
- - v12
- - v13
- - v14
- - develop
- validations:
- required: true
-
- - type: dropdown
id: module
attributes:
label: Module
@@ -86,7 +72,7 @@
- manual install
- FrappeCloud
validations:
- required: true
+ required: false
- type: textarea
id: logs
@@ -95,12 +81,7 @@
description: Please copy and paste any relevant log output. This will be automatically formatted.
render: shell
-
- - type: checkboxes
- id: terms
+ - type: markdown
attributes:
- label: Code of Conduct
- description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md)
- options:
- - label: I agree to follow this project's Code of Conduct
- required: true
+ value: |
+ By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md)
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 e7371fb..4211bd0 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -218,6 +218,8 @@
# updated clear date of all the vouchers based on the bank transaction
vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
+ company_account = frappe.db.get_value('Bank Account', transaction.bank_account, 'account')
+
if transaction.unallocated_amount == 0:
frappe.throw(_("This bank transaction is already fully reconciled"))
total_amount = 0
@@ -226,7 +228,7 @@
total_amount += get_paid_amount(frappe._dict({
'payment_document': voucher['payment_doctype'],
'payment_entry': voucher['payment_name'],
- }), transaction.currency)
+ }), 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"))
@@ -261,7 +263,7 @@
return matching
def check_matching(bank_account, company, transaction, document_types):
- # combine all types of vocuhers
+ # combine all types of vouchers
subquery = get_queries(bank_account, company, transaction, document_types)
filters = {
"amount": transaction.unallocated_amount,
@@ -343,13 +345,11 @@
def get_je_matching_query(amount_condition, transaction):
# get matching journal entry query
+ # We have mapping at the bank level
+ # So one bank could have both types of bank accounts like asset and liability
+ # So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
company_account = frappe.get_value("Bank Account", transaction.bank_account, "account")
- root_type = frappe.get_value("Account", company_account, "root_type")
-
- if root_type == "Liability":
- cr_or_dr = "debit" if transaction.withdrawal > 0 else "credit"
- else:
- cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
+ cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
return f"""
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index 4620087..44cea31 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -102,7 +102,7 @@
AND
bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True)
-def get_paid_amount(payment_entry, currency):
+def get_paid_amount(payment_entry, currency, bank_account):
if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
paid_amount_field = "paid_amount"
@@ -115,7 +115,7 @@
payment_entry.payment_entry, paid_amount_field)
elif payment_entry.payment_document == "Journal Entry":
- return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_credit")
+ return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)")
elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed")
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index aa2408e..cb18dd3 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -986,7 +986,7 @@
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
pi.set_posting_time = 1
- pi.posting_date = '2019-03-15'
+ pi.posting_date = '2019-01-10'
pi.items[0].enable_deferred_expense = 1
pi.items[0].service_start_date = "2019-01-10"
pi.items[0].service_end_date = "2019-03-15"
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 12059f7..c5d8f09 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -185,6 +185,8 @@
frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx))
elif getdate(self.posting_date) > getdate(d.service_end_date):
frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx))
+ elif getdate(self.posting_date) > getdate(d.service_start_date):
+ frappe.throw(_("Row #{0}: Service Start Date cannot be before Invoice Posting Date").format(d.idx))
def validate_invoice_documents_schedule(self):
self.validate_payment_schedule_dates()
diff --git a/erpnext/crm/doctype/lead/test_lead.py b/erpnext/crm/doctype/lead/test_lead.py
index 56bfc8f..3882974 100644
--- a/erpnext/crm/doctype/lead/test_lead.py
+++ b/erpnext/crm/doctype/lead/test_lead.py
@@ -23,6 +23,17 @@
customer.customer_group = "_Test Customer Group"
customer.insert()
+ #check whether lead contact is carried forward to the customer.
+ contact = frappe.db.get_value('Dynamic Link', {
+ "parenttype": "Contact",
+ "link_doctype": "Lead",
+ "link_name": customer.lead_name,
+ }, "parent")
+
+ if contact:
+ contact_doc = frappe.get_doc("Contact", contact)
+ self.assertEqual(contact_doc.has_link(customer.doctype, customer.name), True)
+
def test_make_customer_from_organization(self):
from erpnext.crm.doctype.lead.lead import make_customer
diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json
deleted file mode 100644
index 5efafd6..0000000
--- a/erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json
+++ /dev/null
@@ -1,78 +0,0 @@
-{
- "charts": [],
- "content": "[{\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Integrations Settings\", \"col\": 4}}]",
- "creation": "2020-07-31 10:38:54.021237",
- "docstatus": 0,
- "doctype": "Workspace",
- "for_user": "",
- "hide_custom": 0,
- "icon": "setting",
- "idx": 0,
- "label": "ERPNext Integrations Settings",
- "links": [
- {
- "hidden": 0,
- "is_query_report": 0,
- "label": "Integrations Settings",
- "link_count": 0,
- "onboard": 0,
- "type": "Card Break"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Woocommerce Settings",
- "link_count": 0,
- "link_to": "Woocommerce Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Amazon MWS Settings",
- "link_count": 0,
- "link_to": "Amazon MWS Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Plaid Settings",
- "link_count": 0,
- "link_to": "Plaid Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Exotel Settings",
- "link_count": 0,
- "link_to": "Exotel Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- }
- ],
- "modified": "2021-11-23 04:30:33.106991",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "ERPNext Integrations Settings",
- "owner": "Administrator",
- "parent_page": "",
- "public": 1,
- "restrict_to_domain": "",
- "roles": [],
- "sequence_id": 11,
- "shortcuts": [],
- "title": "ERPNext Integrations Settings"
-}
\ No newline at end of file
diff --git a/erpnext/hr/doctype/department/department.js b/erpnext/hr/doctype/department/department.js
index 7db8cfb..46cfbda 100644
--- a/erpnext/hr/doctype/department/department.js
+++ b/erpnext/hr/doctype/department/department.js
@@ -6,6 +6,15 @@
frm.set_query("parent_department", function(){
return {"filters": [["Department", "is_group", "=", 1]]};
});
+
+ frm.set_query("payroll_cost_center", function() {
+ return {
+ filters: {
+ "company": frm.doc.company,
+ "is_group": 0
+ }
+ };
+ });
},
refresh: function(frm) {
// read-only for root department
diff --git a/erpnext/hr/doctype/employee/employee.js b/erpnext/hr/doctype/employee/employee.js
index 13b33e2..8c73e9c 100755
--- a/erpnext/hr/doctype/employee/employee.js
+++ b/erpnext/hr/doctype/employee/employee.js
@@ -47,6 +47,15 @@
}
};
});
+
+ frm.set_query("payroll_cost_center", function() {
+ return {
+ filters: {
+ "company": frm.doc.company,
+ "is_group": 0
+ }
+ };
+ });
},
onload: function (frm) {
frm.set_query("department", function() {
diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py
index 88e5ca9..a2df26c 100755
--- a/erpnext/hr/doctype/employee/employee.py
+++ b/erpnext/hr/doctype/employee/employee.py
@@ -68,12 +68,18 @@
self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name]))
def validate_user_details(self):
- data = frappe.db.get_value('User',
- self.user_id, ['enabled', 'user_image'], as_dict=1)
- if data.get("user_image") and self.image == '':
- self.image = data.get("user_image")
- self.validate_for_enabled_user_id(data.get("enabled", 0))
- self.validate_duplicate_user_id()
+ if self.user_id:
+ data = frappe.db.get_value('User',
+ self.user_id, ['enabled', 'user_image'], as_dict=1)
+
+ if not data:
+ self.user_id = None
+ return
+
+ if data.get("user_image") and self.image == '':
+ self.image = data.get("user_image")
+ self.validate_for_enabled_user_id(data.get("enabled", 0))
+ self.validate_duplicate_user_id()
def update_nsm_model(self):
frappe.utils.nestedset.update_nsm(self)
diff --git a/erpnext/hr/doctype/expense_claim/expense_claim.js b/erpnext/hr/doctype/expense_claim/expense_claim.js
index 6655563..0479457 100644
--- a/erpnext/hr/doctype/expense_claim/expense_claim.js
+++ b/erpnext/hr/doctype/expense_claim/expense_claim.js
@@ -171,7 +171,7 @@
['docstatus', '=', 1],
['employee', '=', frm.doc.employee],
['paid_amount', '>', 0],
- ['paid_amount', '>', 'claimed_amount']
+ ['status', '!=', 'Claimed']
]
};
});
diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py
index ec70361..2a07920 100644
--- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py
+++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py
@@ -10,15 +10,17 @@
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry
-test_records = frappe.get_test_records('Expense Claim')
test_dependencies = ['Employee']
-company_name = '_Test Company 4'
+company_name = '_Test Company 3'
class TestExpenseClaim(unittest.TestCase):
+ def tearDown(self):
+ frappe.db.rollback()
+
def test_total_expense_claim_for_project(self):
- frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """)
- frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """)
+ frappe.db.sql("""delete from `tabTask`""")
+ frappe.db.sql("""delete from `tabProject`""")
frappe.db.sql("update `tabExpense Claim` set project = '', task = ''")
project = frappe.get_doc({
@@ -37,12 +39,12 @@
task_name = task.name
payable_account = get_payable_account(company_name)
- make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", project.name, task_name)
+ make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3", project.name, task_name)
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200)
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200)
- expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4", project.name, task_name)
+ expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC3", project.name, task_name)
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700)
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 700)
@@ -54,7 +56,7 @@
def test_expense_claim_status(self):
payable_account = get_payable_account(company_name)
- expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4")
+ expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3")
je_dict = make_bank_entry("Expense Claim", expense_claim.name)
je = frappe.get_doc(je_dict)
@@ -73,7 +75,7 @@
def test_expense_claim_gl_entry(self):
payable_account = get_payable_account(company_name)
taxes = generate_taxes()
- expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4",
+ expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3",
do_not_submit=True, taxes=taxes)
expense_claim.submit()
@@ -84,9 +86,9 @@
self.assertTrue(gl_entries)
expected_values = dict((d[0], d) for d in [
- ['Output Tax CGST - _TC4',18.0, 0.0],
+ ['Output Tax CGST - _TC3',18.0, 0.0],
[payable_account, 0.0, 218.0],
- ["Travel Expenses - _TC4", 200.0, 0.0]
+ ["Travel Expenses - _TC3", 200.0, 0.0]
])
for gle in gl_entries:
@@ -102,7 +104,7 @@
"payable_account": payable_account,
"approval_status": "Rejected",
"expenses":
- [{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }]
+ [{"expense_type": "Travel", "default_account": "Travel Expenses - _TC3", "amount": 300, "sanctioned_amount": 200}]
})
expense_claim.submit()
diff --git a/erpnext/hr/doctype/expense_claim/test_records.json b/erpnext/hr/doctype/expense_claim/test_records.json
deleted file mode 100644
index fe51488..0000000
--- a/erpnext/hr/doctype/expense_claim/test_records.json
+++ /dev/null
@@ -1 +0,0 @@
-[]
diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
index 46401a2..1fe9139 100644
--- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
@@ -4,6 +4,7 @@
from frappe.utils import add_days, add_months, getdate, nowdate
import erpnext
+from erpnext.hr.doctype.employee.test_employee import make_employee
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
@@ -13,16 +14,19 @@
def setUpClass(cls):
frappe.db.sql("delete from `tabLeave Period`")
- def test_overlapping_allocation(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
+ emp_id = make_employee("test_emp_leave_allocation@salary.com")
+ cls.employee = frappe.get_doc("Employee", emp_id)
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
+ def tearDown(self):
+ frappe.db.rollback()
+
+ def test_overlapping_allocation(self):
leaves = [
{
"doctype": "Leave Allocation",
"__islocal": 1,
- "employee": employee.name,
- "employee_name": employee.employee_name,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-10-01"),
"to_date": getdate("2015-10-31"),
@@ -32,8 +36,8 @@
{
"doctype": "Leave Allocation",
"__islocal": 1,
- "employee": employee.name,
- "employee_name": employee.employee_name,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-01"),
"to_date": getdate("2015-11-30"),
@@ -45,40 +49,36 @@
self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save)
def test_invalid_period(self):
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
-
doc = frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
- "employee": employee.name,
- "employee_name": employee.employee_name,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-30"),
"to_date": getdate("2015-09-1"),
"new_leaves_allocated": 5
})
- #invalid period
+ # invalid period
self.assertRaises(frappe.ValidationError, doc.save)
def test_allocated_leave_days_over_period(self):
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
doc = frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
- "employee": employee.name,
- "employee_name": employee.employee_name,
+ "employee": self.employee.name,
+ "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-1"),
"to_date": getdate("2015-09-30"),
"new_leaves_allocated": 35
})
- #allocated leave more than period
+
+ # allocated leave more than period
self.assertRaises(frappe.ValidationError, doc.save)
def test_carry_forward_calculation(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
- frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
leave_type.maximum_carry_forwarded_leaves = 10
leave_type.max_leaves_allowed = 30
@@ -86,6 +86,8 @@
# initial leave allocation = 15
leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
from_date=add_months(nowdate(), -12),
to_date=add_months(nowdate(), -1),
@@ -95,6 +97,8 @@
# carry forwarded leaves considering maximum_carry_forwarded_leaves
# new_leaves = 15, carry_forwarded = 10
leave_allocation_1 = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
carry_forward=1)
leave_allocation_1.submit()
@@ -106,6 +110,8 @@
# carry forwarded leaves considering max_leave_allowed
# max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5
leave_allocation_2 = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave",
carry_forward=1,
new_leaves_allocated=25)
@@ -114,8 +120,6 @@
self.assertEqual(leave_allocation_2.unused_leaves, 5)
def test_carry_forward_leaves_expiry(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
- frappe.db.sql("delete from `tabLeave Ledger Entry`")
leave_type = create_leave_type(
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1,
@@ -124,6 +128,8 @@
# initial leave allocation
leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
from_date=add_months(nowdate(), -24),
to_date=add_months(nowdate(), -12),
@@ -131,6 +137,8 @@
leave_allocation.submit()
leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
from_date=add_days(nowdate(), -90),
to_date=add_days(nowdate(), 100),
@@ -142,6 +150,8 @@
# leave allocation with carry forward of only new leaves allocated
leave_allocation_1 = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry",
carry_forward=1,
from_date=add_months(nowdate(), 6),
@@ -151,9 +161,10 @@
self.assertEqual(leave_allocation_1.unused_leaves, leave_allocation.new_leaves_allocated)
def test_creation_of_leave_ledger_entry_on_submit(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
-
- leave_allocation = create_leave_allocation()
+ leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name
+ )
leave_allocation.submit()
leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_allocation.name))
@@ -168,10 +179,10 @@
self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name}))
def test_leave_addition_after_submit(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
- frappe.db.sql("delete from `tabLeave Ledger Entry`")
-
- leave_allocation = create_leave_allocation()
+ leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name
+ )
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 40
@@ -179,44 +190,55 @@
self.assertTrue(leave_allocation.total_leaves_allocated, 40)
def test_leave_subtraction_after_submit(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
- frappe.db.sql("delete from `tabLeave Ledger Entry`")
- leave_allocation = create_leave_allocation()
+ leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name
+ )
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 10
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 10)
- def test_against_leave_application_validation_after_submit(self):
- frappe.db.sql("delete from `tabLeave Allocation`")
- frappe.db.sql("delete from `tabLeave Ledger Entry`")
+ def test_validation_against_leave_application_after_submit(self):
+ from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
- leave_allocation = create_leave_allocation()
+ make_holiday_list()
+ frappe.db.set_value("Company", self.employee.company, "default_holiday_list", "Salary Slip Test Holiday List")
+
+ leave_allocation = create_leave_allocation(
+ employee=self.employee.name,
+ employee_name=self.employee.employee_name
+ )
leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
+
leave_application = frappe.get_doc({
"doctype": 'Leave Application',
- "employee": employee.name,
+ "employee": self.employee.name,
"leave_type": "_Test Leave Type",
"from_date": add_months(nowdate(), 2),
"to_date": add_months(add_days(nowdate(), 10), 2),
- "company": erpnext.get_default_company() or "_Test Company",
+ "company": self.employee.company,
"docstatus": 1,
"status": "Approved",
"leave_approver": 'test@example.com'
})
leave_application.submit()
- leave_allocation.new_leaves_allocated = 8
- leave_allocation.total_leaves_allocated = 8
+ leave_application.reload()
+
+ # allocate less leaves than the ones which are already approved
+ leave_allocation.new_leaves_allocated = leave_application.total_leave_days - 1
+ leave_allocation.total_leaves_allocated = leave_application.total_leave_days - 1
self.assertRaises(frappe.ValidationError, leave_allocation.submit)
def create_leave_allocation(**args):
args = frappe._dict(args)
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
- leave_allocation = frappe.get_doc({
+ emp_id = make_employee("test_emp_leave_allocation@salary.com")
+ employee = frappe.get_doc("Employee", emp_id)
+
+ return frappe.get_doc({
"doctype": "Leave Allocation",
"__islocal": 1,
"employee": args.employee or employee.name,
@@ -227,6 +249,5 @@
"carry_forward": args.carry_forward or 0,
"to_date": args.to_date or add_months(nowdate(), 12)
})
- return leave_allocation
test_dependencies = ["Employee", "Leave Type"]
diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json
index 5979992..af26f7b 100644
--- a/erpnext/loan_management/doctype/loan/loan.json
+++ b/erpnext/loan_management/doctype/loan/loan.json
@@ -240,12 +240,14 @@
"label": "Repayment Schedule"
},
{
+ "allow_on_submit": 1,
"depends_on": "eval:doc.is_term_loan == 1",
"fieldname": "repayment_schedule",
"fieldtype": "Table",
"label": "Repayment Schedule",
"no_copy": 1,
- "options": "Repayment Schedule"
+ "options": "Repayment Schedule",
+ "read_only": 1
},
{
"fieldname": "section_break_17",
@@ -363,6 +365,7 @@
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index 84e0f03..f660a24 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -7,7 +7,7 @@
import frappe
from frappe import _
-from frappe.utils import add_months, flt, getdate, now_datetime, nowdate
+from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate
import erpnext
from erpnext.controllers.accounts_controller import AccountsController
@@ -62,7 +62,7 @@
self.rate_of_interest = frappe.db.get_value("Loan Type", self.loan_type, "rate_of_interest")
if self.repayment_method == "Repay Over Number of Periods":
- self.monthly_repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
+ self.monthly_repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
def check_sanctioned_amount_limit(self):
sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company)
@@ -99,7 +99,7 @@
"total_payment": total_payment,
"balance_loan_amount": balance_amount
})
- next_payment_date = add_months(payment_date, 1)
+ next_payment_date = add_single_month(payment_date)
payment_date = next_payment_date
def set_repayment_period(self):
@@ -211,7 +211,7 @@
if monthly_repayment_amount > loan_amount:
frappe.throw(_("Monthly Repayment Amount cannot be greater than Loan Amount"))
-def get_monthly_repayment_amount(repayment_method, loan_amount, rate_of_interest, repayment_periods):
+def get_monthly_repayment_amount(loan_amount, rate_of_interest, repayment_periods):
if rate_of_interest:
monthly_interest_rate = flt(rate_of_interest) / (12 *100)
monthly_repayment_amount = math.ceil((loan_amount * monthly_interest_rate *
@@ -395,3 +395,9 @@
"value": len(applicants),
"fieldtype": "Int"
}
+
+def add_single_month(date):
+ if getdate(date) == get_last_day(date):
+ return get_last_day(add_months(date, 1))
+ else:
+ return add_months(date, 1)
\ No newline at end of file
diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py
index c0f058f..1676c21 100644
--- a/erpnext/loan_management/doctype/loan/test_loan.py
+++ b/erpnext/loan_management/doctype/loan/test_loan.py
@@ -218,6 +218,14 @@
self.assertEqual(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid -
penalty_amount - total_interest_paid, 0))
+ # Check Repayment Entry cancel
+ repayment_entry.load_from_db()
+ repayment_entry.cancel()
+
+ loan.load_from_db()
+ self.assertEqual(loan.total_principal_paid, 0)
+ self.assertEqual(loan.total_principal_paid, 0)
+
def test_loan_closure(self):
pledge = [{
"loan_security": "Test Security 1",
@@ -295,6 +303,27 @@
self.assertEqual(amounts[0], 11250.00)
self.assertEqual(amounts[1], 78303.00)
+ def test_repayment_schedule_update(self):
+ loan = create_loan(self.applicant2, "Personal Loan", 200000, "Repay Over Number of Periods", 4,
+ applicant_type='Customer', repayment_start_date='2021-04-30', posting_date='2021-04-01')
+
+ loan.submit()
+
+ make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date='2021-04-01')
+
+ process_loan_interest_accrual_for_term_loans(posting_date='2021-05-01')
+ process_loan_interest_accrual_for_term_loans(posting_date='2021-06-01')
+
+ repayment_entry = create_repayment_entry(loan.name, self.applicant2, '2021-06-05', 120000)
+ repayment_entry.submit()
+
+ loan.load_from_db()
+
+ self.assertEqual(flt(loan.get('repayment_schedule')[3].principal_amount, 2), 41369.83)
+ self.assertEqual(flt(loan.get('repayment_schedule')[3].interest_amount, 2), 289.59)
+ self.assertEqual(flt(loan.get('repayment_schedule')[3].total_payment, 2), 41659.41)
+ self.assertEqual(flt(loan.get('repayment_schedule')[3].balance_loan_amount, 2), 0)
+
def test_security_shortfall(self):
pledges = [{
"loan_security": "Test Security 2",
@@ -938,18 +967,18 @@
def create_loan(applicant, loan_type, loan_amount, repayment_method, repayment_periods,
- repayment_start_date=None, posting_date=None):
+ applicant_type=None, repayment_start_date=None, posting_date=None):
loan = frappe.get_doc({
"doctype": "Loan",
- "applicant_type": "Employee",
+ "applicant_type": applicant_type or "Employee",
"company": "_Test Company",
"applicant": applicant,
"loan_type": loan_type,
"loan_amount": loan_amount,
"repayment_method": repayment_method,
"repayment_periods": repayment_periods,
- "repayment_start_date": nowdate(),
+ "repayment_start_date": repayment_start_date or nowdate(),
"is_term_loan": 1,
"posting_date": posting_date or nowdate()
})
diff --git a/erpnext/loan_management/doctype/loan_application/loan_application.py b/erpnext/loan_management/doctype/loan_application/loan_application.py
index 24d8d68..a8ffcb9 100644
--- a/erpnext/loan_management/doctype/loan_application/loan_application.py
+++ b/erpnext/loan_management/doctype/loan_application/loan_application.py
@@ -80,7 +80,7 @@
if self.is_term_loan:
if self.repayment_method == "Repay Over Number of Periods":
- self.repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
+ self.repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
if self.repayment_method == "Repay Fixed Amount per Period":
monthly_interest_rate = flt(self.rate_of_interest) / (12 *100)
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index 93b4af9..e2d758b 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -176,20 +176,19 @@
@frappe.whitelist()
def get_disbursal_amount(loan, on_current_security_price=0):
+ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
+ get_pending_principal_amount,
+ )
+
loan_details = frappe.get_value("Loan", loan, ["loan_amount", "disbursed_amount", "total_payment",
"total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan",
- "maximum_loan_amount"], as_dict=1)
+ "maximum_loan_amount", "written_off_amount"], as_dict=1)
if loan_details.is_secured_loan and frappe.get_all('Loan Security Shortfall', filters={'loan': loan,
'status': 'Pending'}):
return 0
- if loan_details.status == 'Disbursed':
- pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
- - flt(loan_details.total_principal_paid)
- else:
- pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
- - flt(loan_details.total_principal_paid)
+ pending_principal_amount = get_pending_principal_amount(loan_details)
security_value = 0.0
if loan_details.is_secured_loan and on_current_security_price:
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
index e945d49..0de073f 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
@@ -74,6 +74,39 @@
})
)
+ if self.payable_principal_amount:
+ gle_map.append(
+ self.get_gl_dict({
+ "account": self.loan_account,
+ "party_type": self.applicant_type,
+ "party": self.applicant,
+ "against": self.interest_income_account,
+ "debit": self.payable_principal_amount,
+ "debit_in_account_currency": self.interest_amount,
+ "against_voucher_type": "Loan",
+ "against_voucher": self.loan,
+ "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format(
+ self.last_accrual_date, self.posting_date, self.loan),
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ "posting_date": self.posting_date
+ })
+ )
+
+ gle_map.append(
+ self.get_gl_dict({
+ "account": self.interest_income_account,
+ "against": self.loan_account,
+ "credit": self.payable_principal_amount,
+ "credit_in_account_currency": self.interest_amount,
+ "against_voucher_type": "Loan",
+ "against_voucher": self.loan,
+ "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format(
+ self.last_accrual_date, self.posting_date, self.loan),
+ "cost_center": erpnext.get_default_cost_center(self.company),
+ "posting_date": self.posting_date
+ })
+ )
+
if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
@@ -82,7 +115,10 @@
# rate of interest is 13.5 then first loan interest accural will be on '01-10-2019'
# which means interest will be accrued for 30 days which should be equal to 11095.89
def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest, accrual_type):
- from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts
+ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
+ calculate_amounts,
+ get_pending_principal_amount,
+ )
no_of_days = get_no_of_days_for_interest_accural(loan, posting_date)
precision = cint(frappe.db.get_default("currency_precision")) or 2
@@ -90,12 +126,7 @@
if no_of_days <= 0:
return
- if loan.status == 'Disbursed':
- pending_principal_amount = flt(loan.total_payment) - flt(loan.total_interest_payable) \
- - flt(loan.total_principal_paid) - flt(loan.written_off_amount)
- else:
- pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \
- - flt(loan.total_principal_paid) - flt(loan.written_off_amount)
+ pending_principal_amount = get_pending_principal_amount(loan)
interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date)
payable_interest = interest_per_day * no_of_days
@@ -133,7 +164,7 @@
if not open_loans:
open_loans = frappe.get_all("Loan",
- fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account",
+ fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account", "loan_amount",
"is_term_loan", "status", "disbursement_date", "disbursed_amount", "applicant_type", "applicant",
"rate_of_interest", "total_interest_payable", "written_off_amount", "total_principal_paid", "repayment_start_date"],
filters=query_filters)
@@ -190,7 +221,8 @@
AND l.is_term_loan =1
AND rs.payment_date <= %s
AND rs.is_accrued=0 {0}
- AND l.status = 'Disbursed'""".format(condition), (getdate(date)), as_dict=1)
+ AND l.status = 'Disbursed'
+ ORDER BY rs.payment_date""".format(condition), (getdate(date)), as_dict=1)
return term_loans
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 5922e4f..2abb395 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -35,9 +35,12 @@
def on_submit(self):
self.update_paid_amount()
+ self.update_repayment_schedule()
self.make_gl_entries()
def on_cancel(self):
+ self.check_future_accruals()
+ self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid()
self.ignore_linked_doctypes = ['GL Entry']
self.make_gl_entries(cancel=1)
@@ -90,7 +93,7 @@
def book_unaccrued_interest(self):
precision = cint(frappe.db.get_default("currency_precision")) or 2
- if self.total_interest_paid > self.interest_payable:
+ if flt(self.total_interest_paid, precision) > flt(self.interest_payable, precision):
if not self.is_term_loan:
# get last loan interest accrual date
last_accrual_date = get_last_accrual_date(self.against_loan)
@@ -121,7 +124,18 @@
})
def update_paid_amount(self):
- loan = frappe.get_doc("Loan", self.against_loan)
+ loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'written_off_amount'], as_dict=1)
+
+ loan.update({
+ 'total_amount_paid': loan.total_amount_paid + self.amount_paid,
+ 'total_principal_paid': loan.total_principal_paid + self.principal_amount_paid
+ })
+
+ pending_principal_amount = get_pending_principal_amount(loan)
+ if not loan.is_secured_loan and pending_principal_amount <= 0:
+ loan.update({'status': 'Loan Closure Requested'})
for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
@@ -130,17 +144,31 @@
WHERE name = %s""",
(flt(payment.paid_principal_amount), flt(payment.paid_interest_amount), payment.loan_interest_accrual))
- frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
- WHERE name = %s """, (loan.total_amount_paid + self.amount_paid,
- loan.total_principal_paid + self.principal_amount_paid, self.against_loan))
+ frappe.db.sql(""" UPDATE `tabLoan`
+ SET total_amount_paid = %s, total_principal_paid = %s, status = %s
+ WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status,
+ self.against_loan))
update_shortfall_status(self.against_loan, self.principal_amount_paid)
def mark_as_unpaid(self):
- loan = frappe.get_doc("Loan", self.against_loan)
+ loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'written_off_amount'], as_dict=1)
no_of_repayments = len(self.repayment_details)
+ loan.update({
+ 'total_amount_paid': loan.total_amount_paid - self.amount_paid,
+ 'total_principal_paid': loan.total_principal_paid - self.principal_amount_paid
+ })
+
+ if loan.status == 'Loan Closure Requested':
+ if loan.disbursed_amount >= loan.loan_amount:
+ loan['status'] = 'Disbursed'
+ else:
+ loan['status'] = 'Partially Disbursed'
+
for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
SET paid_principal_amount = `paid_principal_amount` - %s,
@@ -154,12 +182,20 @@
lia_doc = frappe.get_doc('Loan Interest Accrual', payment.loan_interest_accrual)
lia_doc.cancel()
- frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
- WHERE name = %s """, (loan.total_amount_paid - self.amount_paid,
- loan.total_principal_paid - self.principal_amount_paid, self.against_loan))
+ frappe.db.sql(""" UPDATE `tabLoan`
+ SET total_amount_paid = %s, total_principal_paid = %s, status = %s
+ WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status, self.against_loan))
- if loan.status == "Loan Closure Requested":
- frappe.db.set_value("Loan", self.against_loan, "status", "Disbursed")
+ def check_future_accruals(self):
+ future_accrual_date = frappe.db.get_value("Loan Interest Accrual", {"posting_date": (">", self.posting_date),
+ "docstatus": 1, "loan": self.against_loan}, 'posting_date')
+
+ if future_accrual_date:
+ frappe.throw("Cannot cancel. Interest accruals already processed till {0}".format(get_datetime(future_accrual_date)))
+
+ def update_repayment_schedule(self, cancel=0):
+ if self.is_term_loan and self.principal_amount_paid > self.payable_principal_amount:
+ regenerate_repayment_schedule(self.against_loan, cancel)
def allocate_amounts(self, repayment_details):
self.set('repayment_details', [])
@@ -182,50 +218,93 @@
interest_paid -= self.total_penalty_paid
- total_interest_paid = 0
- # interest_paid = self.amount_paid - self.principal_amount_paid - self.penalty_amount
+ if self.is_term_loan:
+ interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
+ self.allocate_principal_amount_for_term_loans(interest_paid, repayment_details, updated_entries)
+ else:
+ interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
+ self.allocate_excess_payment_for_demand_loans(interest_paid, repayment_details)
+
+ def allocate_interest_amount(self, interest_paid, repayment_details):
+ updated_entries = {}
+ self.total_interest_paid = 0
+ idx = 1
if interest_paid > 0:
for lia, amounts in repayment_details.get('pending_accrual_entries', []).items():
- if amounts['interest_amount'] + amounts['payable_principal_amount'] <= interest_paid:
+ interest_amount = 0
+ if amounts['interest_amount'] <= interest_paid:
interest_amount = amounts['interest_amount']
- paid_principal = amounts['payable_principal_amount']
- self.principal_amount_paid += paid_principal
- interest_paid -= (interest_amount + paid_principal)
+ self.total_interest_paid += interest_amount
+ interest_paid -= interest_amount
elif interest_paid:
if interest_paid >= amounts['interest_amount']:
interest_amount = amounts['interest_amount']
- paid_principal = interest_paid - interest_amount
- self.principal_amount_paid += paid_principal
+ self.total_interest_paid += interest_amount
interest_paid = 0
else:
interest_amount = interest_paid
+ self.total_interest_paid += interest_amount
interest_paid = 0
- paid_principal=0
- total_interest_paid += interest_amount
- self.append('repayment_details', {
- 'loan_interest_accrual': lia,
- 'paid_interest_amount': interest_amount,
- 'paid_principal_amount': paid_principal
- })
+ if interest_amount:
+ self.append('repayment_details', {
+ 'loan_interest_accrual': lia,
+ 'paid_interest_amount': interest_amount,
+ 'paid_principal_amount': 0
+ })
+ updated_entries[lia] = idx
+ idx += 1
+ return interest_paid, updated_entries
+
+ def allocate_principal_amount_for_term_loans(self, interest_paid, repayment_details, updated_entries):
+ if interest_paid > 0:
+ for lia, amounts in repayment_details.get('pending_accrual_entries', []).items():
+ paid_principal = 0
+ if amounts['payable_principal_amount'] <= interest_paid:
+ paid_principal = amounts['payable_principal_amount']
+ self.principal_amount_paid += paid_principal
+ interest_paid -= paid_principal
+ elif interest_paid:
+ if interest_paid >= amounts['payable_principal_amount']:
+ paid_principal = amounts['payable_principal_amount']
+ self.principal_amount_paid += paid_principal
+ interest_paid = 0
+ else:
+ paid_principal = interest_paid
+ self.principal_amount_paid += paid_principal
+ interest_paid = 0
+
+ if updated_entries.get(lia):
+ idx = updated_entries.get(lia)
+ self.get('repayment_details')[idx-1].paid_principal_amount += paid_principal
+ else:
+ self.append('repayment_details', {
+ 'loan_interest_accrual': lia,
+ 'paid_interest_amount': 0,
+ 'paid_principal_amount': paid_principal
+ })
+
+ if interest_paid > 0:
+ self.principal_amount_paid += interest_paid
+
+ def allocate_excess_payment_for_demand_loans(self, interest_paid, repayment_details):
if repayment_details['unaccrued_interest'] and interest_paid > 0:
# no of days for which to accrue interest
# Interest can only be accrued for an entire day and not partial
if interest_paid > repayment_details['unaccrued_interest']:
interest_paid -= repayment_details['unaccrued_interest']
- total_interest_paid += repayment_details['unaccrued_interest']
+ self.total_interest_paid += repayment_details['unaccrued_interest']
else:
# get no of days for which interest can be paid
per_day_interest = get_per_day_interest(self.pending_principal_amount,
self.rate_of_interest, self.posting_date)
no_of_days = cint(interest_paid/per_day_interest)
- total_interest_paid += no_of_days * per_day_interest
+ self.total_interest_paid += no_of_days * per_day_interest
interest_paid -= no_of_days * per_day_interest
- self.total_interest_paid = total_interest_paid
if interest_paid > 0:
self.principal_amount_paid += interest_paid
@@ -361,6 +440,76 @@
else:
return None, 0
+def regenerate_repayment_schedule(loan, cancel=0):
+ from erpnext.loan_management.doctype.loan.loan import (
+ add_single_month,
+ get_monthly_repayment_amount,
+ )
+
+ loan_doc = frappe.get_doc('Loan', loan)
+ next_accrual_date = None
+ accrued_entries = 0
+ last_repayment_amount = 0
+ last_balance_amount = 0
+
+ for term in reversed(loan_doc.get('repayment_schedule')):
+ if not term.is_accrued:
+ next_accrual_date = term.payment_date
+ loan_doc.remove(term)
+ else:
+ accrued_entries += 1
+ if not last_repayment_amount:
+ last_repayment_amount = term.total_payment
+ if not last_balance_amount:
+ last_balance_amount = term.balance_loan_amount
+
+ loan_doc.save()
+
+ balance_amount = get_pending_principal_amount(loan_doc)
+
+ if loan_doc.repayment_method == 'Repay Fixed Amount per Period':
+ monthly_repayment_amount = flt(balance_amount/len(loan_doc.get('repayment_schedule')) - accrued_entries)
+ else:
+ if not cancel:
+ monthly_repayment_amount = get_monthly_repayment_amount(balance_amount,
+ loan_doc.rate_of_interest, loan_doc.repayment_periods - accrued_entries)
+ else:
+ monthly_repayment_amount = last_repayment_amount
+ balance_amount = last_balance_amount
+
+ payment_date = next_accrual_date
+
+ while(balance_amount > 0):
+ interest_amount = flt(balance_amount * flt(loan_doc.rate_of_interest) / (12*100))
+ principal_amount = monthly_repayment_amount - interest_amount
+ balance_amount = flt(balance_amount + interest_amount - monthly_repayment_amount)
+ if balance_amount < 0:
+ principal_amount += balance_amount
+ balance_amount = 0.0
+
+ total_payment = principal_amount + interest_amount
+ loan_doc.append("repayment_schedule", {
+ "payment_date": payment_date,
+ "principal_amount": principal_amount,
+ "interest_amount": interest_amount,
+ "total_payment": total_payment,
+ "balance_loan_amount": balance_amount
+ })
+ next_payment_date = add_single_month(payment_date)
+ payment_date = next_payment_date
+
+ loan_doc.save()
+
+def get_pending_principal_amount(loan):
+ if loan.status in ('Disbursed', 'Closed') or loan.disbursed_amount >= loan.loan_amount:
+ pending_principal_amount = flt(loan.total_payment) - flt(loan.total_principal_paid) \
+ - flt(loan.total_interest_payable) - flt(loan.written_off_amount)
+ else:
+ pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_principal_paid) \
+ - flt(loan.total_interest_payable) - flt(loan.written_off_amount)
+
+ return pending_principal_amount
+
# This function returns the amounts that are payable at the time of loan repayment based on posting date
# So it pulls all the unpaid Loan Interest Accrual Entries and calculates the penalty if applicable
@@ -408,12 +557,7 @@
if due_date and not final_due_date:
final_due_date = add_days(due_date, loan_type_details.grace_period_in_days)
- if against_loan_doc.status in ('Disbursed', 'Closed') or against_loan_doc.disbursed_amount >= against_loan_doc.loan_amount:
- pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid \
- - against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
- else:
- pending_principal_amount = against_loan_doc.disbursed_amount - against_loan_doc.total_principal_paid \
- - against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
+ pending_principal_amount = get_pending_principal_amount(against_loan_doc)
unaccrued_interest = 0
if due_date:
diff --git a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
index bff9d5c..4567374 100644
--- a/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
+++ b/erpnext/loan_management/doctype/loan_security_unpledge/loan_security_unpledge.py
@@ -27,6 +27,9 @@
d.idx, frappe.bold(d.loan_security)))
def validate_unpledge_qty(self):
+ from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
+ get_pending_principal_amount,
+ )
from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import (
get_ltv_ratio,
)
@@ -43,15 +46,10 @@
"valid_upto": (">=", get_datetime())
}, as_list=1))
- loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid',
+ loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid', 'loan_amount',
'total_interest_payable', 'written_off_amount', 'disbursed_amount', 'status'], as_dict=1)
- if loan_details.status == 'Disbursed':
- pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
- - flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
- else:
- pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
- - flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
+ pending_principal_amount = get_pending_principal_amount(loan_details)
security_value = 0
unpledge_qty_map = {}
diff --git a/erpnext/non_profit/doctype/membership/membership.py b/erpnext/non_profit/doctype/membership/membership.py
index beb38e2..f9b295a 100644
--- a/erpnext/non_profit/doctype/membership/membership.py
+++ b/erpnext/non_profit/doctype/membership/membership.py
@@ -409,7 +409,7 @@
def set_expired_status():
frappe.db.sql("""
UPDATE
- `tabMembership` SET `status` = 'Expired'
+ `tabMembership` SET `membership_status` = 'Expired'
WHERE
- `status` not in ('Cancelled') AND `to_date` < %s
+ `membership_status` not in ('Cancelled') AND `to_date` < %s
""", (nowdate()))
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d9cedab..deeeeb7 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -317,4 +317,7 @@
erpnext.patches.v14_0.migrate_crm_settings
erpnext.patches.v13_0.rename_ksa_qr_field
erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021
-erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template
\ No newline at end of file
+erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template
+erpnext.patches.v13_0.update_tax_category_for_rcm
+execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
+erpnext.patches.v14_0.set_payroll_cost_centers
\ No newline at end of file
diff --git a/erpnext/patches/v11_0/add_default_dispatch_notification_template.py b/erpnext/patches/v11_0/add_default_dispatch_notification_template.py
index 08006ad..c7771a5 100644
--- a/erpnext/patches/v11_0/add_default_dispatch_notification_template.py
+++ b/erpnext/patches/v11_0/add_default_dispatch_notification_template.py
@@ -22,4 +22,5 @@
delivery_settings = frappe.get_doc("Delivery Settings")
delivery_settings.dispatch_template = _("Dispatch Notification")
+ delivery_settings.flags.ignore_links = True
delivery_settings.save()
diff --git a/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py b/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py
index d157aad..d4fbded 100644
--- a/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py
+++ b/erpnext/patches/v12_0/create_itc_reversal_custom_fields.py
@@ -97,6 +97,8 @@
'itc_central_tax': 0,
'itc_cess_amount': 0
})
+ if not gst_accounts:
+ continue
if d.account_head in gst_accounts.get('igst_account'):
amount_map[d.parent]['itc_integrated_tax'] += d.amount
diff --git a/erpnext/patches/v12_0/update_bom_in_so_mr.py b/erpnext/patches/v12_0/update_bom_in_so_mr.py
index 37d850f..132f3bd 100644
--- a/erpnext/patches/v12_0/update_bom_in_so_mr.py
+++ b/erpnext/patches/v12_0/update_bom_in_so_mr.py
@@ -6,7 +6,7 @@
frappe.reload_doc("selling", "doctype", "sales_order_item")
for doctype in ["Sales Order", "Material Request"]:
- condition = " and child_doc.stock_qty > child_doc.produced_qty"
+ condition = " and child_doc.stock_qty > child_doc.produced_qty and doc.per_delivered < 100"
if doctype == "Material Request":
condition = " and doc.per_ordered < 100 and doc.material_request_type = 'Manufacture'"
@@ -15,5 +15,6 @@
child_doc.bom_no = item.default_bom
WHERE
child_doc.item_code = item.name and child_doc.docstatus < 2
+ and child_doc.parent = doc.name
and item.default_bom is not null and item.default_bom != '' {cond}
""".format(doc = doctype, cond = condition))
diff --git a/erpnext/patches/v13_0/add_default_interview_notification_templates.py b/erpnext/patches/v13_0/add_default_interview_notification_templates.py
index 0208ca9..6b5de52 100644
--- a/erpnext/patches/v13_0/add_default_interview_notification_templates.py
+++ b/erpnext/patches/v13_0/add_default_interview_notification_templates.py
@@ -32,4 +32,5 @@
hr_settings = frappe.get_doc('HR Settings')
hr_settings.interview_reminder_template = _('Interview Reminder')
hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder')
+ hr_settings.flags.ignore_links = True
hr_settings.save()
diff --git a/erpnext/patches/v13_0/update_tax_category_for_rcm.py b/erpnext/patches/v13_0/update_tax_category_for_rcm.py
new file mode 100644
index 0000000..7af2366
--- /dev/null
+++ b/erpnext/patches/v13_0/update_tax_category_for_rcm.py
@@ -0,0 +1,31 @@
+import frappe
+from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
+
+from erpnext.regional.india import states
+
+
+def execute():
+ company = frappe.get_all('Company', filters = {'country': 'India'})
+ if not company:
+ return
+
+ create_custom_fields({
+ 'Tax Category': [
+ dict(fieldname='is_inter_state', label='Is Inter State',
+ fieldtype='Check', insert_after='disabled', print_hide=1),
+ dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check',
+ insert_after='is_inter_state', print_hide=1),
+ dict(fieldname='tax_category_column_break', fieldtype='Column Break',
+ insert_after='is_reverse_charge'),
+ dict(fieldname='gst_state', label='Source State', fieldtype='Select',
+ options='\n'.join(states), insert_after='company')
+ ]
+ }, update=True)
+
+ tax_category = frappe.qb.DocType("Tax Category")
+
+ frappe.qb.update(tax_category).set(
+ tax_category.is_reverse_charge, 1
+ ).where(
+ tax_category.name.isin(['Reverse Charge Out-State', 'Reverse Charge In-State'])
+ ).run()
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
index 8b1752b..120182a 100644
--- a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
+++ b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
@@ -24,4 +24,5 @@
hr_settings = frappe.get_doc("HR Settings")
hr_settings.exit_questionnaire_notification_template = template
+ hr_settings.flags.ignore_links = True
hr_settings.save()
diff --git a/erpnext/patches/v14_0/set_payroll_cost_centers.py b/erpnext/patches/v14_0/set_payroll_cost_centers.py
new file mode 100644
index 0000000..89b305b
--- /dev/null
+++ b/erpnext/patches/v14_0/set_payroll_cost_centers.py
@@ -0,0 +1,32 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc('payroll', 'doctype', 'employee_cost_center')
+ frappe.reload_doc('payroll', 'doctype', 'salary_structure_assignment')
+
+ employees = frappe.get_all("Employee", fields=["department", "payroll_cost_center", "name"])
+
+ employee_cost_center = {}
+ for d in employees:
+ cost_center = d.payroll_cost_center
+ if not cost_center and d.department:
+ cost_center = frappe.get_cached_value("Department", d.department, "payroll_cost_center")
+
+ if cost_center:
+ employee_cost_center.setdefault(d.name, cost_center)
+
+ salary_structure_assignments = frappe.get_all("Salary Structure Assignment",
+ filters = {"docstatus": ["!=", 2]},
+ fields=["name", "employee"])
+
+ for d in salary_structure_assignments:
+ cost_center = employee_cost_center.get(d.employee)
+ if cost_center:
+ assignment = frappe.get_doc("Salary Structure Assignment", d.name)
+ if not assignment.get("payroll_cost_centers"):
+ assignment.append("payroll_cost_centers", {
+ "cost_center": cost_center,
+ "percentage": 100
+ })
+ assignment.save()
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/employee_cost_center/__init__.py b/erpnext/payroll/doctype/employee_cost_center/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/payroll/doctype/employee_cost_center/__init__.py
diff --git a/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.json b/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.json
new file mode 100644
index 0000000..8fed9f7
--- /dev/null
+++ b/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.json
@@ -0,0 +1,43 @@
+{
+ "actions": [],
+ "creation": "2021-12-23 12:44:38.389283",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "cost_center",
+ "percentage"
+ ],
+ "fields": [
+ {
+ "allow_on_submit": 1,
+ "fieldname": "cost_center",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Cost Center",
+ "options": "Cost Center",
+ "reqd": 1
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "percentage",
+ "fieldtype": "Int",
+ "in_list_view": 1,
+ "label": "Percentage (%)",
+ "non_negative": 1,
+ "reqd": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-12-23 17:39:03.410924",
+ "modified_by": "Administrator",
+ "module": "Payroll",
+ "name": "Employee Cost Center",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": []
+}
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.py b/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.py
new file mode 100644
index 0000000..6c5be97
--- /dev/null
+++ b/erpnext/payroll/doctype/employee_cost_center/employee_cost_center.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class EmployeeCostCenter(Document):
+ pass
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index 84c59a2..5bb32cf 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -7,6 +7,7 @@
from frappe import _
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.model.document import Document
+from frappe.query_builder.functions import Coalesce
from frappe.utils import (
DATE_FORMAT,
add_days,
@@ -157,11 +158,20 @@
Returns list of salary slips based on selected criteria
"""
- ss_list = frappe.db.sql("""
- select t1.name, t1.salary_structure, t1.payroll_cost_center from `tabSalary Slip` t1
- where t1.docstatus = %s and t1.start_date >= %s and t1.end_date <= %s and t1.payroll_entry = %s
- and (t1.journal_entry is null or t1.journal_entry = "") and ifnull(salary_slip_based_on_timesheet,0) = %s
- """, (ss_status, self.start_date, self.end_date, self.name, self.salary_slip_based_on_timesheet), as_dict=as_dict)
+ ss = frappe.qb.DocType("Salary Slip")
+ ss_list = (
+ frappe.qb.from_(ss)
+ .select(ss.name, ss.salary_structure)
+ .where(
+ (ss.docstatus == ss_status)
+ & (ss.start_date >= self.start_date)
+ & (ss.end_date <= self.end_date)
+ & (ss.payroll_entry == self.name)
+ & ((ss.journal_entry.isnull()) | (ss.journal_entry == ""))
+ & (Coalesce(ss.salary_slip_based_on_timesheet, 0) == self.salary_slip_based_on_timesheet)
+ )
+ ).run(as_dict=as_dict)
+
return ss_list
@frappe.whitelist()
@@ -190,13 +200,20 @@
def get_salary_components(self, component_type):
salary_slips = self.get_sal_slip_list(ss_status = 1, as_dict = True)
+
if salary_slips:
- salary_components = frappe.db.sql("""
- select ssd.salary_component, ssd.amount, ssd.parentfield, ss.payroll_cost_center
- from `tabSalary Slip` ss, `tabSalary Detail` ssd
- where ss.name = ssd.parent and ssd.parentfield = '%s' and ss.name in (%s)
- """ % (component_type, ', '.join(['%s']*len(salary_slips))),
- tuple([d.name for d in salary_slips]), as_dict=True)
+ ss = frappe.qb.DocType("Salary Slip")
+ ssd = frappe.qb.DocType("Salary Detail")
+ salary_components = (
+ frappe.qb.from_(ss)
+ .join(ssd)
+ .on(ss.name == ssd.parent)
+ .select(ssd.salary_component, ssd.amount, ssd.parentfield, ss.salary_structure, ss.employee)
+ .where(
+ (ssd.parentfield == component_type)
+ & (ss.name.isin(tuple([d.name for d in salary_slips])))
+ )
+ ).run(as_dict=True)
return salary_components
@@ -204,18 +221,49 @@
salary_components = self.get_salary_components(component_type)
if salary_components:
component_dict = {}
+ self.employee_cost_centers = {}
for item in salary_components:
+ employee_cost_centers = self.get_payroll_cost_centers_for_employee(item.employee, item.salary_structure)
+
add_component_to_accrual_jv_entry = True
if component_type == "earnings":
- is_flexible_benefit, only_tax_impact = frappe.db.get_value("Salary Component", item['salary_component'], ['is_flexible_benefit', 'only_tax_impact'])
+ is_flexible_benefit, only_tax_impact = \
+ frappe.get_cached_value("Salary Component",item['salary_component'], ['is_flexible_benefit', 'only_tax_impact'])
if is_flexible_benefit == 1 and only_tax_impact ==1:
add_component_to_accrual_jv_entry = False
+
if add_component_to_accrual_jv_entry:
- component_dict[(item.salary_component, item.payroll_cost_center)] \
- = component_dict.get((item.salary_component, item.payroll_cost_center), 0) + flt(item.amount)
+ for cost_center, percentage in employee_cost_centers.items():
+ amount_against_cost_center = flt(item.amount) * percentage / 100
+ component_dict[(item.salary_component, cost_center)] \
+ = component_dict.get((item.salary_component, cost_center), 0) + amount_against_cost_center
+
account_details = self.get_account(component_dict = component_dict)
return account_details
+ def get_payroll_cost_centers_for_employee(self, employee, salary_structure):
+ if not self.employee_cost_centers.get(employee):
+ ss_assignment_name = frappe.db.get_value("Salary Structure Assignment",
+ {"employee": employee, "salary_structure": salary_structure, "docstatus": 1}, 'name')
+
+ if ss_assignment_name:
+ cost_centers = dict(frappe.get_all("Employee Cost Center", {"parent": ss_assignment_name},
+ ["cost_center", "percentage"], as_list=1))
+ if not cost_centers:
+ default_cost_center, department = frappe.get_cached_value("Employee", employee, ["payroll_cost_center", "department"])
+ if not default_cost_center and department:
+ default_cost_center = frappe.get_cached_value("Department", department, "payroll_cost_center")
+ if not default_cost_center:
+ default_cost_center = self.cost_center
+
+ cost_centers = {
+ default_cost_center: 100
+ }
+
+ self.employee_cost_centers.setdefault(employee, cost_centers)
+
+ return self.employee_cost_centers.get(employee, {})
+
def get_account(self, component_dict = None):
account_dict = {}
for key, amount in component_dict.items():
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index c6f3897..4f097fa 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -120,8 +120,7 @@
employee1 = make_employee("test_employee1@example.com", payroll_cost_center="_Test Cost Center - _TC",
department="cc - _TC", company="_Test Company")
- employee2 = make_employee("test_employee2@example.com", payroll_cost_center="_Test Cost Center 2 - _TC",
- department="cc - _TC", company="_Test Company")
+ employee2 = make_employee("test_employee2@example.com", department="cc - _TC", company="_Test Company")
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
create_account(account_name="_Test Payroll Payable",
@@ -132,8 +131,26 @@
frappe.db.set_value("Company", "_Test Company", "default_payroll_payable_account",
"_Test Payroll Payable - _TC")
currency=frappe.db.get_value("Company", "_Test Company", "default_currency")
+
make_salary_structure("_Test Salary Structure 1", "Monthly", employee1, company="_Test Company", currency=currency, test_tax=False)
- make_salary_structure("_Test Salary Structure 2", "Monthly", employee2, company="_Test Company", currency=currency, test_tax=False)
+ ss = make_salary_structure("_Test Salary Structure 2", "Monthly", employee2, company="_Test Company", currency=currency, test_tax=False)
+
+ # update cost centers in salary structure assignment for employee2
+ ssa = frappe.db.get_value("Salary Structure Assignment",
+ {"employee": employee2, "salary_structure": ss.name, "docstatus": 1}, 'name')
+
+ ssa_doc = frappe.get_doc("Salary Structure Assignment", ssa)
+ ssa_doc.payroll_cost_centers = []
+ ssa_doc.append("payroll_cost_centers", {
+ "cost_center": "_Test Cost Center - _TC",
+ "percentage": 60
+ })
+ ssa_doc.append("payroll_cost_centers", {
+ "cost_center": "_Test Cost Center 2 - _TC",
+ "percentage": 40
+ })
+
+ ssa_doc.save()
dates = get_start_end_dates('Monthly', nowdate())
if not frappe.db.get_value("Salary Slip", {"start_date": dates.start_date, "end_date": dates.end_date}):
@@ -148,10 +165,10 @@
""", je)
expected_je = (
('_Test Payroll Payable - _TC', 'Main - _TC', 0.0, 155600.0),
- ('Salary - _TC', '_Test Cost Center - _TC', 78000.0, 0.0),
- ('Salary - _TC', '_Test Cost Center 2 - _TC', 78000.0, 0.0),
- ('Salary Deductions - _TC', '_Test Cost Center - _TC', 0.0, 200.0),
- ('Salary Deductions - _TC', '_Test Cost Center 2 - _TC', 0.0, 200.0)
+ ('Salary - _TC', '_Test Cost Center - _TC', 124800.0, 0.0),
+ ('Salary - _TC', '_Test Cost Center 2 - _TC', 31200.0, 0.0),
+ ('Salary Deductions - _TC', '_Test Cost Center - _TC', 0.0, 320.0),
+ ('Salary Deductions - _TC', '_Test Cost Center 2 - _TC', 0.0, 80.0)
)
self.assertEqual(je_entries, expected_je)
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.json b/erpnext/payroll/doctype/salary_slip/salary_slip.json
index 7a80e69..4e40e13 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.json
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.json
@@ -12,7 +12,6 @@
"department",
"designation",
"branch",
- "payroll_cost_center",
"column_break1",
"status",
"journal_entry",
@@ -463,15 +462,6 @@
"read_only": 1
},
{
- "fetch_from": "employee.payroll_cost_center",
- "fetch_if_empty": 1,
- "fieldname": "payroll_cost_center",
- "fieldtype": "Link",
- "label": "Payroll Cost Center",
- "options": "Cost Center",
- "read_only": 1
- },
- {
"fieldname": "mode_of_payment",
"fieldtype": "Select",
"label": "Mode Of Payment",
@@ -647,7 +637,7 @@
"idx": 9,
"is_submittable": 1,
"links": [],
- "modified": "2021-10-08 11:47:47.098248",
+ "modified": "2021-12-23 11:47:47.098248",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Slip",
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 3052a2b..6e8fae0 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -171,6 +171,7 @@
salary_slip.end_date = month_end_date
salary_slip.save()
salary_slip.submit()
+ salary_slip.reload()
no_of_days = self.get_no_of_days()
days_in_month = no_of_days[0]
diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.py b/erpnext/payroll/doctype/salary_structure/salary_structure.py
index ae83c04..4cbf948 100644
--- a/erpnext/payroll/doctype/salary_structure/salary_structure.py
+++ b/erpnext/payroll/doctype/salary_structure/salary_structure.py
@@ -167,15 +167,12 @@
def postprocess(source, target):
if employee:
employee_details = frappe.db.get_value("Employee", employee,
- ["employee_name", "branch", "designation", "department", "payroll_cost_center"], as_dict=1)
+ ["employee_name", "branch", "designation", "department"], as_dict=1)
target.employee = employee
target.employee_name = employee_details.employee_name
target.branch = employee_details.branch
target.designation = employee_details.designation
target.department = employee_details.department
- target.payroll_cost_center = employee_details.payroll_cost_center
- if not target.payroll_cost_center and target.department:
- target.payroll_cost_center = frappe.db.get_value("Department", target.department, "payroll_cost_center")
target.run_method('process_salary_structure', for_preview=for_preview)
diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js
index 6cd897e..220bfbf 100644
--- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js
+++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.js
@@ -40,28 +40,29 @@
}
}
});
+
+ frm.set_query("cost_center", "payroll_cost_centers", function() {
+ return {
+ filters: {
+ "company": frm.doc.company,
+ "is_group": 0
+ }
+ };
+ });
},
employee: function(frm) {
- if(frm.doc.employee){
+ if (frm.doc.employee) {
frappe.call({
- method: "frappe.client.get_value",
- args:{
- doctype: "Employee",
- fieldname: "company",
- filters:{
- name: frm.doc.employee
- }
- },
+ method: "set_payroll_cost_centers",
+ doc: frm.doc,
callback: function(data) {
- if(data.message){
- frm.set_value("company", data.message.company);
- }
+ refresh_field("payroll_cost_centers");
}
});
}
- else{
- frm.set_value("company", null);
+ else {
+ frm.set_value("payroll_cost_centers", []);
}
},
diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
index c8b98e5..197ab5f 100644
--- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
+++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.json
@@ -22,7 +22,9 @@
"base",
"column_break_9",
"variable",
- "amended_from"
+ "amended_from",
+ "section_break_17",
+ "payroll_cost_centers"
],
"fields": [
{
@@ -90,7 +92,8 @@
},
{
"fieldname": "section_break_7",
- "fieldtype": "Section Break"
+ "fieldtype": "Section Break",
+ "label": "Base & Variable"
},
{
"fieldname": "base",
@@ -141,14 +144,29 @@
"fieldtype": "Link",
"label": "Payroll Payable Account",
"options": "Account"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "employee",
+ "fieldname": "section_break_17",
+ "fieldtype": "Section Break",
+ "label": "Payroll Cost Centers"
+ },
+ {
+ "allow_on_submit": 1,
+ "fieldname": "payroll_cost_centers",
+ "fieldtype": "Table",
+ "label": "Cost Centers",
+ "options": "Employee Cost Center"
}
],
"is_submittable": 1,
"links": [],
- "modified": "2021-03-31 22:44:46.267974",
+ "modified": "2021-12-23 17:28:09.794444",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Structure Assignment",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -193,6 +211,7 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "employee_name",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
index e1ff9ca..8359478 100644
--- a/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
+++ b/erpnext/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py
@@ -5,7 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import getdate
+from frappe.utils import flt, getdate
class DuplicateAssignment(frappe.ValidationError): pass
@@ -15,6 +15,10 @@
self.validate_dates()
self.validate_income_tax_slab()
self.set_payroll_payable_account()
+ if not self.get("payroll_cost_centers"):
+ self.set_payroll_cost_centers()
+
+ self.validate_cost_center_distribution()
def validate_dates(self):
joining_date, relieving_date = frappe.db.get_value("Employee", self.employee,
@@ -51,6 +55,30 @@
"Company", self.company, "default_currency"), "is_group": 0})
self.payroll_payable_account = payroll_payable_account
+ @frappe.whitelist()
+ def set_payroll_cost_centers(self):
+ self.payroll_cost_centers = []
+ default_payroll_cost_center = self.get_payroll_cost_center()
+ if default_payroll_cost_center:
+ self.append("payroll_cost_centers", {
+ "cost_center": default_payroll_cost_center,
+ "percentage": 100
+ })
+
+ def get_payroll_cost_center(self):
+ payroll_cost_center = frappe.db.get_value("Employee", self.employee, "payroll_cost_center")
+ if not payroll_cost_center and self.department:
+ payroll_cost_center = frappe.db.get_value("Department", self.department, "payroll_cost_center")
+
+ return payroll_cost_center
+
+ def validate_cost_center_distribution(self):
+ if self.get("payroll_cost_centers"):
+ total_percentage = sum([flt(d.percentage) for d in self.get("payroll_cost_centers", [])])
+ if total_percentage != 100:
+ frappe.throw(_("Total percentage against cost centers should be 100"))
+
+
def get_assigned_salary_structure(employee, on_date):
if not employee or not on_date:
return None
@@ -64,6 +92,7 @@
})
return salary_structure[0][0] if salary_structure else None
+
@frappe.whitelist()
def get_employee_currency(employee):
employee_currency = frappe.db.get_value('Salary Structure Assignment', {'employee': employee}, 'currency')
diff --git a/erpnext/projects/doctype/project/project.js b/erpnext/projects/doctype/project/project.js
index 31460f6..4f19bbd 100644
--- a/erpnext/projects/doctype/project/project.js
+++ b/erpnext/projects/doctype/project/project.js
@@ -59,22 +59,16 @@
frm.trigger('show_dashboard');
}
- frm.events.set_buttons(frm);
+ frm.trigger("set_custom_buttons");
},
- set_buttons: function(frm) {
+ set_custom_buttons: function(frm) {
if (!frm.is_new()) {
frm.add_custom_button(__('Duplicate Project with Tasks'), () => {
frm.events.create_duplicate(frm);
- });
+ }, __("Actions"));
- frm.add_custom_button(__('Completed'), () => {
- frm.events.set_status(frm, 'Completed');
- }, __('Set Status'));
-
- frm.add_custom_button(__('Cancelled'), () => {
- frm.events.set_status(frm, 'Cancelled');
- }, __('Set Status'));
+ frm.trigger("set_project_status_button");
if (frappe.model.can_read("Task")) {
@@ -83,7 +77,7 @@
"project": frm.doc.name
};
frappe.set_route("List", "Task", "Gantt");
- });
+ }, __("View"));
frm.add_custom_button(__("Kanban Board"), () => {
frappe.call('erpnext.projects.doctype.project.project.create_kanban_board_if_not_exists', {
@@ -91,13 +85,35 @@
}).then(() => {
frappe.set_route('List', 'Task', 'Kanban', frm.doc.project_name);
});
- });
+ }, __("View"));
}
}
},
+ set_project_status_button: function(frm) {
+ frm.add_custom_button(__('Set Project Status'), () => {
+ let d = new frappe.ui.Dialog({
+ "title": __("Set Project Status"),
+ "fields": [
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "label": "Status",
+ "reqd": 1,
+ "options": "Completed\nCancelled",
+ },
+ ],
+ primary_action: function() {
+ frm.events.set_status(frm, d.get_values().status);
+ d.hide();
+ },
+ primary_action_label: __("Set Project Status")
+ }).show();
+ }, __("Actions"));
+ },
+
create_duplicate: function(frm) {
return new Promise(resolve => {
frappe.prompt('Project Name', (data) => {
@@ -117,7 +133,9 @@
set_status: function(frm, status) {
frappe.confirm(__('Set Project and all Tasks to status {0}?', [status.bold()]), () => {
frappe.xcall('erpnext.projects.doctype.project.project.set_project_status',
- {project: frm.doc.name, status: status}).then(() => { /* page will auto reload */ });
+ {project: frm.doc.name, status: status}).then(() => {
+ frm.reload_doc();
+ });
});
},
diff --git a/erpnext/projects/report/project_profitability/test_project_profitability.py b/erpnext/projects/report/project_profitability/test_project_profitability.py
index 0415690..1eb3d0d 100644
--- a/erpnext/projects/report/project_profitability/test_project_profitability.py
+++ b/erpnext/projects/report/project_profitability/test_project_profitability.py
@@ -25,6 +25,7 @@
self.timesheet = make_timesheet(emp, is_billable=1)
self.salary_slip = make_salary_slip(self.timesheet.name)
+ self.salary_slip.start_date = self.timesheet.start_date
holidays = self.salary_slip.get_holidays_for_employee(date, date)
if holidays:
@@ -41,8 +42,8 @@
def test_project_profitability(self):
filters = {
'company': '_Test Company',
- 'start_date': add_days(getdate(), -3),
- 'end_date': getdate()
+ 'start_date': add_days(self.timesheet.start_date, -3),
+ 'end_date': self.timesheet.start_date
}
report = execute(filters)
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 773d53c..3791741 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -680,7 +680,7 @@
var item = frappe.get_doc(cdt, cdn);
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]);
- // check if child doctype is Sales Order Item/Qutation Item and calculate the rate
+ // check if child doctype is Sales Order Item/Quotation Item and calculate the rate
if (in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Purchase Receipt Item"]), cdt)
this.apply_pricing_rule_on_item(item);
else
@@ -1582,25 +1582,27 @@
_set_values_for_item_list(children) {
var me = this;
- var price_list_rate_changed = false;
var items_rule_dict = {};
for(var i=0, l=children.length; i<l; i++) {
- var d = children[i];
+ var d = children[i] ;
+ let item_row = frappe.get_doc(d.doctype, d.name);
var existing_pricing_rule = frappe.model.get_value(d.doctype, d.name, "pricing_rules");
for(var k in d) {
var v = d[k];
if (["doctype", "name"].indexOf(k)===-1) {
if(k=="price_list_rate") {
- if(flt(v) != flt(d.price_list_rate)) price_list_rate_changed = true;
+ item_row['rate'] = v;
}
if (k !== 'free_item_data') {
- frappe.model.set_value(d.doctype, d.name, k, v);
+ item_row[k] = v;
}
}
}
+ frappe.model.round_floats_in(item_row, ["price_list_rate", "discount_percentage"]);
+
// if pricing rule set as blank from an existing value, apply price_list
if(!me.frm.doc.ignore_pricing_rule && existing_pricing_rule && !d.pricing_rules) {
me.apply_price_list(frappe.get_doc(d.doctype, d.name));
@@ -1617,9 +1619,10 @@
}
}
+ me.frm.refresh_field('items');
me.apply_rule_on_other_items(items_rule_dict);
- if(!price_list_rate_changed) me.calculate_taxes_and_totals();
+ me.calculate_taxes_and_totals();
}
apply_rule_on_other_items(args) {
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index 5865424..c0dcb70 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -277,8 +277,10 @@
inter_state_gst_field = [
dict(fieldname='is_inter_state', label='Is Inter State',
fieldtype='Check', insert_after='disabled', print_hide=1),
+ dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check',
+ insert_after='is_inter_state', print_hide=1),
dict(fieldname='tax_category_column_break', fieldtype='Column Break',
- insert_after='is_inter_state'),
+ insert_after='is_reverse_charge'),
dict(fieldname='gst_state', label='Source State', fieldtype='Select',
options='\n'.join(states), insert_after='company')
]
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index fd3ec3c..215b483 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -67,7 +67,8 @@
frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN."))
def validate_tax_category(doc, method):
- if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state}):
+ if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state,
+ 'is_reverse_charge': doc.is_reverse_charge}):
if doc.is_inter_state:
frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state))
else:
@@ -264,7 +265,7 @@
def get_tax_template(master_doctype, company, is_inter_state, state_code):
tax_categories = frappe.get_all('Tax Category', fields = ['name', 'is_inter_state', 'gst_state'],
- filters = {'is_inter_state': is_inter_state})
+ filters = {'is_inter_state': is_inter_state, 'is_reverse_charge': 0})
default_tax = ''
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index 0c8c53a..b7f74df 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -196,20 +196,19 @@
if not lead.lead_name:
frappe.throw(_("Please mention the Lead Name in Lead {0}").format(self.lead_name))
- if lead.company_name:
- contact_names = frappe.get_all('Dynamic Link', filters={
- "parenttype":"Contact",
- "link_doctype":"Lead",
- "link_name":self.lead_name
- }, fields=["parent as name"])
+ contact_names = frappe.get_all('Dynamic Link', filters={
+ "parenttype":"Contact",
+ "link_doctype":"Lead",
+ "link_name":self.lead_name
+ }, fields=["parent as name"])
- for contact_name in contact_names:
- contact = frappe.get_doc('Contact', contact_name.get('name'))
- if not contact.has_link('Customer', self.name):
- contact.append('links', dict(link_doctype='Customer', link_name=self.name))
- contact.save(ignore_permissions=self.flags.ignore_permissions)
+ for contact_name in contact_names:
+ contact = frappe.get_doc('Contact', contact_name.get('name'))
+ if not contact.has_link('Customer', self.name):
+ contact.append('links', dict(link_doctype='Customer', link_name=self.name))
+ contact.save(ignore_permissions=self.flags.ignore_permissions)
- else:
+ if not contact_names:
lead.lead_name = lead.lead_name.lstrip().split(" ")
lead.first_name = lead.lead_name[0]
lead.last_name = " ".join(lead.lead_name[1:])
diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json
index 14b7951..91e8eff 100644
--- a/erpnext/setup/setup_wizard/data/country_wise_tax.json
+++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json
@@ -1178,11 +1178,13 @@
{
"title": "Reverse Charge In-State",
"is_inter_state": 0,
+ "is_reverse_charge": 1,
"gst_state": ""
},
{
"title": "Reverse Charge Out-State",
"is_inter_state": 1,
+ "is_reverse_charge": 1,
"gst_state": ""
},
{
diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json
index a3d44af..6e1e0d4 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.json
+++ b/erpnext/stock/doctype/serial_no/serial_no.json
@@ -1,7 +1,6 @@
{
"actions": [],
"allow_import": 1,
- "allow_rename": 1,
"autoname": "field:serial_no",
"creation": "2013-05-16 10:59:15",
"description": "Distinct unit of an Item",
@@ -434,10 +433,11 @@
"icon": "fa fa-barcode",
"idx": 1,
"links": [],
- "modified": "2021-01-08 14:31:15.375996",
+ "modified": "2021-12-23 10:44:30.299450",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial No",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -476,5 +476,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py
index 38291d1..2947faf 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.py
+++ b/erpnext/stock/doctype/serial_no/serial_no.py
@@ -194,23 +194,6 @@
if sle_exists:
frappe.throw(_("Cannot delete Serial No {0}, as it is used in stock transactions").format(self.name))
- def before_rename(self, old, new, merge=False):
- if merge:
- frappe.throw(_("Sorry, Serial Nos cannot be merged"))
-
- def after_rename(self, old, new, merge=False):
- """rename serial_no text fields"""
- for dt in frappe.db.sql("""select parent from tabDocField
- where fieldname='serial_no' and fieldtype in ('Text', 'Small Text', 'Long Text')"""):
-
- for item in frappe.db.sql("""select name, serial_no from `tab%s`
- where serial_no like %s""" % (dt[0], frappe.db.escape('%' + old + '%'))):
-
- serial_nos = map(lambda i: new if i.upper()==old.upper() else i, item[1].split('\n'))
- frappe.db.sql("""update `tab%s` set serial_no = %s
- where name=%s""" % (dt[0], '%s', '%s'),
- ('\n'.join(list(serial_nos)), item[0]))
-
def update_serial_no_reference(self, serial_no=None):
last_sle = self.get_last_sle(serial_no)
self.set_purchase_details(last_sle.get("purchase_sle"))
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index a00d63e..93e303c 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -35,10 +35,16 @@
from erpnext.stock.utils import get_bin, get_incoming_rate
-class IncorrectValuationRateError(frappe.ValidationError): pass
-class DuplicateEntryForWorkOrderError(frappe.ValidationError): pass
-class OperationsNotCompleteError(frappe.ValidationError): pass
-class MaxSampleAlreadyRetainedError(frappe.ValidationError): pass
+class FinishedGoodError(frappe.ValidationError):
+ pass
+class IncorrectValuationRateError(frappe.ValidationError):
+ pass
+class DuplicateEntryForWorkOrderError(frappe.ValidationError):
+ pass
+class OperationsNotCompleteError(frappe.ValidationError):
+ pass
+class MaxSampleAlreadyRetainedError(frappe.ValidationError):
+ pass
from erpnext.controllers.stock_controller import StockController
@@ -701,6 +707,11 @@
finished_item = self.get_finished_item()
+ if not finished_item and self.purpose == "Manufacture":
+ # In case of independent Manufacture entry, don't auto set
+ # user must decide and set
+ return
+
for d in self.items:
if d.t_warehouse and not d.s_warehouse:
if self.purpose=="Repack" or d.item_code == finished_item:
@@ -721,38 +732,64 @@
return finished_item
def validate_finished_goods(self):
- """validation: finished good quantity should be same as manufacturing quantity"""
- if not self.work_order: return
+ """
+ 1. Check if FG exists
+ 2. Check if Multiple FG Items are present
+ 3. Check FG Item and Qty against WO if present
+ """
+ production_item, wo_qty, finished_items = None, 0, []
- production_item, wo_qty = frappe.db.get_value("Work Order",
- self.work_order, ["production_item", "qty"])
+ wo_details = frappe.db.get_value(
+ "Work Order", self.work_order, ["production_item", "qty"]
+ )
+ if wo_details:
+ production_item, wo_qty = wo_details
- finished_items = []
for d in self.get('items'):
if d.is_finished_item:
+ if not self.work_order:
+ finished_items.append(d.item_code)
+ continue # Independent Manufacture Entry, no WO to match against
+
if d.item_code != production_item:
frappe.throw(_("Finished Item {0} does not match with Work Order {1}")
- .format(d.item_code, self.work_order))
+ .format(d.item_code, self.work_order)
+ )
elif flt(d.transfer_qty) > flt(self.fg_completed_qty):
- frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \
- format(d.idx, d.transfer_qty, self.fg_completed_qty))
+ frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}")
+ .format(d.idx, d.transfer_qty, self.fg_completed_qty)
+ )
+
finished_items.append(d.item_code)
if len(set(finished_items)) > 1:
- frappe.throw(_("Multiple items cannot be marked as finished item"))
+ frappe.throw(
+ msg=_("Multiple items cannot be marked as finished item"),
+ title=_("Note"),
+ exc=FinishedGoodError
+ )
if self.purpose == "Manufacture":
if not finished_items:
- frappe.throw(_('Finished Good has not set in the stock entry {0}')
- .format(self.name))
+ frappe.throw(
+ msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name),
+ title=_("Missing Finished Good"),
+ exc=FinishedGoodError
+ )
- allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings",
- "overproduction_percentage_for_work_order"))
+ allowance_percentage = flt(
+ frappe.db.get_single_value(
+ "Manufacturing Settings","overproduction_percentage_for_work_order"
+ )
+ )
+ allowed_qty = wo_qty + ((allowance_percentage/100) * wo_qty)
- allowed_qty = wo_qty + (allowance_percentage/100 * wo_qty)
- if self.fg_completed_qty > allowed_qty:
- frappe.throw(_("For quantity {0} should not be greater than work order quantity {1}")
- .format(flt(self.fg_completed_qty), wo_qty))
+ # No work order could mean independent Manufacture entry, if so skip validation
+ if self.work_order and self.fg_completed_qty > allowed_qty:
+ frappe.throw(
+ _("For quantity {0} should not be greater than work order quantity {1}")
+ .format(flt(self.fg_completed_qty), wo_qty)
+ )
def update_stock_ledger(self):
sl_entries = []
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 5a9e77e..b874874 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -15,7 +15,10 @@
set_item_variant_settings,
)
from erpnext.stock.doctype.serial_no.serial_no import * # noqa
-from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse
+from erpnext.stock.doctype.stock_entry.stock_entry import (
+ FinishedGoodError,
+ move_sample_to_retention_warehouse,
+)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
@@ -929,6 +932,38 @@
distributed_costs = [d.additional_cost for d in se.items]
self.assertEqual([40.0, 60.0], distributed_costs)
+ def test_independent_manufacture_entry(self):
+ "Test FG items and incoming rate calculation in Maniufacture Entry without WO or BOM linked."
+ se = frappe.get_doc(
+ doctype="Stock Entry",
+ purpose="Manufacture",
+ stock_entry_type="Manufacture",
+ company="_Test Company",
+ items=[
+ frappe._dict(item_code="_Test Item", qty=1, basic_rate=200, s_warehouse="_Test Warehouse - _TC"),
+ frappe._dict(item_code="_Test FG Item", qty=4, t_warehouse="_Test Warehouse 1 - _TC")
+ ]
+ )
+ # SE must have atleast one FG
+ self.assertRaises(FinishedGoodError, se.save)
+
+ se.items[0].is_finished_item = 1
+ se.items[1].is_finished_item = 1
+ # SE cannot have multiple FGs
+ self.assertRaises(FinishedGoodError, se.save)
+
+ se.items[0].is_finished_item = 0
+ se.save()
+
+ # Check if FG cost is calculated based on RM total cost
+ # RM total cost = 200, FG rate = 200/4(FG qty) = 50
+ self.assertEqual(se.items[1].basic_rate, 50)
+ self.assertEqual(se.value_difference, 0.0)
+ self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
+
+ # teardown
+ se.delete()
+
@change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_future_negative_sle(self):
# Initialize item, batch, warehouse, opening qty
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 9889a22..06f8fa7 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -1097,7 +1097,7 @@
}
def apply_price_list_on_item(args):
- item_doc = frappe.get_doc("Item", args.item_code)
+ item_doc = frappe.db.get_value("Item", args.item_code, ['name', 'variant_of'], as_dict=1)
item_details = get_price_list_rate(args, item_doc)
item_details.update(get_pricing_rule_for_item(args, item_details.price_list_rate))
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py
index 0ebe4f9..e6dfc97 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/stock_ageing.py
@@ -3,6 +3,7 @@
from operator import itemgetter
+from typing import Dict, List, Tuple, Union
import frappe
from frappe import _
@@ -10,19 +11,29 @@
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
+Filters = frappe._dict
-def execute(filters=None):
- columns = get_columns(filters)
- item_details = get_fifo_queue(filters)
+def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"]
- _func = itemgetter(1)
+ columns = get_columns(filters)
+ item_details = FIFOSlots(filters).generate()
+ data = format_report_data(filters, item_details, to_date)
+
+ chart_data = get_chart_data(data, filters)
+
+ return columns, data, None, chart_data
+
+def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> List[Dict]:
+ "Returns ordered, formatted data with ranges."
+ _func = itemgetter(1)
data = []
+
for item, item_dict in item_details.items():
earliest_age, latest_age = 0, 0
+ details = item_dict["details"]
fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func)
- details = item_dict["details"]
if not fifo_queue: continue
@@ -31,23 +42,22 @@
latest_age = date_diff(to_date, fifo_queue[-1][1])
range1, range2, range3, above_range3 = get_range_age(filters, fifo_queue, to_date, item_dict)
- row = [details.name, details.item_name,
- details.description, details.item_group, details.brand]
+ row = [details.name, details.item_name, details.description,
+ details.item_group, details.brand]
if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse)
row.extend([item_dict.get("total_qty"), average_age,
range1, range2, range3, above_range3,
- earliest_age, latest_age, details.stock_uom])
+ earliest_age, latest_age,
+ details.stock_uom])
data.append(row)
- chart_data = get_chart_data(data, filters)
+ return data
- return columns, data, None, chart_data
-
-def get_average_age(fifo_queue, to_date):
+def get_average_age(fifo_queue: List, to_date: str) -> float:
batch_age = age_qty = total_qty = 0.0
for batch in fifo_queue:
batch_age = date_diff(to_date, batch[1])
@@ -61,7 +71,7 @@
return flt(age_qty / total_qty, 2) if total_qty else 0.0
-def get_range_age(filters, fifo_queue, to_date, item_dict):
+def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple:
range1 = range2 = range3 = above_range3 = 0.0
for item in fifo_queue:
@@ -79,7 +89,7 @@
return range1, range2, range3, above_range3
-def get_columns(filters):
+def get_columns(filters: Filters) -> List[Dict]:
range_columns = []
setup_ageing_columns(filters, range_columns)
columns = [
@@ -164,106 +174,7 @@
return columns
-def get_fifo_queue(filters, sle=None):
- item_details = {}
- transferred_item_details = {}
- serial_no_batch_purchase_details = {}
-
- if sle == None:
- sle = get_stock_ledger_entries(filters)
-
- for d in sle:
- key = (d.name, d.warehouse) if filters.get('show_warehouse_wise_stock') else d.name
- item_details.setdefault(key, {"details": d, "fifo_queue": []})
- fifo_queue = item_details[key]["fifo_queue"]
-
- transferred_item_key = (d.voucher_no, d.name, d.warehouse)
- transferred_item_details.setdefault(transferred_item_key, [])
-
- if d.voucher_type == "Stock Reconciliation":
- d.actual_qty = flt(d.qty_after_transaction) - flt(item_details[key].get("qty_after_transaction", 0))
-
- serial_no_list = get_serial_nos(d.serial_no) if d.serial_no else []
-
- if d.actual_qty > 0:
- if transferred_item_details.get(transferred_item_key):
- batch = transferred_item_details[transferred_item_key][0]
- fifo_queue.append(batch)
- transferred_item_details[transferred_item_key].pop(0)
- else:
- if serial_no_list:
- for serial_no in serial_no_list:
- if serial_no_batch_purchase_details.get(serial_no):
- fifo_queue.append([serial_no, serial_no_batch_purchase_details.get(serial_no)])
- else:
- serial_no_batch_purchase_details.setdefault(serial_no, d.posting_date)
- fifo_queue.append([serial_no, d.posting_date])
- else:
- fifo_queue.append([d.actual_qty, d.posting_date])
- else:
- if serial_no_list:
- fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_no_list]
- else:
- qty_to_pop = abs(d.actual_qty)
- while qty_to_pop:
- batch = fifo_queue[0] if fifo_queue else [0, None]
- if 0 < flt(batch[0]) <= qty_to_pop:
- # if batch qty > 0
- # not enough or exactly same qty in current batch, clear batch
- qty_to_pop -= flt(batch[0])
- transferred_item_details[transferred_item_key].append(fifo_queue.pop(0))
- else:
- # all from current batch
- batch[0] = flt(batch[0]) - qty_to_pop
- transferred_item_details[transferred_item_key].append([qty_to_pop, batch[1]])
- qty_to_pop = 0
-
- item_details[key]["qty_after_transaction"] = d.qty_after_transaction
-
- if "total_qty" not in item_details[key]:
- item_details[key]["total_qty"] = d.actual_qty
- else:
- item_details[key]["total_qty"] += d.actual_qty
-
- item_details[key]["has_serial_no"] = d.has_serial_no
-
- return item_details
-
-def get_stock_ledger_entries(filters):
- return frappe.db.sql("""select
- item.name, item.item_name, item_group, brand, description, item.stock_uom, item.has_serial_no,
- actual_qty, posting_date, voucher_type, voucher_no, serial_no, batch_no, qty_after_transaction, warehouse
- from `tabStock Ledger Entry` sle,
- (select name, item_name, description, stock_uom, brand, item_group, has_serial_no
- from `tabItem` {item_conditions}) item
- where item_code = item.name and
- company = %(company)s and
- posting_date <= %(to_date)s and
- is_cancelled != 1
- {sle_conditions}
- order by posting_date, posting_time, sle.creation, actual_qty""" #nosec
- .format(item_conditions=get_item_conditions(filters),
- sle_conditions=get_sle_conditions(filters)), filters, as_dict=True)
-
-def get_item_conditions(filters):
- conditions = []
- if filters.get("item_code"):
- conditions.append("item_code=%(item_code)s")
- if filters.get("brand"):
- conditions.append("brand=%(brand)s")
-
- return "where {}".format(" and ".join(conditions)) if conditions else ""
-
-def get_sle_conditions(filters):
- conditions = []
- if filters.get("warehouse"):
- lft, rgt = frappe.db.get_value('Warehouse', filters.get("warehouse"), ['lft', 'rgt'])
- conditions.append("""warehouse in (select wh.name from `tabWarehouse` wh
- where wh.lft >= {0} and rgt <= {1})""".format(lft, rgt))
-
- return "and {}".format(" and ".join(conditions)) if conditions else ""
-
-def get_chart_data(data, filters):
+def get_chart_data(data: List, filters: Filters) -> Dict:
if not data:
return []
@@ -294,17 +205,201 @@
"type" : "bar"
}
-def setup_ageing_columns(filters, range_columns):
- for i, label in enumerate(["0-{range1}".format(range1=filters["range1"]),
- "{range1}-{range2}".format(range1=cint(filters["range1"])+ 1, range2=filters["range2"]),
- "{range2}-{range3}".format(range2=cint(filters["range2"])+ 1, range3=filters["range3"]),
- "{range3}-{above}".format(range3=cint(filters["range3"])+ 1, above=_("Above"))]):
- add_column(range_columns, label="Age ("+ label +")", fieldname='range' + str(i+1))
+def setup_ageing_columns(filters: Filters, range_columns: List):
+ ranges = [
+ f"0 - {filters['range1']}",
+ f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}",
+ f"{cint(filters['range2']) + 1} - {cint(filters['range3'])}",
+ f"{cint(filters['range3']) + 1} - {_('Above')}"
+ ]
+ for i, label in enumerate(ranges):
+ fieldname = 'range' + str(i+1)
+ add_column(range_columns, label=f"Age ({label})",fieldname=fieldname)
-def add_column(range_columns, label, fieldname, fieldtype='Float', width=140):
+def add_column(range_columns: List, label:str, fieldname: str, fieldtype: str = 'Float', width: int = 140):
range_columns.append(dict(
label=label,
fieldname=fieldname,
fieldtype=fieldtype,
width=width
))
+
+
+class FIFOSlots:
+ "Returns FIFO computed slots of inwarded stock as per date."
+
+ def __init__(self, filters: Dict = None , sle: List = None):
+ self.item_details = {}
+ self.transferred_item_details = {}
+ self.serial_no_batch_purchase_details = {}
+ self.filters = filters
+ self.sle = sle
+
+ def generate(self) -> Dict:
+ """
+ Returns dict of the foll.g structure:
+ Key = Item A / (Item A, Warehouse A)
+ Key: {
+ 'details' -> Dict: ** item details **,
+ 'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock,
+ consumed/updated and maintained via FIFO. **
+ }
+ """
+ if self.sle is None:
+ self.sle = self.__get_stock_ledger_entries()
+
+ for d in self.sle:
+ key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
+
+ if d.voucher_type == "Stock Reconciliation":
+ prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
+ d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
+
+ serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
+
+ if d.actual_qty > 0:
+ self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
+ else:
+ self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos)
+
+ self.__update_balances(d, key)
+
+ return self.item_details
+
+ def __init_key_stores(self, row: Dict) -> Tuple:
+ "Initialise keys and FIFO Queue."
+
+ key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
+ self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
+ fifo_queue = self.item_details[key]["fifo_queue"]
+
+ transferred_item_key = (row.voucher_no, row.name, row.warehouse)
+ self.transferred_item_details.setdefault(transferred_item_key, [])
+
+ return key, fifo_queue, transferred_item_key
+
+ def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
+ "Update FIFO Queue on inward stock."
+
+ if self.transferred_item_details.get(transfer_key):
+ # inward/outward from same voucher, item & warehouse
+ slot = self.transferred_item_details[transfer_key].pop(0)
+ fifo_queue.append(slot)
+ else:
+ if not serial_nos:
+ if fifo_queue and flt(fifo_queue[0][0]) < 0:
+ # neutralize negative stock by adding positive stock
+ fifo_queue[0][0] += flt(row.actual_qty)
+ fifo_queue[0][1] = row.posting_date
+ else:
+ fifo_queue.append([flt(row.actual_qty), row.posting_date])
+ return
+
+ for serial_no in serial_nos:
+ if self.serial_no_batch_purchase_details.get(serial_no):
+ fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)])
+ else:
+ self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
+ fifo_queue.append([serial_no, row.posting_date])
+
+ def __compute_outgoing_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
+ "Update FIFO Queue on outward stock."
+ if serial_nos:
+ fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos]
+ return
+
+ qty_to_pop = abs(row.actual_qty)
+ while qty_to_pop:
+ slot = fifo_queue[0] if fifo_queue else [0, None]
+ if 0 < flt(slot[0]) <= qty_to_pop:
+ # qty to pop >= slot qty
+ # if +ve and not enough or exactly same balance in current slot, consume whole slot
+ qty_to_pop -= flt(slot[0])
+ self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
+ elif not fifo_queue:
+ # negative stock, no balance but qty yet to consume
+ fifo_queue.append([-(qty_to_pop), row.posting_date])
+ self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
+ qty_to_pop = 0
+ else:
+ # qty to pop < slot qty, ample balance
+ # consume actual_qty from first slot
+ slot[0] = flt(slot[0]) - qty_to_pop
+ self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
+ qty_to_pop = 0
+
+ def __update_balances(self, row: Dict, key: Union[Tuple, str]):
+ self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
+
+ if "total_qty" not in self.item_details[key]:
+ self.item_details[key]["total_qty"] = row.actual_qty
+ else:
+ self.item_details[key]["total_qty"] += row.actual_qty
+
+ self.item_details[key]["has_serial_no"] = row.has_serial_no
+
+ def __get_stock_ledger_entries(self) -> List[Dict]:
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+ item = self.__get_item_query() # used as derived table in sle query
+
+ sle_query = (
+ frappe.qb.from_(sle).from_(item)
+ .select(
+ item.name, item.item_name, item.item_group,
+ item.brand, item.description,
+ item.stock_uom, item.has_serial_no,
+ sle.actual_qty, sle.posting_date,
+ sle.voucher_type, sle.voucher_no,
+ sle.serial_no, sle.batch_no,
+ sle.qty_after_transaction, sle.warehouse
+ ).where(
+ (sle.item_code == item.name)
+ & (sle.company == self.filters.get("company"))
+ & (sle.posting_date <= self.filters.get("to_date"))
+ & (sle.is_cancelled != 1)
+ )
+ )
+
+ if self.filters.get("warehouse"):
+ sle_query = self.__get_warehouse_conditions(sle, sle_query)
+
+ sle_query = sle_query.orderby(
+ sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty
+ )
+
+ return sle_query.run(as_dict=True)
+
+ def __get_item_query(self) -> str:
+ item_table = frappe.qb.DocType("Item")
+
+ item = frappe.qb.from_("Item").select(
+ "name", "item_name", "description", "stock_uom",
+ "brand", "item_group", "has_serial_no"
+ )
+
+ if self.filters.get("item_code"):
+ item = item.where(item_table.item_code == self.filters.get("item_code"))
+
+ if self.filters.get("brand"):
+ item = item.where(item_table.brand == self.filters.get("brand"))
+
+ return item
+
+ def __get_warehouse_conditions(self, sle, sle_query) -> str:
+ warehouse = frappe.qb.DocType("Warehouse")
+ lft, rgt = frappe.db.get_value(
+ "Warehouse",
+ self.filters.get("warehouse"),
+ ['lft', 'rgt']
+ )
+
+ warehouse_results = (
+ frappe.qb.from_(warehouse)
+ .select("name").where(
+ (warehouse.lft >= lft)
+ & (warehouse.rgt <= rgt)
+ ).run()
+ )
+ warehouse_results = [x[0] for x in warehouse_results]
+
+ return sle_query.where(sle.warehouse.isin(warehouse_results))
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
new file mode 100644
index 0000000..5ffe97f
--- /dev/null
+++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
@@ -0,0 +1,73 @@
+### Concept of FIFO Slots
+
+Since we need to know age-wise remaining stock, we maintain all the inward entries as slots. So each time stock comes in, a slot is added for the same.
+
+Eg. For Item A:
+----------------------
+Date | Qty | Queue
+----------------------
+1st | +50 | [[50, 1-12-2021]]
+2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
+----------------------
+
+Now the queue can tell us the total stock and also how old the stock is.
+Here, the balance qty is 70.
+50 qty is (today-the 1st) days old
+20 qty is (today-the 2nd) days old
+
+### Calculation of FIFO Slots
+
+#### Case 1: Outward from sufficient balance qty
+----------------------
+Date | Qty | Queue
+----------------------
+1st | +50 | [[50, 1-12-2021]]
+2nd | -20 | [[30, 1-12-2021]]
+2nd | +20 | [[30, 1-12-2021], [20, 2-12-2021]]
+
+Here after the first entry, while issuing 20 qty:
+- **since 20 is lesser than the balance**, **qty_to_pop (20)** is simply consumed from first slot (FIFO consumption)
+- Any inward entry after as usual will get its own slot added to the queue
+
+#### Case 2: Outward from sufficient cumulative (slots) balance qty
+----------------------
+Date | Qty | Queue
+----------------------
+1st | +50 | [[50, 1-12-2021]]
+2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
+2nd | -60 | [[10, 2-12-2021]]
+
+- Consumption happens slot wise. First slot 1 is consumed
+- Since **qty_to_pop (60) is greater than slot 1 qty (50)**, the entire slot is consumed and popped
+- Now the queue is [[20, 2-12-2021]], and **qty_to_pop=10** (remaining qty to pop)
+- It then goes ahead to the next slot and consumes 10 from it
+- Now the queue is [[10, 2-12-2021]]
+
+#### Case 3: Outward from insufficient balance qty
+> This case is possible only if **Allow Negative Stock** was enabled at some point/is enabled.
+
+----------------------
+Date | Qty | Queue
+----------------------
+1st | +50 | [[50, 1-12-2021]]
+2nd | -60 | [[-10, 1-12-2021]]
+
+- Since **qty_to_pop (60)** is more than the balance in slot 1, the entire slot is consumed and popped
+- Now the queue is **empty**, and **qty_to_pop=10** (remaining qty to pop)
+- Since we still have more to consume, we append the balance since 60 is issued from 50 i.e. -10.
+- We register this negative value, since the stock issue has caused the balance to become negative
+
+Now when stock is inwarded:
+- Instead of adding a slot we check if there are any negative balances.
+- If yes, we keep adding positive stock to it until we make the balance positive.
+- Once the balance is positive, the next inward entry will add a new slot in the queue
+
+Eg:
+----------------------
+Date | Qty | Queue
+----------------------
+1st | +50 | [[50, 1-12-2021]]
+2nd | -60 | [[-10, 1-12-2021]]
+3rd | +5 | [[-5, 3-12-2021]]
+4th | +10 | [[5, 4-12-2021]]
+4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
\ No newline at end of file
diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
new file mode 100644
index 0000000..949bb7c
--- /dev/null
+++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
@@ -0,0 +1,126 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import frappe
+
+from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
+from erpnext.tests.utils import ERPNextTestCase
+
+
+class TestStockAgeing(ERPNextTestCase):
+ def setUp(self) -> None:
+ self.filters = frappe._dict(
+ company="_Test Company",
+ to_date="2021-12-10"
+ )
+
+ def test_normal_inward_outward_queue(self):
+ "Reference: Case 1 in stock_ageing_fifo_logic.md"
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=30, qty_after_transaction=30,
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=20, qty_after_transaction=50,
+ posting_date="2021-12-02", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=40,
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+
+ slots = FIFOSlots(self.filters, sle).generate()
+
+ self.assertTrue(slots["Flask Item"]["fifo_queue"])
+ result = slots["Flask Item"]
+ queue = result["fifo_queue"]
+
+ self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(queue[0][0], 20.0)
+
+ def test_insufficient_balance(self):
+ "Reference: Case 3 in stock_ageing_fifo_logic.md"
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-30), qty_after_transaction=(-30),
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=20, qty_after_transaction=(-10),
+ posting_date="2021-12-02", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=20, qty_after_transaction=10,
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=10, qty_after_transaction=20,
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="004",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+
+ slots = FIFOSlots(self.filters, sle).generate()
+
+ result = slots["Flask Item"]
+ queue = result["fifo_queue"]
+
+ self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(queue[0][0], 10.0)
+ self.assertEqual(queue[1][0], 10.0)
+
+ def test_stock_reconciliation(self):
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=30, qty_after_transaction=30,
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=50,
+ posting_date="2021-12-02", voucher_type="Stock Reconciliation",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=40,
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+
+ slots = FIFOSlots(self.filters, sle).generate()
+
+ result = slots["Flask Item"]
+ queue = result["fifo_queue"]
+
+ self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(queue[0][0], 20.0)
+ self.assertEqual(queue[1][0], 20.0)
diff --git a/erpnext/stock/report/stock_balance/stock_balance.py b/erpnext/stock/report/stock_balance/stock_balance.py
index 3c7b26b..b4f43a7 100644
--- a/erpnext/stock/report/stock_balance/stock_balance.py
+++ b/erpnext/stock/report/stock_balance/stock_balance.py
@@ -9,7 +9,7 @@
from frappe.utils import cint, date_diff, flt, getdate
import erpnext
-from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
+from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress
@@ -33,7 +33,7 @@
if filters.get('show_stock_ageing_data'):
filters['show_warehouse_wise_stock'] = True
- item_wise_fifo_queue = get_fifo_queue(filters, sle)
+ item_wise_fifo_queue = FIFOSlots(filters, sle).generate()
# if no stock ledger entry found return
if not sle:
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js
index c484516..31f389f 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js
@@ -8,7 +8,8 @@
"fifo_value_diff",
"fifo_valuation_diff",
"valuation_diff",
- "fifo_difference_diff"
+ "fifo_difference_diff",
+ "diff_value_diff"
];
frappe.query_reports["Stock Ledger Invariant Check"] = {
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index ca47a1e..48753b0 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -50,6 +50,7 @@
def add_invariant_check_fields(sles):
balance_qty = 0.0
+ balance_stock_value = 0.0
for idx, sle in enumerate(sles):
queue = json.loads(sle.stock_queue)
@@ -60,6 +61,7 @@
fifo_value += qty * rate
balance_qty += sle.actual_qty
+ balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
balance_qty = sle.qty_after_transaction
@@ -70,6 +72,7 @@
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
)
sle.expected_qty_after_transaction = balance_qty
+ sle.stock_value_from_diff = balance_stock_value
# set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
@@ -81,6 +84,7 @@
sle.valuation_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
)
+ sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
@@ -191,13 +195,22 @@
"fieldtype": "Float",
"label": "D - E",
},
-
{
"fieldname": "stock_value_difference",
"fieldtype": "Float",
"label": "(F) Stock Value Difference",
},
{
+ "fieldname": "stock_value_from_diff",
+ "fieldtype": "Float",
+ "label": "Balance Stock Value using (F)",
+ },
+ {
+ "fieldname": "diff_value_diff",
+ "fieldtype": "Float",
+ "label": "K - D",
+ },
+ {
"fieldname": "fifo_stock_diff",
"fieldtype": "Float",
"label": "(G) Stock Value difference (FIFO queue)",
diff --git a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py
index 4d1491b..22bdb89 100644
--- a/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py
+++ b/erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py
@@ -9,7 +9,7 @@
from frappe import _
from frappe.utils import flt
-from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
+from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
from erpnext.stock.report.stock_balance.stock_balance import (
get_item_details,
get_item_warehouse_map,
@@ -33,7 +33,7 @@
item_map = get_item_details(items, sle, filters)
iwb_map = get_item_warehouse_map(filters, sle)
warehouse_list = get_warehouse_list(filters)
- item_ageing = get_fifo_queue(filters)
+ item_ageing = FIFOSlots(filters).generate()
data = []
item_balance = {}
item_value = {}
diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py
index 14cec46..7a0a5e5 100644
--- a/erpnext/support/doctype/issue/test_issue.py
+++ b/erpnext/support/doctype/issue/test_issue.py
@@ -98,6 +98,7 @@
issue.save()
self.assertEqual(issue.on_hold_since, frappe.flags.current_time)
+ self.assertFalse(issue.resolution_by)
creation = get_datetime("2020-03-04 5:00")
frappe.flags.current_time = get_datetime("2020-03-04 5:00")
diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
index c94700b..b3348f1 100644
--- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
+++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py
@@ -476,7 +476,7 @@
priority = get_response_and_resolution_duration(doc)
start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation)
set_response_by(doc, start_date_time, priority)
- if apply_sla_for_resolution:
+ if apply_sla_for_resolution and not doc.get('on_hold_since'): # resolution_by is reset if on hold
set_resolution_by(doc, start_date_time, priority)
@@ -624,9 +624,6 @@
if doc.meta.has_field("user_resolution_time"):
doc.user_resolution_time = None
- if doc.meta.has_field("agreement_status"):
- doc.agreement_status = "First Response Due"
-
# called via hooks on communication update
def on_communication_update(doc, status):