diff --git a/.github/helper/install.sh b/.github/helper/install.sh index e7f46410e6c..f9a7a024aea 100644 --- a/.github/helper/install.sh +++ b/.github/helper/install.sh @@ -2,6 +2,13 @@ set -e +# Check for merge conflicts before proceeding +python -m compileall -f "${GITHUB_WORKSPACE}" +if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}" + then echo "Found merge conflicts" + exit 1 +fi + cd ~ || exit sudo apt-get install redis-server libcups2-dev diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 42fdb0df5df..c77d8bca032 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -6,6 +6,7 @@ from erpnext.hooks import regional_overrides __version__ = "13.27.0" + def get_default_company(user=None): """Get default company for user""" from frappe.defaults import get_user_default_as_list 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 735ab301aba..0f0ab68dcb8 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 @@ -114,10 +114,13 @@ class OpeningInvoiceCreationTool(Document): ) or {} ) + + default_currency = frappe.db.get_value(row.party_type, row.party, "default_currency") + if company_details: invoice.update( { - "currency": company_details.get("default_currency"), + "currency": default_currency or company_details.get("default_currency"), "letter_head": company_details.get("default_letter_head"), } ) diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js index 648d2da754e..c0b8fa57011 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.js @@ -38,6 +38,15 @@ erpnext.accounts.PaymentReconciliationController = frappe.ui.form.Controller.ext ] }; }); + + this.frm.set_query("cost_center", () => { + return { + "filters": { + "company": this.frm.doc.company, + "is_group": 0 + } + } + }); }, refresh: function() { diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json index eb0c20f92d9..18d34850850 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.json @@ -24,6 +24,7 @@ "invoice_limit", "payment_limit", "bank_cash_account", + "cost_center", "sec_break1", "invoices", "column_break_15", @@ -178,13 +179,19 @@ { "fieldname": "column_break_11", "fieldtype": "Column Break" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" } ], "hide_toolbar": 1, "icon": "icon-resize-horizontal", "issingle": 1, "links": [], - "modified": "2021-10-04 20:27:11.114194", + "modified": "2022-04-29 15:37:10.246831", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Reconciliation", @@ -209,5 +216,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py index b596df92247..e5b942fb6ef 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/payment_reconciliation.py @@ -332,6 +332,9 @@ class PaymentReconciliation(Document): def get_conditions(self, get_invoices=False, get_payments=False, get_return_invoices=False): condition = " and company = '{0}' ".format(self.company) + if self.get("cost_center") and (get_invoices or get_payments or get_return_invoices): + condition = " and cost_center = '{0}' ".format(self.cost_center) + if get_invoices: condition += ( " and posting_date >= {0}".format(frappe.db.escape(self.from_invoice_date)) diff --git a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py index 2271f48a2b9..d2374b77a63 100644 --- a/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py +++ b/erpnext/accounts/doctype/payment_reconciliation/test_payment_reconciliation.py @@ -1,9 +1,96 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe import unittest +import frappe +from frappe.utils import add_days, getdate + +from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice + class TestPaymentReconciliation(unittest.TestCase): - pass + @classmethod + def setUpClass(cls): + make_customer() + make_invoice_and_payment() + + def test_payment_reconciliation(self): + payment_reco = frappe.get_doc("Payment Reconciliation") + payment_reco.company = "_Test Company" + payment_reco.party_type = "Customer" + payment_reco.party = "_Test Payment Reco Customer" + payment_reco.receivable_payable_account = "Debtors - _TC" + payment_reco.from_invoice_date = add_days(getdate(), -1) + payment_reco.to_invoice_date = getdate() + payment_reco.from_payment_date = add_days(getdate(), -1) + payment_reco.to_payment_date = getdate() + payment_reco.maximum_invoice_amount = 1000 + payment_reco.maximum_payment_amount = 1000 + payment_reco.invoice_limit = 10 + payment_reco.payment_limit = 10 + payment_reco.bank_cash_account = "_Test Bank - _TC" + payment_reco.cost_center = "_Test Cost Center - _TC" + payment_reco.get_unreconciled_entries() + + self.assertEqual(len(payment_reco.get("invoices")), 1) + self.assertEqual(len(payment_reco.get("payments")), 1) + + payment_entry = payment_reco.get("payments")[0].reference_name + invoice = payment_reco.get("invoices")[0].invoice_number + + payment_reco.allocate_entries( + { + "payments": [payment_reco.get("payments")[0].as_dict()], + "invoices": [payment_reco.get("invoices")[0].as_dict()], + } + ) + payment_reco.reconcile() + + payment_entry_doc = frappe.get_doc("Payment Entry", payment_entry) + self.assertEqual(payment_entry_doc.get("references")[0].reference_name, invoice) + + +def make_customer(): + if not frappe.db.get_value("Customer", "_Test Payment Reco Customer"): + frappe.get_doc( + { + "doctype": "Customer", + "customer_name": "_Test Payment Reco Customer", + "customer_type": "Individual", + "customer_group": "_Test Customer Group", + "territory": "_Test Territory", + } + ).insert() + + +def make_invoice_and_payment(): + si = create_sales_invoice( + customer="_Test Payment Reco Customer", qty=1, rate=690, do_not_save=True + ) + si.cost_center = "_Test Cost Center - _TC" + si.save() + si.submit() + + pe = frappe.get_doc( + { + "doctype": "Payment Entry", + "payment_type": "Receive", + "party_type": "Customer", + "party": "_Test Payment Reco Customer", + "company": "_Test Company", + "paid_from_account_currency": "INR", + "paid_to_account_currency": "INR", + "source_exchange_rate": 1, + "target_exchange_rate": 1, + "reference_no": "1", + "reference_date": getdate(), + "received_amount": 690, + "paid_amount": 690, + "paid_from": "Debtors - _TC", + "paid_to": "_Test Bank - _TC", + "cost_center": "_Test Cost Center - _TC", + } + ) + pe.insert() + pe.submit() diff --git a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py index f66cf1c9f1a..53b1c64c460 100644 --- a/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py +++ b/erpnext/accounts/doctype/period_closing_voucher/period_closing_voucher.py @@ -166,7 +166,7 @@ class PeriodClosingVoucher(AccountsController): sum(t1.debit_in_account_currency) - sum(t1.credit_in_account_currency) as bal_in_account_currency, sum(t1.debit) - sum(t1.credit) as bal_in_company_currency from `tabGL Entry` t1, `tabAccount` t2 - where t1.account = t2.name and t2.report_type = 'Profit and Loss' + where t1.is_cancelled = 0 and t1.account = t2.name and t2.report_type = 'Profit and Loss' and t2.docstatus < 2 and t2.company = %s and t1.posting_date between %s and %s group by t1.account, {dimension_fields} diff --git a/erpnext/buying/doctype/purchase_order/purchase_order.json b/erpnext/buying/doctype/purchase_order/purchase_order.json index 896208f25e1..239f4988303 100644 --- a/erpnext/buying/doctype/purchase_order/purchase_order.json +++ b/erpnext/buying/doctype/purchase_order/purchase_order.json @@ -23,6 +23,10 @@ "order_confirmation_no", "order_confirmation_date", "amended_from", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", "drop_ship", "customer", "customer_name", @@ -1138,16 +1142,39 @@ "fieldtype": "Link", "label": "Tax Withholding Category", "options": "Tax Withholding Category" + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions " + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" + }, + { + "fieldname": "project", + "fieldtype": "Link", + "label": "Project", + "options": "Project" } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2021-09-28 13:10:47.955401", + "modified": "2022-04-26 12:16:38.694276", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { @@ -1194,6 +1221,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "supplier", "title_field": "supplier_name", "track_changes": 1 diff --git a/erpnext/hooks.py b/erpnext/hooks.py index a21a0313544..ff120a52926 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -592,6 +592,9 @@ accounting_dimension_doctypes = [ "Subscription Plan", "POS Invoice", "POS Invoice Item", + "Purchase Order", + "Purchase Receipt", + "Sales Order", ] regional_overrides = { diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 87f48b7e257..f3cd864c908 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -5,7 +5,7 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, get_datetime +from frappe.utils import cint, get_datetime, get_link_to_form from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_actual_start_end_datetime_of_shift, @@ -127,19 +127,17 @@ def mark_attendance_and_link_log( log_names = [x.name for x in logs] employee = logs[0].employee if attendance_status == "Skip": - frappe.db.sql( - """update `tabEmployee Checkin` - set skip_auto_attendance = %s - where name in %s""", - ("1", log_names), - ) + skip_attendance_in_checkins(log_names) return None + elif attendance_status in ("Present", "Absent", "Half Day"): employee_doc = frappe.get_doc("Employee", employee) - if not frappe.db.exists( + duplicate = frappe.db.exists( "Attendance", {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")}, - ): + ) + + if not duplicate: doc_dict = { "doctype": "Attendance", "employee": employee, @@ -155,6 +153,12 @@ def mark_attendance_and_link_log( } attendance = frappe.get_doc(doc_dict).insert() attendance.submit() + + if attendance_status == "Absent": + attendance.add_comment( + text=_("Employee was marked Absent for not meeting the working hours threshold.") + ) + frappe.db.sql( """update `tabEmployee Checkin` set attendance = %s @@ -163,12 +167,10 @@ def mark_attendance_and_link_log( ) return attendance else: - frappe.db.sql( - """update `tabEmployee Checkin` - set skip_auto_attendance = %s - where name in %s""", - ("1", log_names), - ) + skip_attendance_in_checkins(log_names) + if duplicate: + add_comment_in_checkins(log_names, duplicate) + return None else: frappe.throw(_("{} is an invalid Attendance Status.").format(attendance_status)) @@ -237,3 +239,29 @@ def time_diff_in_hours(start, end): def find_index_in_dict(dict_list, key, value): return next((index for (index, d) in enumerate(dict_list) if d[key] == value), None) + + +def add_comment_in_checkins(log_names, duplicate): + text = _("Auto Attendance skipped due to duplicate attendance record: {}").format( + get_link_to_form("Attendance", duplicate) + ) + + for name in log_names: + frappe.get_doc( + { + "doctype": "Comment", + "comment_type": "Comment", + "reference_doctype": "Employee Checkin", + "reference_name": name, + "content": text, + } + ).insert(ignore_permissions=True) + + +def skip_attendance_in_checkins(log_names): + EmployeeCheckin = frappe.qb.DocType("Employee Checkin") + ( + frappe.qb.update(EmployeeCheckin) + .set("skip_auto_attendance", 1) + .where(EmployeeCheckin.name.isin(log_names)) + ).run() diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 5a1248698c2..868be6ef719 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -84,7 +84,7 @@ class ShiftAssignment(Document): @frappe.whitelist() def get_events(start, end, filters=None): - events = [] + from frappe.desk.calendar import get_event_conditions employee = frappe.db.get_value( "Employee", {"user_id": frappe.session.user}, ["name", "company"], as_dict=True @@ -95,20 +95,22 @@ def get_events(start, end, filters=None): employee = "" company = frappe.db.get_value("Global Defaults", None, "default_company") - from frappe.desk.reportview import get_filters_cond - - conditions = get_filters_cond("Shift Assignment", filters, []) - add_assignments(events, start, end, conditions=conditions) + conditions = get_event_conditions("Shift Assignment", filters) + events = add_assignments(start, end, conditions=conditions) return events -def add_assignments(events, start, end, conditions=None): +def add_assignments(start, end, conditions=None): + events = [] + query = """select name, start_date, end_date, employee_name, employee, docstatus, shift_type from `tabShift Assignment` where - start_date >= %(start_date)s - or end_date <= %(end_date)s - or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date) + ( + start_date >= %(start_date)s + or end_date <= %(end_date)s + or (%(start_date)s between start_date and end_date and %(end_date)s between start_date and end_date) + ) and docstatus = 1""" if conditions: query += conditions diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 4a1ec293bde..ea37424762b 100644 --- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py @@ -4,14 +4,22 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, nowdate +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.shift_assignment.shift_assignment import get_events + test_dependencies = ["Shift Type"] -class TestShiftAssignment(unittest.TestCase): +class TestShiftAssignment(FrappeTestCase): def setUp(self): - frappe.db.sql("delete from `tabShift Assignment`") + frappe.db.delete("Shift Assignment") + if not frappe.db.exists("Shift Type", "Day Shift"): + frappe.get_doc( + {"doctype": "Shift Type", "name": "Day Shift", "start_time": "9:00:00", "end_time": "18:00:00"} + ).insert() def test_make_shift_assignment(self): shift_assignment = frappe.get_doc( @@ -86,3 +94,36 @@ class TestShiftAssignment(unittest.TestCase): ) self.assertRaises(frappe.ValidationError, shift_assignment_3.save) + + def test_shift_assignment_calendar(self): + employee1 = make_employee("test_shift_assignment1@example.com", company="_Test Company") + employee2 = make_employee("test_shift_assignment2@example.com", company="_Test Company") + date = nowdate() + + shift_1 = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee1, + "start_date": date, + "status": "Active", + } + ).submit() + + frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee2, + "start_date": date, + "status": "Active", + } + ).submit() + + events = get_events( + start=date, end=date, filters=[["Shift Assignment", "employee", "=", employee1, False]] + ) + self.assertEqual(len(events), 1) + self.assertEqual(events[0]["name"], shift_1.name) diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 3f5cb222bfa..2000eeb5443 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -139,7 +139,17 @@ class ShiftType(Document): for date in dates: shift_details = get_employee_shift(employee, date, True) if shift_details and shift_details.shift_type.name == self.name: - mark_attendance(employee, date, "Absent", self.name) + attendance = mark_attendance(employee, date, "Absent", self.name) + if attendance: + frappe.get_doc( + { + "doctype": "Comment", + "comment_type": "Comment", + "reference_doctype": "Attendance", + "reference_name": attendance, + "content": frappe._("Employee was marked Absent due to missing Employee Checkins."), + } + ).insert(ignore_permissions=True) def get_assigned_employee(self, from_date=None, consider_default_shift=False): filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"} diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 841c59b78a2..d90a5e19488 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -358,5 +358,8 @@ erpnext.patches.v13_0.rename_non_profit_fields erpnext.patches.v13_0.enable_ksa_vat_docs #1 erpnext.patches.v13_0.create_gst_custom_fields_in_quotation erpnext.patches.v13_0.update_expense_claim_status_for_paid_advances +erpnext.patches.v13_0.change_default_item_manufacturer_fieldtype erpnext.patches.v13_0.set_return_against_in_pos_invoice_references -erpnext.patches.v13_0.copy_custom_field_filters_to_website_item \ No newline at end of file +erpnext.patches.v13_0.copy_custom_field_filters_to_website_item +erpnext.patches.v13_0.education_deprecation_warning +erpnext.patches.v13_0.create_accounting_dimensions_in_orders diff --git a/erpnext/patches/v13_0/change_default_item_manufacturer_fieldtype.py b/erpnext/patches/v13_0/change_default_item_manufacturer_fieldtype.py new file mode 100644 index 00000000000..0b00188e6a8 --- /dev/null +++ b/erpnext/patches/v13_0/change_default_item_manufacturer_fieldtype.py @@ -0,0 +1,16 @@ +import frappe + + +def execute(): + + # Erase all default item manufacturers that dont exist. + item = frappe.qb.DocType("Item") + manufacturer = frappe.qb.DocType("Manufacturer") + + ( + frappe.qb.update(item) + .set(item.default_item_manufacturer, None) + .left_join(manufacturer) + .on(item.default_item_manufacturer == manufacturer.name) + .where(manufacturer.name.isnull() & item.default_item_manufacturer.isnotnull()) + ).run() diff --git a/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py b/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py new file mode 100644 index 00000000000..8a3f1d0a58f --- /dev/null +++ b/erpnext/patches/v13_0/create_accounting_dimensions_in_orders.py @@ -0,0 +1,39 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_field + + +def execute(): + accounting_dimensions = frappe.db.get_all( + "Accounting Dimension", fields=["fieldname", "label", "document_type", "disabled"] + ) + + if not accounting_dimensions: + return + + count = 1 + for d in accounting_dimensions: + + if count % 2 == 0: + insert_after_field = "dimension_col_break" + else: + insert_after_field = "accounting_dimensions_section" + + for doctype in ["Purchase Order", "Purchase Receipt", "Sales Order"]: + + field = frappe.db.get_value("Custom Field", {"dt": doctype, "fieldname": d.fieldname}) + + if field: + continue + + df = { + "fieldname": d.fieldname, + "label": d.label, + "fieldtype": "Link", + "options": d.document_type, + "insert_after": insert_after_field, + } + + create_custom_field(doctype, df, ignore_validate=False) + frappe.clear_cache(doctype=doctype) + + count += 1 diff --git a/erpnext/patches/v13_0/education_deprecation_warning.py b/erpnext/patches/v13_0/education_deprecation_warning.py new file mode 100644 index 00000000000..96602ebdf94 --- /dev/null +++ b/erpnext/patches/v13_0/education_deprecation_warning.py @@ -0,0 +1,10 @@ +import click + + +def execute(): + + click.secho( + "Education Domain is moved to a separate app and will be removed from ERPNext in version-14.\n" + "When upgrading to ERPNext version-14, please install the app to continue using the Education domain: https://github.com/frappe/education", + fg="yellow", + ) diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py index 1962117608b..b3e4ad54aa5 100644 --- a/erpnext/payroll/doctype/salary_slip/salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -376,13 +376,19 @@ class SalarySlip(TransactionBase): if joining_date and (getdate(self.start_date) < joining_date <= getdate(self.end_date)): start_date = joining_date unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving( - unmarked_days, include_holidays_in_total_working_days, self.start_date, joining_date + unmarked_days, + include_holidays_in_total_working_days, + self.start_date, + add_days(joining_date, -1), ) if relieving_date and (getdate(self.start_date) <= relieving_date < getdate(self.end_date)): end_date = relieving_date unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving( - unmarked_days, include_holidays_in_total_working_days, relieving_date, self.end_date + unmarked_days, + include_holidays_in_total_working_days, + add_days(relieving_date, 1), + self.end_date, ) # exclude days for which attendance has been marked @@ -408,10 +414,10 @@ class SalarySlip(TransactionBase): from erpnext.hr.doctype.employee.employee import is_holiday if include_holidays_in_total_working_days: - unmarked_days -= date_diff(end_date, start_date) + unmarked_days -= date_diff(end_date, start_date) + 1 else: # exclude only if not holidays - for days in range(date_diff(end_date, start_date)): + for days in range(date_diff(end_date, start_date) + 1): date = add_days(end_date, -days) if not is_holiday(self.employee, date): unmarked_days -= 1 diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 65dae625b6e..0abf58b062c 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -128,6 +128,72 @@ class TestSalarySlip(unittest.TestCase): }, ) def test_payment_days_for_mid_joinee_including_holidays(self): + no_of_days = self.get_no_of_days() + month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate()) + + new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") + joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5) + + for days in range(date_diff(month_end_date, month_start_date) + 1): + date = add_days(month_start_date, days) + mark_attendance(new_emp_id, date, "Present", ignore_validate=True) + + # Case 1: relieving in mid month + frappe.db.set_value( + "Employee", + new_emp_id, + {"date_of_joining": month_start_date, "relieving_date": relieving_date, "status": "Active"}, + ) + + new_ss = make_employee_salary_slip( + "test_payment_days_based_on_joining_date@salary.com", + "Monthly", + "Test Payment Based On Attendence", + ) + self.assertEqual(new_ss.payment_days, no_of_days[0] - 5) + + # Case 2: joining in mid month + frappe.db.set_value( + "Employee", + new_emp_id, + {"date_of_joining": joining_date, "relieving_date": month_end_date, "status": "Active"}, + ) + + frappe.delete_doc("Salary Slip", new_ss.name, force=True) + new_ss = make_employee_salary_slip( + "test_payment_days_based_on_joining_date@salary.com", + "Monthly", + "Test Payment Based On Attendence", + ) + self.assertEqual(new_ss.payment_days, no_of_days[0] - 3) + + # Case 3: joining and relieving in mid-month + frappe.db.set_value( + "Employee", + new_emp_id, + {"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"}, + ) + + frappe.delete_doc("Salary Slip", new_ss.name, force=True) + new_ss = make_employee_salary_slip( + "test_payment_days_based_on_joining_date@salary.com", + "Monthly", + "Test Payment Based On Attendence", + ) + + self.assertEqual(new_ss.total_working_days, no_of_days[0]) + self.assertEqual(new_ss.payment_days, no_of_days[0] - 8) + + @change_settings( + "Payroll Settings", + { + "payroll_based_on": "Attendance", + "consider_unmarked_attendance_as": "Absent", + "include_holidays_in_total_working_days": True, + }, + ) + def test_payment_days_for_mid_joinee_including_holidays_and_unmarked_days(self): + # tests mid month joining and relieving along with unmarked days from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday no_of_days = self.get_no_of_days() @@ -135,12 +201,6 @@ class TestSalarySlip(unittest.TestCase): new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com") joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5) - frappe.db.set_value( - "Employee", - new_emp_id, - {"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"}, - ) - holidays = 0 for days in range(date_diff(relieving_date, joining_date) + 1): @@ -150,6 +210,12 @@ class TestSalarySlip(unittest.TestCase): else: holidays += 1 + frappe.db.set_value( + "Employee", + new_emp_id, + {"date_of_joining": joining_date, "relieving_date": relieving_date, "status": "Left"}, + ) + new_ss = make_employee_salary_slip( "test_payment_days_based_on_joining_date@salary.com", "Monthly", diff --git a/erpnext/projects/doctype/project/project.py b/erpnext/projects/doctype/project/project.py index 2a3e31f2891..6b691786a1a 100644 --- a/erpnext/projects/doctype/project/project.py +++ b/erpnext/projects/doctype/project/project.py @@ -84,6 +84,7 @@ class Project(Document): type=task_details.type, issue=task_details.issue, is_group=task_details.is_group, + color=task_details.color, ) ).insert() diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index 060c54e110b..64964853be6 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -58,6 +58,7 @@ def validate_eligibility(doc): invalid_company = not frappe.db.get_value("E Invoice User", {"company": doc.get("company")}) invalid_supply_type = doc.get("gst_category") not in [ "Registered Regular", + "Registered Composition", "SEZ", "Overseas", "Deemed Export", @@ -125,24 +126,33 @@ def read_json(name): def get_transaction_details(invoice): supply_type = "" - if invoice.gst_category == "Registered Regular": + if ( + invoice.gst_category == "Registered Regular" or invoice.gst_category == "Registered Composition" + ): supply_type = "B2B" elif invoice.gst_category == "SEZ": - supply_type = "SEZWOP" + if invoice.export_type == "Without Payment of Tax": + supply_type = "SEZWOP" + else: + supply_type = "SEZWP" elif invoice.gst_category == "Overseas": - supply_type = "EXPWOP" + if invoice.export_type == "Without Payment of Tax": + supply_type = "EXPWOP" + else: + supply_type = "EXPWP" elif invoice.gst_category == "Deemed Export": supply_type = "DEXP" if not supply_type: - rr, sez, overseas, export = ( + rr, rc, sez, overseas, export = ( bold("Registered Regular"), + bold("Registered Composition"), bold("SEZ"), bold("Overseas"), bold("Deemed Export"), ) frappe.throw( - _("GST category should be one of {}, {}, {}, {}").format(rr, sez, overseas, export), + _("GST category should be one of {}, {}, {}, {}, {}").format(rr, rc, sez, overseas, export), title=_("Invalid Supply Type"), ) diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 8e3d8e3c8a7..82d734d845c 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -714,7 +714,7 @@ def get_custom_fields(): insert_after="customer", no_copy=1, print_hide=1, - depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0', + depends_on='eval:in_list(["Registered Regular", "Registered Composition", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0', ), dict( fieldname="irn_cancelled", diff --git a/erpnext/regional/report/vat_audit_report/vat_audit_report.py b/erpnext/regional/report/vat_audit_report/vat_audit_report.py index 6e5982465cf..70f2c0a3339 100644 --- a/erpnext/regional/report/vat_audit_report/vat_audit_report.py +++ b/erpnext/regional/report/vat_audit_report/vat_audit_report.py @@ -95,10 +95,9 @@ class VATAuditReport(object): as_dict=1, ) for d in items: - if d.item_code not in self.invoice_items.get(d.parent, {}): - self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0}) - self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0) - self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated + self.invoice_items.setdefault(d.parent, {}).setdefault(d.item_code, {"net_amount": 0.0}) + self.invoice_items[d.parent][d.item_code]["net_amount"] += d.get("base_net_amount", 0) + self.invoice_items[d.parent][d.item_code]["is_zero_rated"] = d.is_zero_rated def get_items_based_on_tax_rate(self, doctype): self.items_based_on_tax_rate = frappe._dict() @@ -110,7 +109,7 @@ class VATAuditReport(object): self.tax_details = frappe.db.sql( """ SELECT - parent, account_head, item_wise_tax_detail, base_tax_amount_after_discount_amount + parent, account_head, item_wise_tax_detail FROM `tab%s` WHERE @@ -123,7 +122,7 @@ class VATAuditReport(object): tuple([doctype] + list(self.invoices.keys())), ) - for parent, account, item_wise_tax_detail, tax_amount in self.tax_details: + for parent, account, item_wise_tax_detail in self.tax_details: if item_wise_tax_detail: try: if account in self.sa_vat_accounts: @@ -135,7 +134,7 @@ class VATAuditReport(object): # to skip items with non-zero tax rate in multiple rows if taxes[0] == 0 and not is_zero_rated: continue - tax_rate, item_amount_map = self.get_item_amount_map(parent, item_code, taxes) + tax_rate = self.get_item_amount_map(parent, item_code, taxes) if tax_rate is not None: rate_based_dict = self.items_based_on_tax_rate.setdefault(parent, {}).setdefault( @@ -151,16 +150,22 @@ class VATAuditReport(object): tax_rate = taxes[0] tax_amount = taxes[1] gross_amount = net_amount + tax_amount - item_amount_map = self.item_tax_rate.setdefault(parent, {}).setdefault(item_code, []) - amount_dict = { - "tax_rate": tax_rate, - "gross_amount": gross_amount, - "tax_amount": tax_amount, - "net_amount": net_amount, - } - item_amount_map.append(amount_dict) - return tax_rate, item_amount_map + self.item_tax_rate.setdefault(parent, {}).setdefault( + item_code, + { + "tax_rate": tax_rate, + "gross_amount": 0.0, + "tax_amount": 0.0, + "net_amount": 0.0, + }, + ) + + self.item_tax_rate[parent][item_code]["net_amount"] += net_amount + self.item_tax_rate[parent][item_code]["tax_amount"] += tax_amount + self.item_tax_rate[parent][item_code]["gross_amount"] += gross_amount + + return tax_rate def get_conditions(self): conditions = "" @@ -205,9 +210,10 @@ class VATAuditReport(object): for inv, inv_data in self.invoices.items(): if self.items_based_on_tax_rate.get(inv): for rate, items in self.items_based_on_tax_rate.get(inv).items(): + row = {"tax_amount": 0.0, "gross_amount": 0.0, "net_amount": 0.0} + consolidated_data_map.setdefault(rate, {"data": []}) for item in items: - row = {} item_details = self.item_tax_rate.get(inv).get(item) row["account"] = inv_data.get("account") row["posting_date"] = formatdate(inv_data.get("posting_date"), "dd-mm-yyyy") @@ -216,10 +222,11 @@ class VATAuditReport(object): row["party_type"] = "Customer" if doctype == "Sales Invoice" else "Supplier" row["party"] = inv_data.get("party") row["remarks"] = inv_data.get("remarks") - row["gross_amount"] = item_details[0].get("gross_amount") - row["tax_amount"] = item_details[0].get("tax_amount") - row["net_amount"] = item_details[0].get("net_amount") - consolidated_data_map[rate]["data"].append(row) + row["gross_amount"] += item_details.get("gross_amount") + row["tax_amount"] += item_details.get("tax_amount") + row["net_amount"] += item_details.get("net_amount") + + consolidated_data_map[rate]["data"].append(row) return consolidated_data_map diff --git a/erpnext/regional/united_arab_emirates/setup.py b/erpnext/regional/united_arab_emirates/setup.py index 2acab029149..4b9623f014e 100644 --- a/erpnext/regional/united_arab_emirates/setup.py +++ b/erpnext/regional/united_arab_emirates/setup.py @@ -245,7 +245,7 @@ def make_custom_fields(): "Supplier Quotation Item": invoice_item_fields, } - create_custom_fields(custom_fields) + create_custom_fields(custom_fields, ignore_validate=True) def add_print_formats(): diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js index c15c917f828..12fc3c74d9c 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.js +++ b/erpnext/selling/doctype/sales_order/sales_order.js @@ -152,7 +152,9 @@ erpnext.selling.SalesOrderController = erpnext.selling.SellingController.extend( } } - this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + if (flt(doc.per_picked, 6) < 100 && flt(doc.per_delivered, 6) < 100) { + this.frm.add_custom_button(__('Pick List'), () => this.create_pick_list(), __('Create')); + } const order_is_a_sale = ["Sales", "Shopping Cart"].indexOf(doc.order_type) !== -1; const order_is_maintenance = ["Maintenance"].indexOf(doc.order_type) !== -1; diff --git a/erpnext/selling/doctype/sales_order/sales_order.json b/erpnext/selling/doctype/sales_order/sales_order.json index fe2f14e19a6..6bcc8f05ac3 100644 --- a/erpnext/selling/doctype/sales_order/sales_order.json +++ b/erpnext/selling/doctype/sales_order/sales_order.json @@ -25,6 +25,10 @@ "po_no", "po_date", "tax_id", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", "contact_info", "customer_address", "address_display", @@ -113,7 +117,6 @@ "is_internal_customer", "represents_company", "inter_company_order_reference", - "project", "party_account_currency", "column_break_77", "source", @@ -1520,14 +1523,31 @@ "fieldname": "per_picked", "fieldtype": "Percent", "label": "% Picked", + "no_copy": 1, "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" } ], "icon": "fa fa-file-text", "idx": 105, "is_submittable": 1, "links": [], - "modified": "2022-03-15 21:38:31.437586", + "modified": "2022-04-26 14:38:18.350207", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order", @@ -1606,4 +1626,4 @@ "title_field": "customer_name", "track_changes": 1, "track_seen": 1 -} \ No newline at end of file +} diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 3cb422c587d..edd7d26d0bc 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -455,6 +455,16 @@ class SalesOrder(SellingController): if tot_qty != 0: self.db_set("per_delivered", flt(delivered_qty / tot_qty) * 100, update_modified=False) + def update_picking_status(self): + total_picked_qty = 0.0 + total_qty = 0.0 + for so_item in self.items: + total_picked_qty += flt(so_item.picked_qty) + total_qty += flt(so_item.stock_qty) + per_picked = total_picked_qty / total_qty * 100 + + self.db_set("per_picked", flt(per_picked), update_modified=False) + def set_indicator(self): """Set indicator for portal""" if self.per_billed < 100 and self.per_delivered < 100: @@ -1302,9 +1312,30 @@ def make_inter_company_purchase_order(source_name, target_doc=None): @frappe.whitelist() def create_pick_list(source_name, target_doc=None): - def update_item_quantity(source, target, source_parent): - target.qty = flt(source.qty) - flt(source.delivered_qty) - target.stock_qty = (flt(source.qty) - flt(source.delivered_qty)) * flt(source.conversion_factor) + from erpnext.stock.doctype.packed_item.packed_item import is_product_bundle + + def update_item_quantity(source, target, source_parent) -> None: + picked_qty = flt(source.picked_qty) / (flt(source.conversion_factor) or 1) + qty_to_be_picked = flt(source.qty) - max(picked_qty, flt(source.delivered_qty)) + + target.qty = qty_to_be_picked + target.stock_qty = qty_to_be_picked * flt(source.conversion_factor) + + def update_packed_item_qty(source, target, source_parent) -> None: + qty = flt(source.qty) + for item in source_parent.items: + if source.parent_detail_docname == item.name: + picked_qty = flt(item.picked_qty) / (flt(item.conversion_factor) or 1) + pending_percent = (item.qty - max(picked_qty, item.delivered_qty)) / item.qty + target.qty = target.stock_qty = qty * pending_percent + return + + def should_pick_order_item(item) -> bool: + return ( + abs(item.delivered_qty) < abs(item.qty) + and item.delivered_by_supplier != 1 + and not is_product_bundle(item.item_code) + ) doc = get_mapped_doc( "Sales Order", @@ -1315,8 +1346,17 @@ def create_pick_list(source_name, target_doc=None): "doctype": "Pick List Item", "field_map": {"parent": "sales_order", "name": "sales_order_item"}, "postprocess": update_item_quantity, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) - and doc.delivered_by_supplier != 1, + "condition": should_pick_order_item, + }, + "Packed Item": { + "doctype": "Pick List Item", + "field_map": { + "parent": "sales_order", + "name": "sales_order_item", + "parent_detail_docname": "product_bundle_item", + }, + "field_no_map": ["picked_qty"], + "postprocess": update_packed_item_qty, }, }, target_doc, diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json index f13cc7e5c2a..21abb94557c 100644 --- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json +++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json @@ -801,13 +801,15 @@ { "fieldname": "picked_qty", "fieldtype": "Float", - "label": "Picked Qty" + "label": "Picked Qty (in Stock UOM)", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "istable": 1, "links": [], - "modified": "2022-03-15 20:17:33.984799", + "modified": "2022-04-27 03:15:34.366563", "modified_by": "Administrator", "module": "Selling", "name": "Sales Order Item", diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index 65e0cbb7a5c..7a6838680f0 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -343,9 +343,9 @@ erpnext.PointOfSale.Controller = class { toggle_other_sections: (show) => { if (show) { this.item_details.$component.is(':visible') ? this.item_details.$component.css('display', 'none') : ''; - this.item_selector.$component.css('display', 'none'); + this.item_selector.toggle_component(false); } else { - this.item_selector.$component.css('display', 'flex'); + this.item_selector.toggle_component(true); } }, diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 4a99f068cd5..eacf480ef8f 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -130,10 +130,10 @@ erpnext.PointOfSale.ItemCart = class { }, cols: 5, keys: [ - [ 1, 2, 3, __('Quantity') ], - [ 4, 5, 6, __('Discount') ], - [ 7, 8, 9, __('Rate') ], - [ '.', 0, __('Delete'), __('Remove') ] + [ 1, 2, 3, 'Quantity' ], + [ 4, 5, 6, 'Discount' ], + [ 7, 8, 9, 'Rate' ], + [ '.', 0, 'Delete', 'Remove' ] ], css_classes: [ [ '', '', '', 'col-span-2' ], diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index b62b27bc4b3..7a90fb044f3 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -179,6 +179,25 @@ erpnext.PointOfSale.ItemSelector = class { }); this.search_field.toggle_label(false); this.item_group_field.toggle_label(false); + + this.attach_clear_btn(); + } + + attach_clear_btn() { + this.search_field.$wrapper.find('.control-input').append( + ` + + ${frappe.utils.icon('close', 'sm')} + + ` + ); + + this.$clear_search_btn = this.search_field.$wrapper.find('.link-btn'); + + this.$clear_search_btn.on('click', 'a', () => { + this.set_search_value(''); + this.search_field.set_focus(); + }); } set_search_value(value) { @@ -252,6 +271,16 @@ erpnext.PointOfSale.ItemSelector = class { const search_term = e.target.value; this.filter_items({ search_term }); }, 300); + + this.$clear_search_btn.toggle( + Boolean(this.search_field.$input.val()) + ); + }); + + this.search_field.$input.on('focus', () => { + this.$clear_search_btn.toggle( + Boolean(this.search_field.$input.val()) + ); }); } @@ -284,7 +313,7 @@ erpnext.PointOfSale.ItemSelector = class { if (this.items.length == 1) { this.$items_container.find(".item-wrapper").click(); frappe.utils.play_sound("submit"); - $(this.search_field.$input[0]).val("").trigger("input"); + this.set_search_value(''); } else if (this.items.length == 0 && this.barcode_scanned) { // only show alert of barcode is scanned and enter is pressed frappe.show_alert({ @@ -293,7 +322,7 @@ erpnext.PointOfSale.ItemSelector = class { }); frappe.utils.play_sound("error"); this.barcode_scanned = false; - $(this.search_field.$input[0]).val("").trigger("input"); + this.set_search_value(''); } }); } @@ -350,6 +379,7 @@ erpnext.PointOfSale.ItemSelector = class { } toggle_component(show) { - show ? this.$component.css('display', 'flex') : this.$component.css('display', 'none'); + this.set_search_value(''); + this.$component.css('display', show ? 'flex': 'none'); } }; diff --git a/erpnext/selling/page/point_of_sale/pos_number_pad.js b/erpnext/selling/page/point_of_sale/pos_number_pad.js index 95293d1dd5c..f27b0d55ef6 100644 --- a/erpnext/selling/page/point_of_sale/pos_number_pad.js +++ b/erpnext/selling/page/point_of_sale/pos_number_pad.js @@ -25,7 +25,7 @@ erpnext.PointOfSale.NumberPad = class { const fieldname = fieldnames && fieldnames[number] ? fieldnames[number] : typeof number === 'string' ? frappe.scrub(number) : number; - return a2 + `
${number}
`; + return a2 + `
${__(number)}
`; }, ''); }, ''); } diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 7ebc4eed751..e3222bc8850 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -23,6 +23,10 @@ "is_return", "issue_credit_note", "return_against", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", "customer_po_details", "po_no", "column_break_17", @@ -115,7 +119,6 @@ "driver_name", "lr_date", "more_info", - "project", "campaign", "source", "column_break5", @@ -1309,13 +1312,29 @@ "fieldtype": "Currency", "label": "Amount Eligible for Commission", "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" } ], "icon": "fa fa-truck", "idx": 146, "is_submittable": 1, "links": [], - "modified": "2022-03-10 14:29:13.428984", + "modified": "2022-04-26 14:48:08.781837", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", @@ -1380,6 +1399,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "customer", "title_field": "title", "track_changes": 1, diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index b5a45578c6d..f97e7ca9c68 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -78,56 +78,6 @@ class TestDeliveryNote(FrappeTestCase): self.assertFalse(get_gl_entries("Delivery Note", dn.name)) - # def test_delivery_note_gl_entry(self): - # company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') - - # set_valuation_method("_Test Item", "FIFO") - - # make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100) - - # stock_in_hand_account = get_inventory_account('_Test Company with perpetual inventory') - # prev_bal = get_balance_on(stock_in_hand_account) - - # dn = create_delivery_note(company='_Test Company with perpetual inventory', warehouse='Stores - TCP1', cost_center = 'Main - TCP1', expense_account = "Cost of Goods Sold - TCP1") - - # gl_entries = get_gl_entries("Delivery Note", dn.name) - # self.assertTrue(gl_entries) - - # stock_value_difference = abs(frappe.db.get_value("Stock Ledger Entry", - # {"voucher_type": "Delivery Note", "voucher_no": dn.name}, "stock_value_difference")) - - # expected_values = { - # stock_in_hand_account: [0.0, stock_value_difference], - # "Cost of Goods Sold - TCP1": [stock_value_difference, 0.0] - # } - # for i, gle in enumerate(gl_entries): - # self.assertEqual([gle.debit, gle.credit], expected_values.get(gle.account)) - - # # check stock in hand balance - # bal = get_balance_on(stock_in_hand_account) - # self.assertEqual(bal, prev_bal - stock_value_difference) - - # # back dated incoming entry - # make_stock_entry(posting_date=add_days(nowdate(), -2), target="Stores - TCP1", - # qty=5, basic_rate=100) - - # gl_entries = get_gl_entries("Delivery Note", dn.name) - # self.assertTrue(gl_entries) - - # stock_value_difference = abs(frappe.db.get_value("Stock Ledger Entry", - # {"voucher_type": "Delivery Note", "voucher_no": dn.name}, "stock_value_difference")) - - # expected_values = { - # stock_in_hand_account: [0.0, stock_value_difference], - # "Cost of Goods Sold - TCP1": [stock_value_difference, 0.0] - # } - # for i, gle in enumerate(gl_entries): - # self.assertEqual([gle.debit, gle.credit], expected_values.get(gle.account)) - - # dn.cancel() - # self.assertTrue(get_gl_entries("Delivery Note", dn.name)) - # set_perpetual_inventory(0, company) - def test_delivery_note_gl_entry_packing_item(self): company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") @@ -854,8 +804,6 @@ class TestDeliveryNote(FrappeTestCase): company="_Test Company with perpetual inventory", ) - company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") - set_valuation_method("_Test Item", "FIFO") make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100) @@ -881,8 +829,6 @@ class TestDeliveryNote(FrappeTestCase): def test_delivery_note_cost_center_with_balance_sheet_account(self): cost_center = "Main - TCP1" - company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") - set_valuation_method("_Test Item", "FIFO") make_stock_entry(target="Stores - TCP1", qty=5, basic_rate=100) diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index e6f1f0a2952..06baa0fe912 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -918,8 +918,9 @@ }, { "fieldname": "default_item_manufacturer", - "fieldtype": "Data", + "fieldtype": "Link", "label": "Default Item Manufacturer", + "options": "Manufacturer", "read_only": 1 }, { @@ -954,7 +955,7 @@ "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-14 04:13:16.857534", + "modified": "2022-04-28 04:52:10.272256", "modified_by": "Administrator", "module": "Stock", "name": "Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json index e94c34d7adc..cb8eb30cb30 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.json +++ b/erpnext/stock/doctype/packed_item/packed_item.json @@ -29,6 +29,7 @@ "ordered_qty", "column_break_16", "incoming_rate", + "picked_qty", "page_break", "prevdoc_doctype", "parent_detail_docname" @@ -234,13 +235,20 @@ "label": "Ordered Qty", "no_copy": 1, "read_only": 1 + }, + { + "fieldname": "picked_qty", + "fieldtype": "Float", + "label": "Picked Qty", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-03-10 15:42:00.265915", + "modified": "2022-04-27 05:23:08.683245", "modified_by": "Administrator", "module": "Stock", "name": "Packed Item", diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py index 026dd4e122a..4d05d7a345c 100644 --- a/erpnext/stock/doctype/packed_item/packed_item.py +++ b/erpnext/stock/doctype/packed_item/packed_item.py @@ -32,7 +32,7 @@ def make_packing_list(doc): reset = reset_packing_list(doc) for item_row in doc.get("items"): - if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}): + if is_product_bundle(item_row.item_code): for bundle_item in get_product_bundle_items(item_row.item_code): pi_row = add_packed_item_row( doc=doc, @@ -54,6 +54,10 @@ def make_packing_list(doc): set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item +def is_product_bundle(item_code: str) -> bool: + return bool(frappe.db.exists("Product Bundle", {"new_item_code": item_code})) + + def get_indexed_packed_items_table(doc): """ Create dict from stale packed items table like: diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py index fe1b0d9f792..ad7fd9a6976 100644 --- a/erpnext/stock/doctype/packed_item/test_packed_item.py +++ b/erpnext/stock/doctype/packed_item/test_packed_item.py @@ -1,10 +1,12 @@ # Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from typing import List, Optional, Tuple + +import frappe from frappe.tests.utils import FrappeTestCase, change_settings from frappe.utils import add_to_date, nowdate -from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.stock.doctype.item.test_item import make_item @@ -12,6 +14,33 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +def create_product_bundle( + quantities: Optional[List[int]] = None, warehouse: Optional[str] = None +) -> Tuple[str, List[str]]: + """Get a new product_bundle for use in tests. + + Create 10x required stock if warehouse is specified. + """ + if not quantities: + quantities = [2, 2] + + bundle = make_item(properties={"is_stock_item": 0}).name + + bundle_doc = frappe.get_doc({"doctype": "Product Bundle", "new_item_code": bundle}) + + components = [] + for qty in quantities: + compoenent = make_item().name + components.append(compoenent) + bundle_doc.append("items", {"item_code": compoenent, "qty": qty}) + if warehouse: + make_stock_entry(item=compoenent, to_warehouse=warehouse, qty=10 * qty, rate=100) + + bundle_doc.insert() + + return bundle, components + + class TestPackedItem(FrappeTestCase): "Test impact on Packed Items table in various scenarios." @@ -19,24 +48,11 @@ class TestPackedItem(FrappeTestCase): def setUpClass(cls) -> None: super().setUpClass() cls.warehouse = "_Test Warehouse - _TC" - cls.bundle = "_Test Product Bundle X" - cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"] - cls.bundle2 = "_Test Product Bundle Y" - cls.bundle2_items = ["_Test Bundle Item 3", "_Test Bundle Item 4"] + cls.bundle, cls.bundle_items = create_product_bundle(warehouse=cls.warehouse) + cls.bundle2, cls.bundle2_items = create_product_bundle(warehouse=cls.warehouse) - make_item(cls.bundle, {"is_stock_item": 0}) - make_item(cls.bundle2, {"is_stock_item": 0}) - for item in cls.bundle_items + cls.bundle2_items: - make_item(item, {"is_stock_item": 1}) - - make_item("_Test Normal Stock Item", {"is_stock_item": 1}) - - make_product_bundle(cls.bundle, cls.bundle_items, qty=2) - make_product_bundle(cls.bundle2, cls.bundle2_items, qty=2) - - for item in cls.bundle_items + cls.bundle2_items: - make_stock_entry(item_code=item, to_warehouse=cls.warehouse, qty=100, rate=100) + cls.normal_item = make_item().name def test_adding_bundle_item(self): "Test impact on packed items if bundle item row is added." @@ -58,7 +74,7 @@ class TestPackedItem(FrappeTestCase): self.assertEqual(so.packed_items[1].qty, 4) # change item code to non bundle item - so.items[0].item_code = "_Test Normal Stock Item" + so.items[0].item_code = self.normal_item so.save() self.assertEqual(len(so.packed_items), 0) diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index c604c711ef5..e984c082d48 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -114,6 +114,7 @@ "set_only_once": 1 }, { + "collapsible": 1, "fieldname": "print_settings_section", "fieldtype": "Section Break", "label": "Print Settings" @@ -129,7 +130,7 @@ ], "is_submittable": 1, "links": [], - "modified": "2021-10-05 15:08:40.369957", + "modified": "2022-04-21 07:56:40.646473", "modified_by": "Administrator", "module": "Stock", "name": "Pick List", @@ -199,5 +200,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/pick_list/pick_list.py b/erpnext/stock/doctype/pick_list/pick_list.py index d3476a88f05..858481aa7b9 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -4,13 +4,14 @@ import json from collections import OrderedDict, defaultdict from itertools import groupby -from operator import itemgetter +from typing import Dict, List, Set import frappe from frappe import _ from frappe.model.document import Document from frappe.model.mapper import map_child_doc from frappe.utils import cint, floor, flt, today +from frappe.utils.nestedset import get_descendants_of from erpnext.selling.doctype.sales_order.sales_order import ( make_delivery_note as create_delivery_note_from_sales_order, @@ -36,6 +37,7 @@ class PickList(Document): frappe.throw("Row " + str(location.idx) + " has been picked already!") def before_submit(self): + update_sales_orders = set() for item in self.locations: # if the user has not entered any picked qty, set it to stock_qty, before submit if item.picked_qty == 0: @@ -43,7 +45,8 @@ class PickList(Document): if item.sales_order_item: # update the picked_qty in SO Item - self.update_so(item.sales_order_item, item.picked_qty, item.item_code) + self.update_sales_order_item(item, item.picked_qty, item.item_code) + update_sales_orders.add(item.sales_order) if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"): continue @@ -63,18 +66,29 @@ class PickList(Document): title=_("Quantity Mismatch"), ) + self.update_bundle_picked_qty() + self.update_sales_order_picking_status(update_sales_orders) + def before_cancel(self): - # update picked_qty in SO Item on cancel of PL + """Deduct picked qty on cancelling pick list""" + updated_sales_orders = set() + for item in self.get("locations"): if item.sales_order_item: - self.update_so(item.sales_order_item, -1 * item.picked_qty, item.item_code) + self.update_sales_order_item(item, -1 * item.picked_qty, item.item_code) + updated_sales_orders.add(item.sales_order) + + self.update_bundle_picked_qty() + self.update_sales_order_picking_status(updated_sales_orders) + + def update_sales_order_item(self, item, picked_qty, item_code): + item_table = "Sales Order Item" if not item.product_bundle_item else "Packed Item" + stock_qty_field = "stock_qty" if not item.product_bundle_item else "qty" - def update_so(self, so_item, picked_qty, item_code): - so_doc = frappe.get_doc( - "Sales Order", frappe.db.get_value("Sales Order Item", so_item, "parent") - ) already_picked, actual_qty = frappe.db.get_value( - "Sales Order Item", so_item, ["picked_qty", "qty"] + item_table, + item.sales_order_item, + ["picked_qty", stock_qty_field], ) if self.docstatus == 1: @@ -82,23 +96,18 @@ class PickList(Document): 100 + flt(frappe.db.get_single_value("Stock Settings", "over_delivery_receipt_allowance")) ): frappe.throw( - "You are picking more than required quantity for " - + item_code - + ". Check if there is any other pick list created for " - + so_doc.name + _( + "You are picking more than required quantity for {}. Check if there is any other pick list created for {}" + ).format(item_code, item.sales_order) ) - frappe.db.set_value("Sales Order Item", so_item, "picked_qty", already_picked + picked_qty) + frappe.db.set_value(item_table, item.sales_order_item, "picked_qty", already_picked + picked_qty) - total_picked_qty = 0 - total_so_qty = 0 - for item in so_doc.get("items"): - total_picked_qty += flt(item.picked_qty) - total_so_qty += flt(item.stock_qty) - total_picked_qty = total_picked_qty + picked_qty - per_picked = total_picked_qty / total_so_qty * 100 - - so_doc.db_set("per_picked", flt(per_picked), update_modified=False) + @staticmethod + def update_sales_order_picking_status(sales_orders: Set[str]) -> None: + for sales_order in sales_orders: + if sales_order: + frappe.get_doc("Sales Order", sales_order).update_picking_status() @frappe.whitelist() def set_item_locations(self, save=False): @@ -108,7 +117,7 @@ class PickList(Document): from_warehouses = None if self.parent_warehouse: - from_warehouses = frappe.db.get_descendants("Warehouse", self.parent_warehouse) + from_warehouses = get_descendants_of("Warehouse", self.parent_warehouse) # Create replica before resetting, to handle empty table on update after submit. locations_replica = self.get("locations") @@ -189,8 +198,7 @@ class PickList(Document): frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) def before_print(self, settings=None): - if self.get("group_same_items"): - self.group_similar_items() + self.group_similar_items() def group_similar_items(self): group_item_qty = defaultdict(float) @@ -216,6 +224,58 @@ class PickList(Document): for idx, item in enumerate(self.locations, start=1): item.idx = idx + def update_bundle_picked_qty(self): + product_bundles = self._get_product_bundles() + product_bundle_qty_map = self._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, item_code in product_bundles.items(): + picked_qty = self._compute_picked_qty_for_bundle(so_row, product_bundle_qty_map[item_code]) + item_table = "Sales Order Item" + already_picked = frappe.db.get_value(item_table, so_row, "picked_qty") + frappe.db.set_value( + item_table, + so_row, + "picked_qty", + already_picked + (picked_qty * (1 if self.docstatus == 1 else -1)), + ) + + def _get_product_bundles(self) -> Dict[str, str]: + # Dict[so_item_row: item_code] + product_bundles = {} + for item in self.locations: + if not item.product_bundle_item: + continue + product_bundles[item.product_bundle_item] = frappe.db.get_value( + "Sales Order Item", + item.product_bundle_item, + "item_code", + ) + return product_bundles + + def _get_product_bundle_qty_map(self, bundles: List[str]) -> Dict[str, Dict[str, float]]: + # bundle_item_code: Dict[component, qty] + product_bundle_qty_map = {} + for bundle_item_code in bundles: + bundle = frappe.get_last_doc("Product Bundle", {"new_item_code": bundle_item_code}) + product_bundle_qty_map[bundle_item_code] = {item.item_code: item.qty for item in bundle.items} + return product_bundle_qty_map + + def _compute_picked_qty_for_bundle(self, bundle_row, bundle_items) -> int: + """Compute how many full bundles can be created from picked items.""" + precision = frappe.get_precision("Stock Ledger Entry", "qty_after_transaction") + + possible_bundles = [] + for item in self.locations: + if item.product_bundle_item != bundle_row: + continue + + qty_in_bundle = bundle_items.get(item.item_code) + if qty_in_bundle: + possible_bundles.append(item.picked_qty / qty_in_bundle) + else: + possible_bundles.append(0) + return int(flt(min(possible_bundles), precision or 6)) + def validate_item_locations(pick_list): if not pick_list.locations: @@ -449,22 +509,18 @@ def create_delivery_note(source_name, target_doc=None): for location in pick_list.locations: if location.sales_order: sales_orders.append( - [frappe.db.get_value("Sales Order", location.sales_order, "customer"), location.sales_order] + frappe.db.get_value( + "Sales Order", location.sales_order, ["customer", "name as sales_order"], as_dict=True + ) ) - # Group sales orders by customer - for key, keydata in groupby(sales_orders, key=itemgetter(0)): - sales_dict[key] = set([d[1] for d in keydata]) + + for customer, rows in groupby(sales_orders, key=lambda so: so["customer"]): + sales_dict[customer] = {row.sales_order for row in rows} if sales_dict: delivery_note = create_dn_with_so(sales_dict, pick_list) - is_item_wo_so = 0 - for location in pick_list.locations: - if not location.sales_order: - is_item_wo_so = 1 - break - if is_item_wo_so == 1: - # Create a DN for items without sales orders as well + if not all(item.sales_order for item in pick_list.locations): delivery_note = create_dn_wo_so(pick_list) frappe.msgprint(_("Delivery Note(s) created for the Pick List")) @@ -491,27 +547,30 @@ def create_dn_wo_so(pick_list): def create_dn_with_so(sales_dict, pick_list): delivery_note = None + item_table_mapper = { + "doctype": "Delivery Note Item", + "field_map": { + "rate": "rate", + "name": "so_detail", + "parent": "against_sales_order", + }, + "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) + and doc.delivered_by_supplier != 1, + } + for customer in sales_dict: for so in sales_dict[customer]: delivery_note = None delivery_note = create_delivery_note_from_sales_order(so, delivery_note, skip_item_mapping=True) - - item_table_mapper = { - "doctype": "Delivery Note Item", - "field_map": { - "rate": "rate", - "name": "so_detail", - "parent": "against_sales_order", - }, - "condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty) - and doc.delivered_by_supplier != 1, - } break if delivery_note: # map all items of all sales orders of that customer for so in sales_dict[customer]: map_pl_locations(pick_list, item_table_mapper, delivery_note, so) - delivery_note.insert(ignore_mandatory=True) + delivery_note.flags.ignore_mandatory = True + delivery_note.insert() + update_packed_item_details(pick_list, delivery_note) + delivery_note.save() return delivery_note @@ -519,28 +578,28 @@ def create_dn_with_so(sales_dict, pick_list): def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): for location in pick_list.locations: - if location.sales_order == sales_order: - if location.sales_order_item: - sales_order_item = frappe.get_cached_doc( - "Sales Order Item", {"name": location.sales_order_item} - ) - else: - sales_order_item = None + if location.sales_order != sales_order or location.product_bundle_item: + continue - source_doc, table_mapper = ( - [sales_order_item, item_mapper] if sales_order_item else [location, item_mapper] - ) + if location.sales_order_item: + sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item) + else: + sales_order_item = None - dn_item = map_child_doc(source_doc, delivery_note, table_mapper) + source_doc = sales_order_item or location - if dn_item: - dn_item.pick_list_item = location.name - dn_item.warehouse = location.warehouse - dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) - dn_item.batch_no = location.batch_no - dn_item.serial_no = location.serial_no + dn_item = map_child_doc(source_doc, delivery_note, item_mapper) - update_delivery_note_item(source_doc, dn_item, delivery_note) + if dn_item: + dn_item.pick_list_item = location.name + dn_item.warehouse = location.warehouse + dn_item.qty = flt(location.picked_qty) / (flt(location.conversion_factor) or 1) + dn_item.batch_no = location.batch_no + dn_item.serial_no = location.serial_no + + update_delivery_note_item(source_doc, dn_item, delivery_note) + + add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper) set_delivery_note_missing_values(delivery_note) delivery_note.pick_list = pick_list.name @@ -548,6 +607,50 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None): delivery_note.customer = frappe.get_value("Sales Order", sales_order, "customer") +def add_product_bundles_to_delivery_note( + pick_list: "PickList", delivery_note, item_mapper +) -> None: + """Add product bundles found in pick list to delivery note. + + When mapping pick list items, the bundle item itself isn't part of the + locations. Dynamically fetch and add parent bundle item into DN.""" + product_bundles = pick_list._get_product_bundles() + product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values()) + + for so_row, item_code in product_bundles.items(): + sales_order_item = frappe.get_doc("Sales Order Item", so_row) + dn_bundle_item = map_child_doc(sales_order_item, delivery_note, item_mapper) + dn_bundle_item.qty = pick_list._compute_picked_qty_for_bundle( + so_row, product_bundle_qty_map[item_code] + ) + update_delivery_note_item(sales_order_item, dn_bundle_item, delivery_note) + + +def update_packed_item_details(pick_list: "PickList", delivery_note) -> None: + """Update stock details on packed items table of delivery note.""" + + def _find_so_row(packed_item): + for item in delivery_note.items: + if packed_item.parent_detail_docname == item.name: + return item.so_detail + + def _find_pick_list_location(bundle_row, packed_item): + if not bundle_row: + return + for loc in pick_list.locations: + if loc.product_bundle_item == bundle_row and loc.item_code == packed_item.item_code: + return loc + + for packed_item in delivery_note.packed_items: + so_row = _find_so_row(packed_item) + location = _find_pick_list_location(so_row, packed_item) + if not location: + continue + packed_item.warehouse = location.warehouse + packed_item.batch_no = location.batch_no + packed_item.serial_no = location.serial_no + + @frappe.whitelist() def create_stock_entry(pick_list): pick_list = frappe.get_doc(json.loads(pick_list)) diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index ec5011b93d6..e8cebc8e622 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -3,18 +3,21 @@ import frappe from frappe import _dict - -test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"] - from frappe.tests.utils import FrappeTestCase -from erpnext.stock.doctype.item.test_item import create_item +from erpnext.selling.doctype.sales_order.sales_order import create_pick_list +from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order +from erpnext.stock.doctype.item.test_item import create_item, make_item +from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( EmptyStockReconciliationItemsError, ) +test_dependencies = ["Item", "Sales Invoice", "Stock Entry", "Batch"] + class TestPickList(FrappeTestCase): def test_pick_list_picks_warehouse_for_each_item(self): @@ -566,14 +569,79 @@ class TestPickList(FrappeTestCase): if dn_item.item_code == "_Test Item 2": self.assertEqual(dn_item.qty, 2) - # def test_pick_list_skips_items_in_expired_batch(self): - # pass + def test_picklist_with_multi_uom(self): + warehouse = "_Test Warehouse - _TC" + item = make_item(properties={"uoms": [dict(uom="Box", conversion_factor=24)]}).name + make_stock_entry(item=item, to_warehouse=warehouse, qty=1000) - # def test_pick_list_from_sales_order(self): - # pass + so = make_sales_order(item_code=item, qty=10, rate=42, uom="Box") + pl = create_pick_list(so.name) + # pick half the qty + for loc in pl.locations: + loc.picked_qty = loc.stock_qty / 2 + pl.save() + pl.submit() - # def test_pick_list_from_work_order(self): - # pass + so.reload() + self.assertEqual(so.per_picked, 50) - # def test_pick_list_from_material_request(self): - # pass + def test_picklist_with_bundles(self): + warehouse = "_Test Warehouse - _TC" + + quantities = [5, 2] + bundle, components = create_product_bundle(quantities, warehouse=warehouse) + bundle_items = dict(zip(components, quantities)) + + so = make_sales_order(item_code=bundle, qty=3, rate=42) + + pl = create_pick_list(so.name) + pl.save() + self.assertEqual(len(pl.locations), 2) + for item in pl.locations: + self.assertEqual(item.stock_qty, bundle_items[item.item_code] * 3) + + # check picking status on sales order + pl.submit() + so.reload() + self.assertEqual(so.per_picked, 100) + + # deliver + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + self.assertEqual(dn.packed_items[0].warehouse, warehouse) + so.reload() + self.assertEqual(so.per_delivered, 100) + + def test_picklist_with_partial_bundles(self): + # from test_records.json + warehouse = "_Test Warehouse - _TC" + + quantities = [5, 2] + bundle, components = create_product_bundle(quantities, warehouse=warehouse) + + so = make_sales_order(item_code=bundle, qty=4, rate=42) + + pl = create_pick_list(so.name) + for loc in pl.locations: + loc.picked_qty = loc.qty / 2 + + pl.save().submit() + so.reload() + self.assertEqual(so.per_picked, 50) + + # deliver half qty + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + so.reload() + self.assertEqual(so.per_delivered, 50) + + pl = create_pick_list(so.name) + pl.save().submit() + so.reload() + self.assertEqual(so.per_picked, 100) + + # deliver remaining + dn = create_delivery_note(pl.name).submit() + self.assertEqual(dn.items[0].rate, 42) + so.reload() + self.assertEqual(so.per_delivered, 100) diff --git a/erpnext/stock/doctype/pick_list_item/pick_list_item.json b/erpnext/stock/doctype/pick_list_item/pick_list_item.json index 805286ddcc0..a96ebfcdee6 100644 --- a/erpnext/stock/doctype/pick_list_item/pick_list_item.json +++ b/erpnext/stock/doctype/pick_list_item/pick_list_item.json @@ -27,6 +27,7 @@ "column_break_15", "sales_order", "sales_order_item", + "product_bundle_item", "material_request", "material_request_item" ], @@ -146,6 +147,7 @@ { "fieldname": "sales_order_item", "fieldtype": "Data", + "hidden": 1, "label": "Sales Order Item", "read_only": 1 }, @@ -177,11 +179,19 @@ "fieldtype": "Data", "label": "Item Group", "read_only": 1 + }, + { + "description": "product bundle item row's name in sales order. Also indicates that picked item is to be used for a product bundle", + "fieldname": "product_bundle_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Product Bundle Item", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2021-09-28 12:02:16.923056", + "modified": "2022-04-22 05:27:38.497997", "modified_by": "Administrator", "module": "Stock", "name": "Pick List Item", @@ -190,5 +200,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json index 7c6189b1eb1..355f0e593ef 100755 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json @@ -24,6 +24,10 @@ "apply_putaway_rule", "is_return", "return_against", + "accounting_dimensions_section", + "cost_center", + "dimension_col_break", + "project", "section_addresses", "supplier_address", "contact_person", @@ -107,7 +111,6 @@ "bill_no", "bill_date", "more_info", - "project", "status", "amended_from", "range", @@ -1144,13 +1147,29 @@ "label": "Represents Company", "options": "Company", "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "accounting_dimensions_section", + "fieldtype": "Section Break", + "label": "Accounting Dimensions" + }, + { + "fieldname": "cost_center", + "fieldtype": "Link", + "label": "Cost Center", + "options": "Cost Center" + }, + { + "fieldname": "dimension_col_break", + "fieldtype": "Column Break" } ], "icon": "fa fa-truck", "idx": 261, "is_submittable": 1, "links": [], - "modified": "2022-04-10 22:50:37.761362", + "modified": "2022-04-26 13:41:32.625197", "modified_by": "Administrator", "module": "Stock", "name": "Purchase Receipt", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json index c38dfaa1c84..f56e059f81c 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.json +++ b/erpnext/stock/doctype/stock_entry/stock_entry.json @@ -46,9 +46,9 @@ "items", "get_stock_and_rate", "section_break_19", - "total_incoming_value", - "column_break_22", "total_outgoing_value", + "column_break_22", + "total_incoming_value", "value_difference", "additional_costs_section", "additional_costs", @@ -374,7 +374,7 @@ { "fieldname": "total_incoming_value", "fieldtype": "Currency", - "label": "Total Incoming Value", + "label": "Total Incoming Value (Receipt)", "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 @@ -386,7 +386,7 @@ { "fieldname": "total_outgoing_value", "fieldtype": "Currency", - "label": "Total Outgoing Value", + "label": "Total Outgoing Value (Consumption)", "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 @@ -394,7 +394,7 @@ { "fieldname": "value_difference", "fieldtype": "Currency", - "label": "Total Value Difference (Out - In)", + "label": "Total Value Difference (Incoming - Outgoing)", "options": "Company:company:default_currency", "print_hide_if_no_value": 1, "read_only": 1 @@ -619,7 +619,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-02-07 12:55:14.614077", + "modified": "2022-05-02 05:21:39.060501", "modified_by": "Administrator", "module": "Stock", "name": "Stock Entry", diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index e3af675178a..f0566b08897 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -557,7 +557,7 @@ class StockEntry(StockController): ) def set_actual_qty(self): - allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock")) + allow_negative_stock = cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) for d in self.get("items"): previous_sle = get_previous_sle( diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 45e840322b1..cb9b5abaa23 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -30,7 +30,6 @@ class TestStockReconciliation(FrappeTestCase): frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) def tearDown(self): - frappe.flags.dont_execute_stock_reposts = None frappe.local.future_sle = {} def test_reco_for_fifo(self): @@ -40,7 +39,9 @@ class TestStockReconciliation(FrappeTestCase): self._test_reco_sle_gle("Moving Average") def _test_reco_sle_gle(self, valuation_method): - se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1") + item_code = make_item(properties={"valuation_method": valuation_method}).name + + se1, se2, se3 = insert_existing_sle(warehouse="Stores - TCP1", item_code=item_code) company = frappe.db.get_value("Warehouse", "Stores - TCP1", "company") # [[qty, valuation_rate, posting_date, # posting_time, expected_stock_value, bin_qty, bin_valuation]] @@ -54,11 +55,9 @@ class TestStockReconciliation(FrappeTestCase): ] for d in input_data: - set_valuation_method("_Test Item", valuation_method) - last_sle = get_previous_sle( { - "item_code": "_Test Item", + "item_code": item_code, "warehouse": "Stores - TCP1", "posting_date": d[2], "posting_time": d[3], @@ -67,6 +66,7 @@ class TestStockReconciliation(FrappeTestCase): # submit stock reconciliation stock_reco = create_stock_reconciliation( + item_code=item_code, qty=d[0], rate=d[1], posting_date=d[2], @@ -480,9 +480,11 @@ class TestStockReconciliation(FrappeTestCase): """ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + frappe.db.rollback() + # repost will make this test useless, qty should update in realtime without reposts frappe.flags.dont_execute_stock_reposts = True - frappe.db.rollback() + self.addCleanup(frappe.flags.pop, "dont_execute_stock_reposts") item_code = make_item().name warehouse = "_Test Warehouse - _TC" @@ -593,26 +595,26 @@ def create_batch_item_with_batch(item_name, batch_id): b.save() -def insert_existing_sle(warehouse): +def insert_existing_sle(warehouse, item_code="_Test Item"): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry se1 = make_stock_entry( posting_date="2012-12-15", posting_time="02:00", - item_code="_Test Item", + item_code=item_code, target=warehouse, qty=10, basic_rate=700, ) se2 = make_stock_entry( - posting_date="2012-12-25", posting_time="03:00", item_code="_Test Item", source=warehouse, qty=15 + posting_date="2012-12-25", posting_time="03:00", item_code=item_code, source=warehouse, qty=15 ) se3 = make_stock_entry( posting_date="2013-01-05", posting_time="07:00", - item_code="_Test Item", + item_code=item_code, target=warehouse, qty=15, basic_rate=1200, diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index 3b18a9ac26f..df16643d460 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -36,6 +36,9 @@ class Warehouse(NestedSet): self.set_onload("account", account) load_address_and_contact(self) + def validate(self): + self.warn_about_multiple_warehouse_account() + def on_update(self): self.update_nsm_model() @@ -70,6 +73,53 @@ class Warehouse(NestedSet): self.update_nsm_model() self.unlink_from_items() + def warn_about_multiple_warehouse_account(self): + "If Warehouse value is split across multiple accounts, warn." + + def get_accounts_where_value_is_booked(name): + sle = frappe.qb.DocType("Stock Ledger Entry") + gle = frappe.qb.DocType("GL Entry") + ac = frappe.qb.DocType("Account") + + return ( + frappe.qb.from_(sle) + .join(gle) + .on(sle.voucher_no == gle.voucher_no) + .join(ac) + .on(ac.name == gle.account) + .select(gle.account) + .distinct() + .where((sle.warehouse == name) & (ac.account_type == "Stock")) + .orderby(sle.creation) + .run(as_dict=True) + ) + + if self.is_new(): + return + + old_wh_account = frappe.db.get_value("Warehouse", self.name, "account") + + # WH account is being changed or set get all accounts against which wh value is booked + if self.account != old_wh_account: + accounts = get_accounts_where_value_is_booked(self.name) + accounts = [d.account for d in accounts] + + if not accounts or (len(accounts) == 1 and self.account in accounts): + # if same singular account has stock value booked ignore + return + + warning = _("Warehouse's Stock Value has already been booked in the following accounts:") + account_str = "
" + ", ".join(frappe.bold(ac) for ac in accounts) + reason = "

" + _( + "Booking stock value across multiple accounts will make it harder to track stock and account value." + ) + + frappe.msgprint( + warning + account_str + reason, + title=_("Multiple Warehouse Accounts"), + indicator="orange", + ) + def check_if_sle_exists(self): return frappe.db.exists("Stock Ledger Entry", {"warehouse": self.name})