diff --git a/.flake8 b/.flake8 index 56c9b9a3699..5735456ae7d 100644 --- a/.flake8 +++ b/.flake8 @@ -28,6 +28,7 @@ ignore = B007, B950, W191, + E124, # closing bracket, irritating while writing QB code max-line-length = 200 exclude=.github/helper/semgrep_rules diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py index 44cea31ed38..51e1d6e9a00 100644 --- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py +++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py @@ -2,9 +2,10 @@ # For license information, please see license.txt +from functools import reduce + import frappe from frappe.utils import flt -from six.moves import reduce from erpnext.controllers.status_updater import StatusUpdater diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py index ddb833ff3bd..19d8d49dfea 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py @@ -135,7 +135,7 @@ class OpeningInvoiceCreationTool(Document): default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos") rate = flt(row.outstanding_amount) / flt(row.qty) - return frappe._dict({ + item_dict = frappe._dict({ "uom": default_uom, "rate": rate or 0.0, "qty": row.qty, @@ -146,6 +146,13 @@ class OpeningInvoiceCreationTool(Document): "cost_center": cost_center }) + for dimension in get_accounting_dimensions(): + item_dict.update({ + dimension: row.get(dimension) + }) + + return item_dict + item = get_item_dict() invoice = frappe._dict({ @@ -166,7 +173,7 @@ class OpeningInvoiceCreationTool(Document): accounting_dimension = get_accounting_dimensions() for dimension in accounting_dimension: invoice.update({ - dimension: item.get(dimension) + dimension: self.get(dimension) or item.get(dimension) }) return invoice diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py index b5aae9845b6..6700e9b975d 100644 --- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py +++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py @@ -7,21 +7,26 @@ import frappe from frappe.cache_manager import clear_doctype_cache from frappe.custom.doctype.property_setter.property_setter import make_property_setter +from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import ( + create_dimension, + disable_dimension, +) from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import ( get_temporary_opening_account, ) -test_dependencies = ["Customer", "Supplier"] +test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] class TestOpeningInvoiceCreationTool(unittest.TestCase): def setUp(self): if not frappe.db.exists("Company", "_Test Opening Invoice Company"): make_company() + create_dimension() - def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None): + def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None): doc = frappe.get_single("Opening Invoice Creation Tool") args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company, - party_1=party_1, party_2=party_2, invoice_number=invoice_number) + party_1=party_1, party_2=party_2, invoice_number=invoice_number, department=department) doc.update(args) return doc.make_invoices() @@ -106,6 +111,19 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase): doc = frappe.get_doc('Sales Invoice', inv) doc.cancel() + def test_opening_invoice_with_accounting_dimension(self): + invoices = self.make_invoices(invoice_type="Sales", company="_Test Opening Invoice Company", department='Sales - _TOIC') + + expected_value = { + "keys": ["customer", "outstanding_amount", "status", "department"], + 0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"], + 1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"], + } + self.check_expected_values(invoices, expected_value, invoice_type="Sales") + + def tearDown(self): + disable_dimension() + def get_opening_invoice_creation_dict(**args): party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier" company = args.get("company", "_Test Company") diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index 6b4b43d30b7..c13bc23c159 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -58,7 +58,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company= frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError) party = frappe.get_doc(party_type, party) - currency = party.default_currency if party.get("default_currency") else get_company_currency(company) + currency = party.get("default_currency") or currency or get_company_currency(company) party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address) set_contact_details(party_details, party, party_type) diff --git a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py index 4559fa94a4a..8e3bd8be1b9 100644 --- a/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py +++ b/erpnext/accounts/report/accounts_receivable_summary/accounts_receivable_summary.py @@ -5,7 +5,6 @@ import frappe from frappe import _, scrub from frappe.utils import cint, flt -from six import iteritems from erpnext.accounts.party import get_partywise_advanced_payment_amount from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport @@ -40,7 +39,7 @@ class AccountsReceivableSummary(ReceivablePayableReport): if self.filters.show_gl_balance: gl_balance_map = get_gl_balance(self.filters.report_date) - for party, party_dict in iteritems(self.party_total): + for party, party_dict in self.party_total.items(): if party_dict.outstanding == 0: continue diff --git a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py index 1de6fb68241..86eb2134fe8 100644 --- a/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py +++ b/erpnext/accounts/report/deferred_revenue_and_expense/test_deferred_revenue_and_expense.py @@ -17,10 +17,42 @@ from erpnext.stock.doctype.item.test_item import create_item class TestDeferredRevenueAndExpense(unittest.TestCase): @classmethod def setUpClass(self): - clear_old_entries() + clear_accounts_and_items() create_company() + self.maxDiff = None + + def clear_old_entries(self): + sinv = qb.DocType("Sales Invoice") + sinv_item = qb.DocType("Sales Invoice Item") + pinv = qb.DocType("Purchase Invoice") + pinv_item = qb.DocType("Purchase Invoice Item") + + # delete existing invoices with deferred items + deferred_invoices = ( + qb.from_(sinv) + .join(sinv_item) + .on(sinv.name == sinv_item.parent) + .select(sinv.name) + .where(sinv_item.enable_deferred_revenue == 1) + .run() + ) + if deferred_invoices: + qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run() + + deferred_invoices = ( + qb.from_(pinv) + .join(pinv_item) + .on(pinv.name == pinv_item.parent) + .select(pinv.name) + .where(pinv_item.enable_deferred_expense == 1) + .run() + ) + if deferred_invoices: + qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run() def test_deferred_revenue(self): + self.clear_old_entries() + # created deferred expense accounts, if not found deferred_revenue_account = create_account( account_name="Deferred Revenue", @@ -108,6 +140,8 @@ class TestDeferredRevenueAndExpense(unittest.TestCase): self.assertEqual(report.period_total, expected) def test_deferred_expense(self): + self.clear_old_entries() + # created deferred expense accounts, if not found deferred_expense_account = create_account( account_name="Deferred Expense", @@ -198,6 +232,91 @@ class TestDeferredRevenueAndExpense(unittest.TestCase): ] self.assertEqual(report.period_total, expected) + def test_zero_months(self): + self.clear_old_entries() + # created deferred expense accounts, if not found + deferred_revenue_account = create_account( + account_name="Deferred Revenue", + parent_account="Current Liabilities - _CD", + company="_Test Company DR", + ) + + acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings") + acc_settings.book_deferred_entries_based_on = "Months" + acc_settings.save() + + customer = frappe.new_doc("Customer") + customer.customer_name = "_Test Customer DR" + customer.type = "Individual" + customer.insert() + + item = create_item( + "_Test Internet Subscription", + is_stock_item=0, + warehouse="All Warehouses - _CD", + company="_Test Company DR", + ) + item.enable_deferred_revenue = 1 + item.deferred_revenue_account = deferred_revenue_account + item.no_of_months = 0 + item.save() + + si = create_sales_invoice( + item=item.name, + company="_Test Company DR", + customer="_Test Customer DR", + debit_to="Debtors - _CD", + posting_date="2021-05-01", + parent_cost_center="Main - _CD", + cost_center="Main - _CD", + do_not_submit=True, + rate=300, + price_list_rate=300, + ) + si.items[0].enable_deferred_revenue = 1 + si.items[0].deferred_revenue_account = deferred_revenue_account + si.items[0].income_account = "Sales - _CD" + si.save() + si.submit() + + pda = frappe.get_doc( + dict( + doctype="Process Deferred Accounting", + posting_date=nowdate(), + start_date="2021-05-01", + end_date="2021-08-01", + type="Income", + company="_Test Company DR", + ) + ) + pda.insert() + pda.submit() + + # execute report + fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year")) + self.filters = frappe._dict( + { + "company": frappe.defaults.get_user_default("Company"), + "filter_based_on": "Date Range", + "period_start_date": "2021-05-01", + "period_end_date": "2021-08-01", + "from_fiscal_year": fiscal_year.year, + "to_fiscal_year": fiscal_year.year, + "periodicity": "Monthly", + "type": "Revenue", + "with_upcoming_postings": False, + } + ) + + report = Deferred_Revenue_and_Expense_Report(filters=self.filters) + report.run() + expected = [ + {"key": "may_2021", "total": 300.0, "actual": 300.0}, + {"key": "jun_2021", "total": 0, "actual": 0}, + {"key": "jul_2021", "total": 0, "actual": 0}, + {"key": "aug_2021", "total": 0, "actual": 0}, + ] + self.assertEqual(report.period_total, expected) def create_company(): company = frappe.db.exists("Company", "_Test Company DR") @@ -209,15 +328,11 @@ def create_company(): company.insert() -def clear_old_entries(): +def clear_accounts_and_items(): item = qb.DocType("Item") account = qb.DocType("Account") customer = qb.DocType("Customer") supplier = qb.DocType("Supplier") - sinv = qb.DocType("Sales Invoice") - sinv_item = qb.DocType("Sales Invoice Item") - pinv = qb.DocType("Purchase Invoice") - pinv_item = qb.DocType("Purchase Invoice Item") qb.from_(account).delete().where( (account.account_name == "Deferred Revenue") @@ -228,26 +343,3 @@ def clear_old_entries(): ).run() qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run() qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run() - - # delete existing invoices with deferred items - deferred_invoices = ( - qb.from_(sinv) - .join(sinv_item) - .on(sinv.name == sinv_item.parent) - .select(sinv.name) - .where(sinv_item.enable_deferred_revenue == 1) - .run() - ) - if deferred_invoices: - qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run() - - deferred_invoices = ( - qb.from_(pinv) - .join(pinv_item) - .on(pinv.name == pinv_item.parent) - .select(pinv.name) - .where(pinv_item.enable_deferred_expense == 1) - .run() - ) - if deferred_invoices: - qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run() diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index f088b9ffc22..a181af73133 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting): # set contact and address details for supplier, if they are not mentioned if getattr(self, "supplier", None): - self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions, - doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'), - fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'))) + self.update_if_missing( + get_party_details( + self.supplier, + party_type="Supplier", + doctype=self.doctype, + company=self.company, + party_address=self.get("supplier_address"), + shipping_address=self.get('shipping_address'), + fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'), + ignore_permissions=self.flags.ignore_permissions + ) + ) self.set_missing_item_details(for_validate) diff --git a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py index 8fd4978715f..d2ac10adea2 100644 --- a/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py +++ b/erpnext/crm/doctype/linkedin_settings/linkedin_settings.py @@ -2,13 +2,14 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import requests from frappe import _ from frappe.model.document import Document from frappe.utils import get_url_to_form from frappe.utils.file_manager import get_file_path -from six.moves.urllib.parse import urlencode class LinkedInSettings(Document): diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py index 66826ba8d75..03c1a1a0b54 100644 --- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py +++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py @@ -5,11 +5,11 @@ import csv import math import time +from io import StringIO import dateutil import frappe from frappe import _ -from six import StringIO import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py index e242ace60f7..a8119ac86cb 100644 --- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py +++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py @@ -2,13 +2,14 @@ # For license information, please see license.txt +from urllib.parse import urlencode + import frappe import gocardless_pro from frappe import _ from frappe.integrations.utils import create_payment_gateway, create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method, cint, flt, get_url -from six.moves.urllib.parse import urlencode class GoCardlessSettings(Document): diff --git a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py index 8da52f49f1c..309d2cb0e37 100644 --- a/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py +++ b/erpnext/erpnext_integrations/doctype/woocommerce_settings/woocommerce_settings.py @@ -2,12 +2,13 @@ # For license information, please see license.txt +from urllib.parse import urlparse + import frappe from frappe import _ from frappe.custom.doctype.custom_field.custom_field import create_custom_field from frappe.model.document import Document from frappe.utils.nestedset import get_root_of -from six.moves.urllib.parse import urlparse class WoocommerceSettings(Document): diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index d922d875fdd..30d3948fee7 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -1,10 +1,10 @@ import base64 import hashlib import hmac +from urllib.parse import urlparse import frappe from frappe import _ -from six.moves.urllib.parse import urlparse from erpnext import get_default_company diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py index 1dc5b31461e..70250f5bcf8 100755 --- a/erpnext/hr/doctype/leave_application/leave_application.py +++ b/erpnext/hr/doctype/leave_application/leave_application.py @@ -22,6 +22,7 @@ from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.leave_block_list.leave_block_list import get_applicable_block_dates from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import create_leave_ledger_entry from erpnext.hr.utils import ( + get_holiday_dates_for_employee, get_leave_period, set_employee_name, share_doc_with_approver, @@ -159,33 +160,57 @@ class LeaveApplication(Document): .format(formatdate(future_allocation[0].from_date), future_allocation[0].name)) def update_attendance(self): - if self.status == "Approved": - for dt in daterange(getdate(self.from_date), getdate(self.to_date)): - date = dt.strftime("%Y-%m-%d") - status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave" - attendance_name = frappe.db.exists('Attendance', dict(employee = self.employee, - attendance_date = date, docstatus = ('!=', 2))) + if self.status != "Approved": + return + holiday_dates = [] + if not frappe.db.get_value("Leave Type", self.leave_type, "include_holiday"): + holiday_dates = get_holiday_dates_for_employee(self.employee, self.from_date, self.to_date) + + for dt in daterange(getdate(self.from_date), getdate(self.to_date)): + date = dt.strftime("%Y-%m-%d") + attendance_name = frappe.db.exists("Attendance", dict(employee = self.employee, + attendance_date = date, docstatus = ('!=', 2))) + + # don't mark attendance for holidays + # if leave type does not include holidays within leaves as leaves + if date in holiday_dates: if attendance_name: - # update existing attendance, change absent to on leave - doc = frappe.get_doc('Attendance', attendance_name) - if doc.status != status: - doc.db_set('status', status) - doc.db_set('leave_type', self.leave_type) - doc.db_set('leave_application', self.name) - else: - # make new attendance and submit it - doc = frappe.new_doc("Attendance") - doc.employee = self.employee - doc.employee_name = self.employee_name - doc.attendance_date = date - doc.company = self.company - doc.leave_type = self.leave_type - doc.leave_application = self.name - doc.status = status - doc.flags.ignore_validate = True - doc.insert(ignore_permissions=True) - doc.submit() + # cancel and delete existing attendance for holidays + attendance = frappe.get_doc("Attendance", attendance_name) + attendance.flags.ignore_permissions = True + if attendance.docstatus == 1: + attendance.cancel() + frappe.delete_doc("Attendance", attendance_name, force=1) + continue + + self.create_or_update_attendance(attendance_name, date) + + def create_or_update_attendance(self, attendance_name, date): + status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave" + + if attendance_name: + # update existing attendance, change absent to on leave + doc = frappe.get_doc('Attendance', attendance_name) + if doc.status != status: + doc.db_set({ + 'status': status, + 'leave_type': self.leave_type, + 'leave_application': self.name + }) + else: + # make new attendance and submit it + doc = frappe.new_doc("Attendance") + doc.employee = self.employee + doc.employee_name = self.employee_name + doc.attendance_date = date + doc.company = self.company + doc.leave_type = self.leave_type + doc.leave_application = self.name + doc.status = status + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) + doc.submit() def cancel_attendance(self): if self.docstatus == 2: diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py index 9b8d6384520..75e99f8991e 100644 --- a/erpnext/hr/doctype/leave_application/test_leave_application.py +++ b/erpnext/hr/doctype/leave_application/test_leave_application.py @@ -5,7 +5,16 @@ import unittest import frappe from frappe.permissions import clear_user_permissions_for_doctype -from frappe.utils import add_days, add_months, getdate, nowdate +from frappe.utils import ( + add_days, + add_months, + get_first_day, + get_last_day, + get_year_ending, + get_year_start, + getdate, + nowdate, +) from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation @@ -19,6 +28,10 @@ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import ( create_assignment_for_multiple_employees, ) from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_holiday_list, + make_leave_application, +) test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"] @@ -61,13 +74,15 @@ class TestLeaveApplication(unittest.TestCase): for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]: frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec + frappe.set_user("Administrator") + @classmethod def setUpClass(cls): set_leave_approver() frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'") def tearDown(self): - frappe.set_user("Administrator") + frappe.db.rollback() def _clear_roles(self): frappe.db.sql("""delete from `tabHas Role` where parent in @@ -106,6 +121,72 @@ class TestLeaveApplication(unittest.TestCase): for d in ('2018-01-01', '2018-01-02', '2018-01-03'): self.assertTrue(getdate(d) in dates) + def test_attendance_for_include_holidays(self): + # Case 1: leave type with 'Include holidays within leaves as leaves' enabled + frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1) + leave_type = frappe.get_doc(dict( + leave_type_name="Test Include Holidays", + doctype="Leave Type", + include_holiday=True + )).insert() + + date = getdate() + make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) + + holiday_list = make_holiday_list() + frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list) + first_sunday = get_first_sunday(holiday_list) + + leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name) + leave_application.reload() + self.assertEqual(leave_application.total_leave_days, 4) + self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4) + + leave_application.cancel() + + def test_attendance_update_for_exclude_holidays(self): + # Case 2: leave type with 'Include holidays within leaves as leaves' disabled + frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1) + leave_type = frappe.get_doc(dict( + leave_type_name="Test Do Not Include Holidays", + doctype="Leave Type", + include_holiday=False + )).insert() + + date = getdate() + make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date)) + + holiday_list = make_holiday_list() + frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list) + first_sunday = get_first_sunday(holiday_list) + + # already marked attendance on a holiday should be deleted in this case + config = { + "doctype": "Attendance", + "employee": "_T-Employee-00001", + "status": "Present" + } + attendance_on_holiday = frappe.get_doc(config) + attendance_on_holiday.attendance_date = first_sunday + attendance_on_holiday.save() + + # already marked attendance on a non-holiday should be updated + attendance = frappe.get_doc(config) + attendance.attendance_date = add_days(first_sunday, 3) + attendance.save() + + leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name) + leave_application.reload() + # holiday should be excluded while marking attendance + self.assertEqual(leave_application.total_leave_days, 3) + self.assertEqual(frappe.db.count("Attendance", {"leave_application": leave_application.name}), 3) + + # attendance on holiday deleted + self.assertFalse(frappe.db.exists("Attendance", attendance_on_holiday.name)) + + # attendance on non-holiday updated + self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave") + def test_block_list(self): self._clear_roles() @@ -241,7 +322,13 @@ class TestLeaveApplication(unittest.TestCase): leave_period = get_leave_period() today = nowdate() holiday_list = 'Test Holiday List for Optional Holiday' - optional_leave_date = add_days(today, 7) + employee = get_employee() + + default_holiday_list = make_holiday_list() + frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list) + first_sunday = get_first_sunday(default_holiday_list) + + optional_leave_date = add_days(first_sunday, 1) if not frappe.db.exists('Holiday List', holiday_list): frappe.get_doc(dict( @@ -253,7 +340,6 @@ class TestLeaveApplication(unittest.TestCase): dict(holiday_date = optional_leave_date, description = 'Test') ] )).insert() - employee = get_employee() frappe.db.set_value('Leave Period', leave_period.name, 'optional_holiday_list', holiday_list) leave_type = 'Test Optional Type' @@ -266,7 +352,7 @@ class TestLeaveApplication(unittest.TestCase): allocate_leaves(employee, leave_period, leave_type, 10) - date = add_days(today, 6) + date = add_days(first_sunday, 2) leave_application = frappe.get_doc(dict( doctype = 'Leave Application', @@ -637,13 +723,13 @@ def create_carry_forwarded_allocation(employee, leave_type): carry_forward=1) leave_allocation.submit() -def make_allocation_record(employee=None, leave_type=None): +def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None): allocation = frappe.get_doc({ "doctype": "Leave Allocation", "employee": employee or "_T-Employee-00001", "leave_type": leave_type or "_Test Leave Type", - "from_date": "2013-01-01", - "to_date": "2019-12-31", + "from_date": from_date or "2013-01-01", + "to_date": to_date or "2019-12-31", "new_leaves_allocated": 30 }) @@ -692,3 +778,16 @@ def allocate_leaves(employee, leave_period, leave_type, new_leaves_allocated, el }).insert() allocate_leave.submit() + + +def get_first_sunday(holiday_list): + month_start_date = get_first_day(nowdate()) + month_end_date = get_last_day(nowdate()) + first_sunday = frappe.db.sql(""" + select holiday_date from `tabHoliday` + where parent = %s + and holiday_date between %s and %s + order by holiday_date + """, (holiday_list, month_start_date, month_end_date))[0][0] + + return first_sunday \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index 6d35d65bea9..fc3b971bcb9 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -331,7 +331,7 @@ frappe.ui.form.on("BOM", { }); }); - if (has_template_rm) { + if (has_template_rm && has_template_rm.length) { dialog.fields_dict.items.grid.refresh(); } }, @@ -467,7 +467,8 @@ var get_bom_material_detail = function(doc, cdt, cdn, scrap_items) { "uom": d.uom, "stock_uom": d.stock_uom, "conversion_factor": d.conversion_factor, - "sourced_by_supplier": d.sourced_by_supplier + "sourced_by_supplier": d.sourced_by_supplier, + "do_not_explode": d.do_not_explode }, callback: function(r) { d = locals[cdt][cdn]; @@ -640,6 +641,13 @@ frappe.ui.form.on("BOM Operation", "workstation", function(frm, cdt, cdn) { }); }); +frappe.ui.form.on("BOM Item", { + do_not_explode: function(frm, cdt, cdn) { + get_bom_material_detail(frm.doc, cdt, cdn, false); + } +}) + + frappe.ui.form.on("BOM Item", "qty", function(frm, cdt, cdn) { var d = locals[cdt][cdn]; d.stock_qty = d.qty * d.conversion_factor; diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index 5a60fb751d1..045e5bc95ac 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -149,6 +149,7 @@ class BOM(WebsiteGenerator): self.set_bom_material_details() self.set_bom_scrap_items_detail() self.validate_materials() + self.validate_transfer_against() self.set_routing_operations() self.validate_operations() self.calculate_cost() @@ -203,6 +204,10 @@ class BOM(WebsiteGenerator): for item in self.get("items"): self.validate_bom_currency(item) + item.bom_no = '' + if not item.do_not_explode: + item.bom_no = item.bom_no + ret = self.get_bom_material_detail({ "company": self.company, "item_code": item.item_code, @@ -214,8 +219,10 @@ class BOM(WebsiteGenerator): "uom": item.uom, "stock_uom": item.stock_uom, "conversion_factor": item.conversion_factor, - "sourced_by_supplier": item.sourced_by_supplier + "sourced_by_supplier": item.sourced_by_supplier, + "do_not_explode": item.do_not_explode }) + for r in ret: if not item.get(r): item.set(r, ret[r]) @@ -267,6 +274,9 @@ class BOM(WebsiteGenerator): 'sourced_by_supplier' : args.get('sourced_by_supplier', 0) } + if args.get('do_not_explode'): + ret_item['bom_no'] = '' + return ret_item def validate_bom_currency(self, item): @@ -681,6 +691,12 @@ class BOM(WebsiteGenerator): if act_pbom and act_pbom[0][0]: frappe.throw(_("Cannot deactivate or cancel BOM as it is linked with other BOMs")) + def validate_transfer_against(self): + if not self.with_operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against and not self.is_new(): + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + def set_routing_operations(self): if self.routing and self.with_operations and not self.operations: self.get_routing() diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 178d92c26c3..53437c80127 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -385,6 +385,53 @@ class TestBOM(ERPNextTestCase): self.assertNotEqual(len(test_items), len(filtered), msg="Item filtering showing excessive results") self.assertTrue(0 < len(filtered) <= 3, msg="Item filtering showing excessive results") + def test_exclude_exploded_items_from_bom(self): + bom_no = get_default_bom() + new_bom = frappe.copy_doc(frappe.get_doc('BOM', bom_no)) + for row in new_bom.items: + if row.item_code == '_Test Item Home Desktop Manufactured': + self.assertTrue(row.bom_no) + row.do_not_explode = True + + new_bom.docstatus = 0 + new_bom.save() + new_bom.load_from_db() + + for row in new_bom.items: + if row.item_code == '_Test Item Home Desktop Manufactured' and row.do_not_explode: + self.assertFalse(row.bom_no) + + new_bom.delete() + + def test_valid_transfer_defaults(self): + bom_with_op = frappe.db.get_value("BOM", {"item": "_Test FG Item 2", "with_operations": 1, "is_active": 1}) + bom = frappe.copy_doc(frappe.get_doc("BOM", bom_with_op), ignore_no_copy=False) + + # test defaults + bom.docstatus = 0 + bom.transfer_material_against = None + bom.insert() + self.assertEqual(bom.transfer_material_against, "Work Order") + + bom.reload() + bom.transfer_material_against = None + with self.assertRaises(frappe.ValidationError): + bom.save() + bom.reload() + + # test saner default + bom.transfer_material_against = "Job Card" + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + + # test no value on existing doc + bom.transfer_material_against = None + bom.with_operations = 0 + bom.save() + self.assertEqual(bom.transfer_material_against, "Work Order") + bom.delete() + def get_default_bom(item_code="_Test FG Item 2"): return frappe.db.get_value("BOM", {"item": item_code, "is_active": 1, "is_default": 1}) diff --git a/erpnext/manufacturing/doctype/bom_item/bom_item.json b/erpnext/manufacturing/doctype/bom_item/bom_item.json index 4c9877f52b2..3406215cbbb 100644 --- a/erpnext/manufacturing/doctype/bom_item/bom_item.json +++ b/erpnext/manufacturing/doctype/bom_item/bom_item.json @@ -10,6 +10,7 @@ "item_name", "operation", "column_break_3", + "do_not_explode", "bom_no", "source_warehouse", "allow_alternative_item", @@ -73,6 +74,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval:!doc.do_not_explode", "fieldname": "bom_no", "fieldtype": "Link", "in_filter": 1, @@ -284,18 +286,25 @@ "fieldname": "sourced_by_supplier", "fieldtype": "Check", "label": "Sourced by Supplier" + }, + { + "default": "0", + "fieldname": "do_not_explode", + "fieldtype": "Check", + "label": "Do Not Explode" } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-08 14:19:37.563300", + "modified": "2022-01-24 16:57:57.020232", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Item", "owner": "Administrator", "permissions": [], "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index e7eb9c6149d..a399edda707 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -911,6 +911,54 @@ class TestWorkOrder(ERPNextTestCase): self.assertEqual(wo1.operations[0].time_in_mins, wo2.operations[0].time_in_mins) + def test_partial_manufacture_entries(self): + cancel_stock_entry = [] + + frappe.db.set_value("Manufacturing Settings", None, + "backflush_raw_materials_based_on", "Material Transferred for Manufacture") + + wo_order = make_wo_order_test_record(planned_start_date=now(), qty=100) + ste1 = test_stock_entry.make_stock_entry(item_code="_Test Item", + target="_Test Warehouse - _TC", qty=120, basic_rate=5000.0) + ste2 = test_stock_entry.make_stock_entry(item_code="_Test Item Home Desktop 100", + target="_Test Warehouse - _TC", qty=240, basic_rate=1000.0) + + cancel_stock_entry.extend([ste1.name, ste2.name]) + + sm = frappe.get_doc(make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 100)) + for row in sm.get('items'): + if row.get('item_code') == '_Test Item': + row.qty = 110 + + sm.submit() + cancel_stock_entry.append(sm.name) + + s = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 90)) + for row in s.get('items'): + if row.get('item_code') == '_Test Item': + self.assertEqual(row.get('qty'), 100) + s.submit() + cancel_stock_entry.append(s.name) + + s1 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) + for row in s1.get('items'): + if row.get('item_code') == '_Test Item': + self.assertEqual(row.get('qty'), 5) + s1.submit() + cancel_stock_entry.append(s1.name) + + s2 = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 5)) + for row in s2.get('items'): + if row.get('item_code') == '_Test Item': + self.assertEqual(row.get('qty'), 5) + + cancel_stock_entry.reverse() + for ste in cancel_stock_entry: + doc = frappe.get_doc("Stock Entry", ste) + doc.cancel() + + frappe.db.set_value("Manufacturing Settings", None, + "backflush_raw_materials_based_on", "BOM") def update_job_card(job_card, jc_qty=None): employee = frappe.db.get_value('Employee', {'status': 'Active'}, 'name') diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index 12cd58f418b..9452a63d70b 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -333,12 +333,13 @@ "options": "fa fa-wrench" }, { - "default": "Work Order", "depends_on": "operations", + "fetch_from": "bom_no.transfer_material_against", + "fetch_if_empty": 1, "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", - "options": "Work Order\nJob Card" + "options": "\nWork Order\nJob Card" }, { "fieldname": "operations", @@ -574,7 +575,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-11-08 17:36:07.016300", + "modified": "2022-01-24 21:18:12.160114", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", @@ -607,6 +608,7 @@ ], "sort_field": "modified", "sort_order": "ASC", + "states": [], "title_field": "production_item", "track_changes": 1, "track_seen": 1 diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 170454c8238..93ca805e343 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -65,6 +65,7 @@ class WorkOrder(Document): self.validate_warehouse_belongs_to_company() self.calculate_operating_cost() self.validate_qty() + self.validate_transfer_against() self.validate_operation_time() self.status = self.get_status() @@ -72,6 +73,7 @@ class WorkOrder(Document): self.set_required_items(reset_only_qty = len(self.get("required_items"))) + def validate_sales_order(self): if self.sales_order: self.check_sales_order_on_hold_or_close() @@ -625,6 +627,16 @@ class WorkOrder(Document): if not self.qty > 0: frappe.throw(_("Quantity to Manufacture must be greater than 0.")) + def validate_transfer_against(self): + if not self.docstatus == 1: + # let user configure operations until they're ready to submit + return + if not self.operations: + self.transfer_material_against = "Work Order" + if not self.transfer_material_against: + frappe.throw(_("Setting {} is required").format(self.meta.get_label("transfer_material_against")), title=_("Missing value")) + + def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 83bab06b71e..ad5062fc0ef 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -1,3 +1,4 @@ +[pre_model_sync] erpnext.patches.v12_0.update_is_cancelled_field erpnext.patches.v11_0.rename_production_order_to_work_order erpnext.patches.v11_0.refactor_naming_series @@ -226,7 +227,6 @@ erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.update_reason_for_resignation_in_employee execute:frappe.delete_doc("Report", "Quoted Item Comparison") erpnext.patches.v13_0.update_member_email_address -erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.create_leave_policy_assignment_based_on_employee_current_leave_policy erpnext.patches.v13_0.update_pos_closing_entry_in_merge_log erpnext.patches.v13_0.add_po_to_global_search @@ -252,7 +252,7 @@ erpnext.patches.v12_0.add_gst_category_in_delivery_note erpnext.patches.v12_0.purchase_receipt_status erpnext.patches.v13_0.fix_non_unique_represents_company erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing -erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 +erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022 erpnext.patches.v13_0.update_shipment_status erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting erpnext.patches.v13_0.germany_make_custom_fields @@ -266,7 +266,6 @@ erpnext.patches.v13_0.set_training_event_attendance erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold erpnext.patches.v13_0.update_response_by_variance -erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice erpnext.patches.v13_0.update_job_card_details erpnext.patches.v13_0.update_level_in_bom #1234sswef erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry @@ -285,11 +284,9 @@ erpnext.patches.v13_0.reset_clearance_date_for_intracompany_payment_entries erpnext.patches.v13_0.einvoicing_deprecation_warning execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings") execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category") -erpnext.patches.v14_0.delete_einvoicing_doctypes erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021 erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021 -erpnext.patches.v14_0.delete_shopify_doctypes erpnext.patches.v13_0.fix_invoice_statuses erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item erpnext.patches.v13_0.update_dates_in_tax_withholding_category @@ -312,24 +309,29 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_pan_field_for_india #2 -erpnext.patches.v14_0.delete_hub_doctypes erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022 -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.wipe_serial_no_field_for_0_qty 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.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 erpnext.patches.v13_0.agriculture_deprecation_warning -erpnext.patches.v14_0.delete_agriculture_doctypes erpnext.patches.v13_0.hospitality_deprecation_warning -erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 erpnext.patches.v13_0.update_exchange_rate_settings -erpnext.patches.v14_0.rearrange_company_fields -erpnext.patches.v14_0.update_leave_notification_template erpnext.patches.v13_0.update_asset_quantity_field erpnext.patches.v13_0.delete_bank_reconciliation_detail + +[post_model_sync] +erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents +erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template +erpnext.patches.v14_0.delete_einvoicing_doctypes +erpnext.patches.v14_0.delete_shopify_doctypes +erpnext.patches.v14_0.delete_hub_doctypes +erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022 +erpnext.patches.v14_0.delete_agriculture_doctypes +erpnext.patches.v14_0.rearrange_company_fields +erpnext.patches.v14_0.update_leave_notification_template +erpnext.patches.v13_0.update_sane_transfer_against diff --git a/erpnext/patches/v12_0/update_is_cancelled_field.py b/erpnext/patches/v12_0/update_is_cancelled_field.py index df7875079bc..04010348742 100644 --- a/erpnext/patches/v12_0/update_is_cancelled_field.py +++ b/erpnext/patches/v12_0/update_is_cancelled_field.py @@ -2,14 +2,28 @@ import frappe def execute(): - try: - frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") - frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 0 where is_cancelled in ('', NULL, 'No')") + #handle type casting for is_cancelled field + module_doctypes = ( + ('stock', 'Stock Ledger Entry'), + ('stock', 'Serial No'), + ('accounts', 'GL Entry') + ) - frappe.db.sql("UPDATE `tabStock Ledger Entry` SET is_cancelled = 1 where is_cancelled = 'Yes'") - frappe.db.sql("UPDATE `tabSerial No` SET is_cancelled = 1 where is_cancelled = 'Yes'") + for module, doctype in module_doctypes: + if (not frappe.db.has_column(doctype, "is_cancelled") + or frappe.db.get_column_type(doctype, "is_cancelled").lower() == "int(1)" + ): + continue - frappe.reload_doc("stock", "doctype", "stock_ledger_entry") - frappe.reload_doc("stock", "doctype", "serial_no") - except Exception: - pass + frappe.db.sql(""" + UPDATE `tab{doctype}` + SET is_cancelled = 0 + where is_cancelled in ('', NULL, 'No')""" + .format(doctype=doctype)) + frappe.db.sql(""" + UPDATE `tab{doctype}` + SET is_cancelled = 1 + where is_cancelled = 'Yes'""" + .format(doctype=doctype)) + + frappe.reload_doc(module, "doctype", frappe.scrub(doctype)) diff --git a/erpnext/patches/v13_0/delete_old_sales_reports.py b/erpnext/patches/v13_0/delete_old_sales_reports.py index c597fe86457..e6eba0a6085 100644 --- a/erpnext/patches/v13_0/delete_old_sales_reports.py +++ b/erpnext/patches/v13_0/delete_old_sales_reports.py @@ -12,6 +12,7 @@ def execute(): for report in reports_to_delete: if frappe.db.exists("Report", report): + delete_links_from_desktop_icons(report) delete_auto_email_reports(report) check_and_delete_linked_reports(report) @@ -22,3 +23,9 @@ def delete_auto_email_reports(report): auto_email_reports = frappe.db.get_values("Auto Email Report", {"report": report}, ["name"]) for auto_email_report in auto_email_reports: frappe.delete_doc("Auto Email Report", auto_email_report[0]) + +def delete_links_from_desktop_icons(report): + """ Check for one or multiple Desktop Icons and delete """ + desktop_icons = frappe.db.get_values("Desktop Icon", {"_report": report}, ["name"]) + for desktop_icon in desktop_icons: + frappe.delete_doc("Desktop Icon", desktop_icon[0]) \ No newline at end of file diff --git a/erpnext/patches/v13_0/make_non_standard_user_type.py b/erpnext/patches/v13_0/make_non_standard_user_type.py index a7bdf93332a..ff241a3fd93 100644 --- a/erpnext/patches/v13_0/make_non_standard_user_type.py +++ b/erpnext/patches/v13_0/make_non_standard_user_type.py @@ -10,8 +10,15 @@ from erpnext.setup.install import add_non_standard_user_types def execute(): doctype_dict = { 'projects': ['Timesheet'], - 'payroll': ['Salary Slip', 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission'], - 'hr': ['Employee', 'Expense Claim', 'Leave Application', 'Attendance Request', 'Compensatory Leave Request'] + 'payroll': [ + 'Salary Slip', 'Employee Tax Exemption Declaration', 'Employee Tax Exemption Proof Submission', + 'Employee Benefit Application', 'Employee Benefit Claim' + ], + 'hr': [ + 'Employee', 'Expense Claim', 'Leave Application', 'Attendance Request', 'Compensatory Leave Request', + 'Holiday List', 'Employee Advance', 'Training Program', 'Training Feedback', + 'Shift Request', 'Employee Grievance', 'Employee Referral', 'Travel Request' + ] } for module, doctypes in doctype_dict.items(): diff --git a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py index 7a2a2539670..2d35ea34587 100644 --- a/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py +++ b/erpnext/patches/v13_0/setup_fields_for_80g_certificate_and_donation.py @@ -5,6 +5,9 @@ from erpnext.regional.india.setup import make_custom_fields def execute(): if frappe.get_all('Company', filters = {'country': 'India'}): + frappe.reload_doc('accounts', 'doctype', 'POS Invoice') + frappe.reload_doc('accounts', 'doctype', 'POS Invoice Item') + make_custom_fields() if not frappe.db.exists('Party Type', 'Donor'): diff --git a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py index 55fd465b204..60466eb86a4 100644 --- a/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py +++ b/erpnext/patches/v13_0/update_actual_start_and_end_date_in_wo.py @@ -37,4 +37,4 @@ def execute(): jc.production_item = wo.production_item, jc.item_name = wo.item_name WHERE jc.work_order = wo.name and IFNULL(jc.production_item, "") = "" - """) + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_sane_transfer_against.py b/erpnext/patches/v13_0/update_sane_transfer_against.py new file mode 100644 index 00000000000..a163d385843 --- /dev/null +++ b/erpnext/patches/v13_0/update_sane_transfer_against.py @@ -0,0 +1,11 @@ +import frappe + + +def execute(): + bom = frappe.qb.DocType("BOM") + + (frappe.qb + .update(bom) + .set(bom.transfer_material_against, "Work Order") + .where(bom.with_operations == 0) + ).run() 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 120182a80e3..2a8b6ef746a 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 @@ -5,9 +5,6 @@ from frappe import _ def execute(): - frappe.reload_doc("email", "doctype", "email_template") - frappe.reload_doc("hr", "doctype", "hr_settings") - template = frappe.db.exists("Email Template", _("Exit Questionnaire Notification")) if not template: base_path = frappe.get_app_path("erpnext", "hr", "doctype") diff --git a/erpnext/patches/v14_0/rearrange_company_fields.py b/erpnext/patches/v14_0/rearrange_company_fields.py index dd953ffb0f5..fd7eb7f750a 100644 --- a/erpnext/patches/v14_0/rearrange_company_fields.py +++ b/erpnext/patches/v14_0/rearrange_company_fields.py @@ -1,10 +1,7 @@ -import frappe from frappe.custom.doctype.custom_field.custom_field import create_custom_fields def execute(): - frappe.reload_doc('setup', 'doctype', 'company') - custom_fields = { 'Company': [ dict(fieldname='hra_section', label='HRA Settings', @@ -28,4 +25,4 @@ def execute(): ] } - create_custom_fields(custom_fields, update=True) \ No newline at end of file + create_custom_fields(custom_fields, update=True) diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index c0e005ad992..bcf981b74dc 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -994,6 +994,8 @@ def make_leave_application(employee, from_date, to_date, leave_type, company=Non )) leave_application.submit() + return leave_application + def setup_test(): make_earning_salary_component(setup=True, company_list=["_Test Company"]) make_deduction_salary_component(setup=True, company_list=["_Test Company"]) diff --git a/erpnext/regional/germany/utils/datev/datev_csv.py b/erpnext/regional/germany/utils/datev/datev_csv.py index 2d1e02eadba..ec271a10294 100644 --- a/erpnext/regional/germany/utils/datev/datev_csv.py +++ b/erpnext/regional/germany/utils/datev/datev_csv.py @@ -1,11 +1,11 @@ import datetime import zipfile from csv import QUOTE_NONNUMERIC +from io import BytesIO import frappe import pandas as pd from frappe import _ -from six import BytesIO from .datev_constants import DataCategory diff --git a/erpnext/regional/report/datev/test_datev.py b/erpnext/regional/report/datev/test_datev.py index 14d5495eed7..052fb2a7244 100644 --- a/erpnext/regional/report/datev/test_datev.py +++ b/erpnext/regional/report/datev/test_datev.py @@ -1,9 +1,9 @@ import zipfile +from io import BytesIO from unittest import TestCase import frappe from frappe.utils import cstr, now_datetime, today -from six import BytesIO from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.regional.germany.utils.datev.datev_constants import ( diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index b7f74df105d..d74d5a6df9a 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -142,7 +142,7 @@ class Customer(TransactionBase): self.update_lead_status() if self.flags.is_new_doc: - self.create_lead_address_contact() + self.link_lead_address_and_contact() self.update_customer_groups() @@ -176,62 +176,24 @@ class Customer(TransactionBase): if self.lead_name: frappe.db.set_value("Lead", self.lead_name, "status", "Converted") - def create_lead_address_contact(self): + def link_lead_address_and_contact(self): if self.lead_name: - # assign lead address to customer (if already not set) - address_names = frappe.get_all('Dynamic Link', filters={ - "parenttype":"Address", - "link_doctype":"Lead", - "link_name":self.lead_name - }, fields=["parent as name"]) + # assign lead address and contact to customer (if already not set) + linked_contacts_and_addresses = frappe.get_all( + "Dynamic Link", + filters=[ + ["parenttype", "in", ["Contact", "Address"]], + ["link_doctype", "=", "Lead"], + ["link_name", "=", self.lead_name], + ], + fields=["parent as name", "parenttype as doctype"], + ) - for address_name in address_names: - address = frappe.get_doc('Address', address_name.get('name')) - if not address.has_link('Customer', self.name): - address.append('links', dict(link_doctype='Customer', link_name=self.name)) - address.save(ignore_permissions=self.flags.ignore_permissions) - - lead = frappe.db.get_value("Lead", self.lead_name, ["company_name", "lead_name", "email_id", "phone", "mobile_no", "gender", "salutation"], as_dict=True) - - if not lead.lead_name: - frappe.throw(_("Please mention the Lead Name in Lead {0}").format(self.lead_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) - - 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:]) - - # create contact from lead - contact = frappe.new_doc('Contact') - contact.first_name = lead.first_name - contact.last_name = lead.last_name - contact.gender = lead.gender - contact.salutation = lead.salutation - contact.email_id = lead.email_id - contact.phone = lead.phone - contact.mobile_no = lead.mobile_no - contact.is_primary_contact = 1 - contact.append('links', dict(link_doctype='Customer', link_name=self.name)) - if lead.email_id: - contact.append('email_ids', dict(email_id=lead.email_id, is_primary=1)) - if lead.mobile_no: - contact.append('phone_nos', dict(phone=lead.mobile_no, is_primary_mobile_no=1)) - contact.flags.ignore_permissions = self.flags.ignore_permissions - contact.autoname() - if not frappe.db.exists("Contact", contact.name): - contact.insert() + for row in linked_contacts_and_addresses: + linked_doc = frappe.get_doc(row.doctype, row.name) + if not linked_doc.has_link('Customer', self.name): + linked_doc.append('links', dict(link_doctype='Customer', link_name=self.name)) + linked_doc.save(ignore_permissions=self.flags.ignore_permissions) def validate_name_with_customer_group(self): if frappe.db.exists("Customer Group", self.name): diff --git a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py index b2bf5464b5a..3e22d0fa8c8 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -85,7 +85,7 @@ def get_data(conditions, filters): and so.docstatus = 1 {conditions} GROUP BY soi.name - ORDER BY so.transaction_date ASC + ORDER BY so.transaction_date ASC, soi.item_code ASC """.format(conditions=conditions), filters, as_dict=1) return data diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index c94b3463fc8..9f1eb753d97 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -3,6 +3,7 @@ import copy +from urllib.parse import quote import frappe from frappe import _ @@ -10,7 +11,6 @@ from frappe.utils import cint, cstr, nowdate from frappe.utils.nestedset import NestedSet from frappe.website.utils import clear_cache from frappe.website.website_generator import WebsiteGenerator -from six.moves.urllib.parse import quote from erpnext.shopping_cart.filters import ProductFiltersBuilder from erpnext.shopping_cart.product_info import set_product_info_for_website diff --git a/erpnext/setup/install.py b/erpnext/setup/install.py index bafaab814b4..1d7bad2686a 100644 --- a/erpnext/setup/install.py +++ b/erpnext/setup/install.py @@ -189,7 +189,7 @@ def add_non_standard_user_types(): user_type_limit = {} for user_type, data in user_types.items(): - user_type_limit.setdefault(frappe.scrub(user_type), 10) + user_type_limit.setdefault(frappe.scrub(user_type), 20) update_site_config('user_type_doctype_limit', user_type_limit) @@ -204,15 +204,33 @@ def get_user_types_data(): 'apply_user_permission_on': 'Employee', 'user_id_field': 'user_id', 'doctypes': { - 'Salary Slip': ['read'], + # masters + 'Holiday List': ['read'], 'Employee': ['read', 'write'], + # payroll + 'Salary Slip': ['read'], + 'Employee Benefit Application': ['read', 'write', 'create', 'delete'], + # expenses 'Expense Claim': ['read', 'write', 'create', 'delete'], + 'Employee Advance': ['read', 'write', 'create', 'delete'], + # leave and attendance 'Leave Application': ['read', 'write', 'create', 'delete'], 'Attendance Request': ['read', 'write', 'create', 'delete'], 'Compensatory Leave Request': ['read', 'write', 'create', 'delete'], + # tax 'Employee Tax Exemption Declaration': ['read', 'write', 'create', 'delete'], 'Employee Tax Exemption Proof Submission': ['read', 'write', 'create', 'delete'], - 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'] + # projects + 'Timesheet': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # trainings + 'Training Program': ['read'], + 'Training Feedback': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # shifts + 'Shift Request': ['read', 'write', 'create', 'delete', 'submit', 'cancel', 'amend'], + # misc + 'Employee Grievance': ['read', 'write', 'create', 'delete'], + 'Employee Referral': ['read', 'write', 'create', 'delete'], + 'Travel Request': ['read', 'write', 'create', 'delete'] } } } diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 281e8818759..d99fadca46e 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -602,14 +602,6 @@ class Item(WebsiteGenerator): frappe.throw(_("Barcode {0} is not a valid {1} code").format( item_barcode.barcode, item_barcode.barcode_type), InvalidBarcode) - if item_barcode.barcode != item_barcode.name: - # if barcode is getting updated , the row name has to reset. - # Delete previous old row doc and re-enter row as if new to reset name in db. - item_barcode.set("__islocal", True) - item_barcode_entry_name = item_barcode.name - item_barcode.name = None - frappe.delete_doc("Item Barcode", item_barcode_entry_name) - def validate_warehouse_for_reorder(self): '''Validate Reorder level table for duplicate and conditional mandatory''' warehouse = [] diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index a61b3199c0e..60154afa15c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -1445,14 +1445,15 @@ class StockEntry(StockController): qty = req_qty_each * flt(self.fg_completed_qty) elif backflushed_materials.get(item.item_code): + precision = frappe.get_precision("Stock Entry Detail", "qty") for d in backflushed_materials.get(item.item_code): - if d.get(item.warehouse): + if d.get(item.warehouse) > 0: if (qty > req_qty): - qty = (qty/trans_qty) * flt(self.fg_completed_qty) + qty = ((flt(qty, precision) - flt(d.get(item.warehouse), precision)) + / (flt(trans_qty, precision) - flt(produced_qty, precision)) + ) * flt(self.fg_completed_qty) - if consumed_qty and frappe.db.get_single_value("Manufacturing Settings", - "material_consumption"): - qty -= consumed_qty + d[item.warehouse] -= qty if cint(frappe.get_cached_value('UOM', item.stock_uom, 'must_be_whole_number')): qty = frappe.utils.ceil(qty) diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.js b/erpnext/stock/report/stock_ledger/stock_ledger.js index fe2417bba7e..ef7c2cc7d9e 100644 --- a/erpnext/stock/report/stock_ledger/stock_ledger.js +++ b/erpnext/stock/report/stock_ledger/stock_ledger.js @@ -86,10 +86,10 @@ frappe.query_reports["Stock Ledger"] = { ], "formatter": function (value, row, column, data, default_formatter) { value = default_formatter(value, row, column, data); - if (column.fieldname == "out_qty" && data.out_qty < 0) { + if (column.fieldname == "out_qty" && data && data.out_qty < 0) { value = "" + value + ""; } - else if (column.fieldname == "in_qty" && data.in_qty > 0) { + else if (column.fieldname == "in_qty" && data && data.in_qty > 0) { value = "" + value + ""; } diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py index 1dcf863a9d0..525af40b412 100644 --- a/erpnext/stock/report/test_reports.py +++ b/erpnext/stock/report/test_reports.py @@ -1,6 +1,8 @@ import unittest from typing import List, Tuple +import frappe + from erpnext.tests.utils import ReportFilters, ReportName, execute_script_report DEFAULT_FILTERS = { @@ -10,8 +12,12 @@ DEFAULT_FILTERS = { } +batch = frappe.db.get_value("Batch", fieldname=["name"], as_dict=True, order_by="creation desc") + REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Stock Ledger", {"_optional": True}), + ("Stock Ledger", {"batch_no": batch}), + ("Stock Ledger", {"item_code": "_Test Item", "warehouse": "_Test Warehouse - _TC"}), ("Stock Balance", {"_optional": True}), ("Stock Projected Qty", {"_optional": True}), ("Batch-Wise Balance History", {}), @@ -40,6 +46,13 @@ REPORT_FILTER_TEST_CASES: List[Tuple[ReportName, ReportFilters]] = [ ("Item Variant Details", {"item": "_Test Variant Item",}), ("Total Stock Summary", {"group_by": "warehouse",}), ("Batch Item Expiry Status", {}), + ("Incorrect Stock Value Report", {"company": "_Test Company with perpetual inventory"}), + ("Incorrect Serial No Valuation", {}), + ("Incorrect Balance Qty After Transaction", {}), + ("Supplier-Wise Sales Analytics", {}), + ("Item Prices", {"items": "Enabled Items only"}), + ("Delayed Item Report", {"based_on": "Sales Invoice"}), + ("Delayed Item Report", {"based_on": "Delivery Note"}), ("Stock Ageing", {"range1": 30, "range2": 60, "range3": 90, "_optional": True}), ("Stock Ledger Invariant Check", { diff --git a/erpnext/tests/utils.py b/erpnext/tests/utils.py index bc9f04e0892..2bd7e9e71d0 100644 --- a/erpnext/tests/utils.py +++ b/erpnext/tests/utils.py @@ -92,6 +92,8 @@ def change_settings(doctype, settings_dict): for key, value in settings_dict.items(): setattr(settings, key, value) settings.save() + # singles are cached by default, clear to avoid flake + frappe.db.value_cache[settings] = {} yield # yield control to calling function finally: