Browse Source

Merge branch 'develop' into independent-manu-entry

develop
Marica 3 years ago
committed by GitHub
parent
commit
02b9541b43
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 27
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 8
      erpnext/accounts/doctype/sales_invoice/sales_invoice.py
  3. 2
      erpnext/controllers/accounts_controller.py
  4. 11
      erpnext/crm/doctype/lead/test_lead.py
  5. 78
      erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json
  6. 2
      erpnext/hooks.py
  7. 2
      erpnext/hr/doctype/expense_claim/expense_claim.js
  8. 24
      erpnext/hr/doctype/expense_claim/test_expense_claim.py
  9. 1
      erpnext/hr/doctype/expense_claim/test_records.json
  10. 105
      erpnext/hr/doctype/leave_allocation/test_leave_allocation.py
  11. 4
      erpnext/patches.txt
  12. 1
      erpnext/patches/v11_0/add_default_dispatch_notification_template.py
  13. 2
      erpnext/patches/v12_0/create_itc_reversal_custom_fields.py
  14. 1
      erpnext/patches/v13_0/add_default_interview_notification_templates.py
  15. 31
      erpnext/patches/v13_0/update_tax_category_for_rcm.py
  16. 1
      erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py
  17. 44
      erpnext/projects/doctype/project/project.js
  18. 15
      erpnext/public/js/controllers/transaction.js
  19. 4
      erpnext/regional/india/setup.py
  20. 5
      erpnext/regional/india/utils.py
  21. 25
      erpnext/selling/doctype/customer/customer.py
  22. 15
      erpnext/selling/report/sales_order_analysis/sales_order_analysis.py
  23. 2
      erpnext/setup/setup_wizard/data/country_wise_tax.json
  24. 18
      erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json
  25. 2
      erpnext/stock/get_item_details.py
  26. 339
      erpnext/stock/report/stock_ageing/stock_ageing.py
  27. 73
      erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
  28. 126
      erpnext/stock/report/stock_ageing/test_stock_ageing.py
  29. 4
      erpnext/stock/report/stock_balance/stock_balance.py
  30. 3
      erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js
  31. 15
      erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
  32. 4
      erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py
  33. 1
      erpnext/support/doctype/issue/test_issue.py
  34. 5
      erpnext/support/doctype/service_level_agreement/service_level_agreement.py

27
.github/ISSUE_TEMPLATE/bug_report.yaml

@ -24,20 +24,6 @@ body:
validations:
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:
@ -86,7 +72,7 @@ body:
- manual install
- FrappeCloud
validations:
required: true
required: false
- type: textarea
id: logs
@ -95,12 +81,7 @@ body:
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)

8
erpnext/accounts/doctype/sales_invoice/sales_invoice.py

@ -1049,6 +1049,8 @@ class SalesInvoice(SellingController):
frappe.flags.is_reverse_depr_entry = False
asset.flags.ignore_validate_update_after_submit = True
schedule.journal_entry = None
depreciation_amount = self.get_depreciation_amount_in_je(reverse_journal_entry)
asset.finance_books[0].value_after_depreciation += depreciation_amount
asset.save()
def get_posting_date_of_sales_invoice(self):
@ -1071,6 +1073,12 @@ class SalesInvoice(SellingController):
return False
def get_depreciation_amount_in_je(self, journal_entry):
if journal_entry.accounts[0].debit_in_account_currency:
return journal_entry.accounts[0].debit_in_account_currency
else:
return journal_entry.accounts[0].credit_in_account_currency
@property
def enable_discount_accounting(self):
if not hasattr(self, "_enable_discount_accounting"):

2
erpnext/controllers/accounts_controller.py

@ -184,6 +184,8 @@ class AccountsController(TransactionBase):
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()

11
erpnext/crm/doctype/lead/test_lead.py

@ -23,6 +23,17 @@ class TestLead(unittest.TestCase):
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

78
erpnext/erpnext_integrations/workspace/erpnext_integrations_settings/erpnext_integrations_settings.json

@ -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"
}

2
erpnext/hooks.py

@ -374,7 +374,7 @@ scheduler_events = {
"erpnext.selling.doctype.quotation.quotation.set_expired_status",
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status",
"erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email",
"erpnext.non_profit.doctype.membership.membership.set_expired_status"
"erpnext.non_profit.doctype.membership.membership.set_expired_status",
"erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder"
],
"daily_long": [

2
erpnext/hr/doctype/expense_claim/expense_claim.js

@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", {
['docstatus', '=', 1],
['employee', '=', frm.doc.employee],
['paid_amount', '>', 0],
['paid_amount', '>', 'claimed_amount']
['status', '!=', 'Claimed']
]
};
});

24
erpnext/hr/doctype/expense_claim/test_expense_claim.py

@ -10,15 +10,17 @@ from erpnext.accounts.doctype.account.test_account import create_account
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 @@ class TestExpenseClaim(unittest.TestCase):
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 @@ class TestExpenseClaim(unittest.TestCase):
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 @@ class TestExpenseClaim(unittest.TestCase):
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 @@ class TestExpenseClaim(unittest.TestCase):
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 @@ class TestExpenseClaim(unittest.TestCase):
"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()

1
erpnext/hr/doctype/expense_claim/test_records.json

@ -1 +0,0 @@
[]

105
erpnext/hr/doctype/leave_allocation/test_leave_allocation.py

@ -4,6 +4,7 @@ import frappe
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 @@ class TestLeaveAllocation(unittest.TestCase):
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)
def tearDown(self):
frappe.db.rollback()
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
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 @@ class TestLeaveAllocation(unittest.TestCase):
{
"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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ class TestLeaveAllocation(unittest.TestCase):
# 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 @@ class TestLeaveAllocation(unittest.TestCase):
# 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 @@ class TestLeaveAllocation(unittest.TestCase):
# 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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ class TestLeaveAllocation(unittest.TestCase):
# 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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ class TestLeaveAllocation(unittest.TestCase):
# 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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ class TestLeaveAllocation(unittest.TestCase):
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 @@ def create_leave_allocation(**args):
"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"]

4
erpnext/patches.txt

@ -317,4 +317,6 @@ erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
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
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')

1
erpnext/patches/v11_0/add_default_dispatch_notification_template.py

@ -22,4 +22,5 @@ def execute():
delivery_settings = frappe.get_doc("Delivery Settings")
delivery_settings.dispatch_template = _("Dispatch Notification")
delivery_settings.flags.ignore_links = True
delivery_settings.save()

2
erpnext/patches/v12_0/create_itc_reversal_custom_fields.py

@ -97,6 +97,8 @@ def execute():
'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

1
erpnext/patches/v13_0/add_default_interview_notification_templates.py

@ -32,4 +32,5 @@ def execute():
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()

31
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()

1
erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py

@ -24,4 +24,5 @@ def execute():
hr_settings = frappe.get_doc("HR Settings")
hr_settings.exit_questionnaire_notification_template = template
hr_settings.flags.ignore_links = True
hr_settings.save()

44
erpnext/projects/doctype/project/project.js

@ -59,22 +59,16 @@ frappe.ui.form.on("Project", {
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);
});
frm.add_custom_button(__('Completed'), () => {
frm.events.set_status(frm, 'Completed');
}, __('Set Status'));
}, __("Actions"));
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 @@ frappe.ui.form.on("Project", {
"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 @@ frappe.ui.form.on("Project", {
}).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 @@ frappe.ui.form.on("Project", {
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();
});
});
},

15
erpnext/public/js/controllers/transaction.js

@ -680,7 +680,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
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 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
_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 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}
}
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) {

4
erpnext/regional/india/setup.py

@ -277,8 +277,10 @@ def get_custom_fields():
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')
]

5
erpnext/regional/india/utils.py

@ -67,7 +67,8 @@ def validate_pan_for_india(doc, method):
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_based_on_category(master_doctype, company, party_details):
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 = ''

25
erpnext/selling/doctype/customer/customer.py

@ -196,20 +196,19 @@ class Customer(TransactionBase):
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"])
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)
contact_names = frappe.get_all('Dynamic Link', filters={
"parenttype":"Contact",
"link_doctype":"Lead",
"link_name":self.lead_name
}, fields=["parent as name"])
else:
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)
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:])

15
erpnext/selling/report/sales_order_analysis/sales_order_analysis.py

@ -61,6 +61,7 @@ def get_data(conditions, filters):
IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay,
soi.qty, soi.delivered_qty,
(soi.qty - soi.delivered_qty) AS pending_qty,
IF((SELECT pending_qty) = 0, (TO_SECONDS(Max(dn.posting_date))-TO_SECONDS(so.transaction_date)), 0) as time_taken_to_deliver,
IFNULL(SUM(sii.qty), 0) as billed_qty,
soi.base_amount as amount,
(soi.delivered_qty * soi.base_rate) as delivered_qty_amount,
@ -70,9 +71,13 @@ def get_data(conditions, filters):
so.company, soi.name
FROM
`tabSales Order` so,
`tabSales Order Item` soi
(`tabSales Order Item` soi
LEFT JOIN `tabSales Invoice Item` sii
ON sii.so_detail = soi.name and sii.docstatus = 1
ON sii.so_detail = soi.name and sii.docstatus = 1)
LEFT JOIN `tabDelivery Note Item` dni
on dni.so_detail = soi.name
RIGHT JOIN `tabDelivery Note` dn
on dni.parent = dn.name and dn.docstatus = 1
WHERE
soi.parent = so.name
and so.status not in ('Stopped', 'Closed', 'On Hold')
@ -259,6 +264,12 @@ def get_columns(filters):
"fieldname": "delay",
"fieldtype": "Data",
"width": 100
},
{
"label": _("Time Taken to Deliver"),
"fieldname": "time_taken_to_deliver",
"fieldtype": "Duration",
"width": 100
}
])
if not filters.get("group_by_so"):

2
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": ""
},
{

18
erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.json

@ -150,7 +150,7 @@
"fieldtype": "Float",
"in_filter": 1,
"in_list_view": 1,
"label": "Actual Quantity",
"label": "Qty Change",
"oldfieldname": "actual_qty",
"oldfieldtype": "Currency",
"print_width": "150px",
@ -189,7 +189,7 @@
"fieldname": "qty_after_transaction",
"fieldtype": "Float",
"in_filter": 1,
"label": "Actual Qty After Transaction",
"label": "Qty After Transaction",
"oldfieldname": "bin_aqat",
"oldfieldtype": "Currency",
"print_width": "150px",
@ -210,7 +210,7 @@
{
"fieldname": "stock_value",
"fieldtype": "Currency",
"label": "Stock Value",
"label": "Balance Stock Value",
"oldfieldname": "stock_value",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
@ -219,14 +219,14 @@
{
"fieldname": "stock_value_difference",
"fieldtype": "Currency",
"label": "Stock Value Difference",
"label": "Change in Stock Value",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "stock_queue",
"fieldtype": "Text",
"label": "Stock Queue (FIFO)",
"label": "FIFO Stock Queue (qty, rate)",
"oldfieldname": "fcfs_stack",
"oldfieldtype": "Text",
"print_hide": 1,
@ -317,10 +317,11 @@
"in_create": 1,
"index_web_pages_for_search": 1,
"links": [],
"modified": "2021-10-08 13:42:51.857631",
"modified": "2021-12-21 06:25:30.040801",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Ledger Entry",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@ -338,5 +339,6 @@
}
],
"sort_field": "modified",
"sort_order": "DESC"
}
"sort_order": "DESC",
"states": []
}

2
erpnext/stock/get_item_details.py

@ -1097,7 +1097,7 @@ def apply_price_list(args, as_doc=False):
}
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))

339
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 frappe.utils import cint, date_diff, flt
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 @@ def execute(filters=None):
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 columns, data, None, chart_data
return 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 @@ def get_average_age(fifo_queue, to_date):
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 @@ def get_range_age(filters, fifo_queue, to_date, item_dict):
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 @@ def get_columns(filters):
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 @@ def get_chart_data(data, filters):
"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 add_column(range_columns, label, fieldname, fieldtype='Float', width=140):
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: 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))

73
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]]

126
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)

4
erpnext/stock/report/stock_balance/stock_balance.py

@ -9,7 +9,7 @@ from frappe import _
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 @@ def execute(filters=None):
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:

3
erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.js

@ -8,7 +8,8 @@ const DIFFERNCE_FIELD_NAMES = [
"fifo_value_diff",
"fifo_valuation_diff",
"valuation_diff",
"fifo_difference_diff"
"fifo_difference_diff",
"diff_value_diff"
];
frappe.query_reports["Stock Ledger Invariant Check"] = {

15
erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py

@ -50,6 +50,7 @@ def get_stock_ledger_entries(filters):
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 @@ def add_invariant_check_fields(sles):
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 @@ def add_invariant_check_fields(sles):
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 @@ def add_invariant_check_fields(sles):
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,12 +195,21 @@ def get_columns():
"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",

4
erpnext/stock/report/warehouse_wise_item_balance_age_and_value/warehouse_wise_item_balance_age_and_value.py

@ -9,7 +9,7 @@ import frappe
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 @@ def execute(filters=None):
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 = {}

1
erpnext/support/doctype/issue/test_issue.py

@ -98,6 +98,7 @@ class TestIssue(TestSetUp):
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")

5
erpnext/support/doctype/service_level_agreement/service_level_agreement.py

@ -476,7 +476,7 @@ def update_response_and_resolution_metrics(doc, apply_sla_for_resolution):
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 @@ def reset_resolution_metrics(doc):
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):

Loading…
Cancel
Save