diff --git a/erpnext/accounts/custom/address.py b/erpnext/accounts/custom/address.py index 5e764037a7..c417a493c6 100644 --- a/erpnext/accounts/custom/address.py +++ b/erpnext/accounts/custom/address.py @@ -33,6 +33,8 @@ def get_shipping_address(company, address = None): if address and frappe.db.get_value('Dynamic Link', {'parent': address, 'link_name': company}): filters.append(["Address", "name", "=", address]) + if not address: + filters.append(["Address", "is_shipping_address", "=", 1]) address = frappe.get_all("Address", filters=filters, fields=fields) or {} diff --git a/erpnext/accounts/deferred_revenue.py b/erpnext/accounts/deferred_revenue.py index dd346bc240..335e8a15ab 100644 --- a/erpnext/accounts/deferred_revenue.py +++ b/erpnext/accounts/deferred_revenue.py @@ -263,6 +263,9 @@ def book_deferred_income_or_expense(doc, deferred_process, posting_date=None): amount, base_amount = calculate_amount(doc, item, last_gl_entry, total_days, total_booking_days, account_currency) + if not amount: + return + if via_journal_entry: book_revenue_via_journal_entry(doc, credit_account, debit_account, against, amount, base_amount, end_date, project, account_currency, item.cost_center, item, deferred_process, submit_journal_entry) @@ -298,17 +301,21 @@ def process_deferred_accounting(posting_date=None): start_date = add_months(today(), -1) end_date = add_days(today(), -1) - for record_type in ('Income', 'Expense'): - doc = frappe.get_doc(dict( - doctype='Process Deferred Accounting', - posting_date=posting_date, - start_date=start_date, - end_date=end_date, - type=record_type - )) + companies = frappe.get_all('Company') - doc.insert() - doc.submit() + for company in companies: + for record_type in ('Income', 'Expense'): + doc = frappe.get_doc(dict( + doctype='Process Deferred Accounting', + company=company.name, + posting_date=posting_date, + start_date=start_date, + end_date=end_date, + type=record_type + )) + + doc.insert() + doc.submit() def make_gl_entries(doc, credit_account, debit_account, against, amount, base_amount, posting_date, project, account_currency, cost_center, item, deferred_process=None): diff --git a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py index 7cd1e7736c..fac28c9239 100644 --- a/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py +++ b/erpnext/accounts/doctype/accounting_dimension/accounting_dimension.py @@ -19,7 +19,7 @@ class AccountingDimension(Document): def validate(self): if self.document_type in core_doctypes_list + ('Accounting Dimension', 'Project', - 'Cost Center', 'Accounting Dimension Detail', 'Company') : + 'Cost Center', 'Accounting Dimension Detail', 'Company', 'Account') : msg = _("Not allowed to create accounting dimension for {0}").format(self.document_type) frappe.throw(msg) diff --git a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py index 5f110e2727..ffc9d1c465 100644 --- a/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py +++ b/erpnext/accounts/doctype/bank_statement_import/bank_statement_import.py @@ -51,7 +51,7 @@ class BankStatementImport(DataImport): self.import_file, self.google_sheets_url ) - if 'Bank Account' not in json.dumps(preview): + if 'Bank Account' not in json.dumps(preview['columns']): frappe.throw(_("Please add the Bank Account column")) from frappe.core.page.background_jobs.background_jobs import get_info diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.json b/erpnext/accounts/doctype/payment_entry/payment_entry.json index 54623dd6cd..51f18a5a4e 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.json +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.json @@ -690,7 +690,7 @@ "options": "Account" }, { - "depends_on": "eval:doc.received_amount", + "depends_on": "eval:doc.received_amount && doc.payment_type != 'Internal Transfer'", "fieldname": "received_amount_after_tax", "fieldtype": "Currency", "label": "Received Amount After Tax", @@ -707,7 +707,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-06-09 11:55:04.215050", + "modified": "2021-06-22 20:37:06.154206", "modified_by": "Administrator", "module": "Accounts", "name": "Payment Entry", diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index b6b2bef963..adaf99a790 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -706,7 +706,7 @@ class PaymentEntry(AccountsController): if account_currency != self.company_currency: frappe.throw(_("Currency for {0} must be {1}").format(d.account_head, self.company_currency)) - if self.payment_type == 'Pay': + if self.payment_type in ('Pay', 'Internal Transfer'): dr_or_cr = "debit" if d.add_deduct_tax == "Add" else "credit" elif self.payment_type == 'Receive': dr_or_cr = "credit" if d.add_deduct_tax == "Add" else "debit" @@ -761,7 +761,7 @@ class PaymentEntry(AccountsController): return self.advance_tax_account elif self.payment_type == 'Receive': return self.paid_from - elif self.payment_type == 'Pay': + elif self.payment_type in ('Pay', 'Internal Transfer'): return self.paid_to def update_advance_paid(self): diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index 4641d6b5ff..d1302f5ae7 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -589,9 +589,9 @@ class TestPaymentEntry(unittest.TestCase): party_account_balance = get_balance_on(account=pe.paid_from, cost_center=pe.cost_center) self.assertEqual(pe.cost_center, si.cost_center) - self.assertEqual(expected_account_balance, account_balance) - self.assertEqual(expected_party_balance, party_balance) - self.assertEqual(expected_party_account_balance, party_account_balance) + self.assertEqual(flt(expected_account_balance), account_balance) + self.assertEqual(flt(expected_party_balance), party_balance) + self.assertEqual(flt(expected_party_account_balance), party_account_balance) def create_payment_terms_template(): diff --git a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py index 0b0ee904ff..500952e38a 100644 --- a/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py +++ b/erpnext/accounts/doctype/process_statement_of_accounts/process_statement_of_accounts.py @@ -207,10 +207,9 @@ def fetch_customers(customer_collection, collection_name, primary_mandatory): @frappe.whitelist() def get_customer_emails(customer_name, primary_mandatory, billing_and_primary=True): billing_email = frappe.db.sql(""" - SELECT c.email_id FROM `tabContact` AS c JOIN `tabDynamic Link` AS l ON c.name=l.parent \ - WHERE l.link_doctype='Customer' and l.link_name='""" + customer_name + """' and \ - c.is_billing_contact=1 \ - order by c.creation desc""") + SELECT c.email_id FROM `tabContact` AS c JOIN `tabDynamic Link` AS l ON c.name=l.parent + WHERE l.link_doctype='Customer' and l.link_name=%s and c.is_billing_contact=1 + order by c.creation desc""", customer_name) if len(billing_email) == 0 or (billing_email[0][0] is None): if billing_and_primary: diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js index f58c8f4526..dc9094c3e9 100644 --- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js +++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.js @@ -27,10 +27,6 @@ erpnext.accounts.PurchaseInvoice = erpnext.buying.BuyingController.extend({ }); }, - company: function() { - erpnext.accounts.dimensions.update_dimension(this.frm, this.frm.doctype); - }, - onload: function() { this._super(); @@ -569,5 +565,9 @@ frappe.ui.form.on("Purchase Invoice", { frm: frm, freeze_message: __("Creating Purchase Receipt ...") }) - } + }, + + company: function(frm) { + erpnext.accounts.dimensions.update_dimension(frm, frm.doctype); + }, }) diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py index ff433b962f..311745d3cd 100644 --- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py +++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py @@ -966,7 +966,7 @@ class TestPurchaseInvoice(unittest.TestCase): update_tax_witholding_category('_Test Company', 'TDS Payable - _TC', nowdate()) # Create Purchase Order with TDS applied - po = create_purchase_order(do_not_save=1, supplier=supplier.name, rate=3000) + po = create_purchase_order(do_not_save=1, supplier=supplier.name, rate=3000, item='_Test Non Stock Item') po.apply_tds = 1 po.tax_withholding_category = 'TDS - 194 - Dividends - Individual' po.save() @@ -1002,6 +1002,7 @@ class TestPurchaseInvoice(unittest.TestCase): # Create Purchase Invoice against Purchase Order purchase_invoice = get_mapped_purchase_invoice(po.name) purchase_invoice.allocate_advances_automatically = 1 + purchase_invoice.items[0].item_code = '_Test Non Stock Item' purchase_invoice.items[0].expense_account = '_Test Account Cost for Goods Sold - _TC' purchase_invoice.save() purchase_invoice.submit() @@ -1009,21 +1010,21 @@ class TestPurchaseInvoice(unittest.TestCase): # Check GLE for Purchase Invoice # Zero net effect on final TDS Payable on invoice expected_gle = [ - ['_Test Account Cost for Goods Sold - _TC', 30000, 0], - ['_Test Account Excise Duty - _TC', 0, 3000], - ['Creditors - _TC', 0, 27000], - ['TDS Payable - _TC', 3000, 3000] + ['_Test Account Cost for Goods Sold - _TC', 30000], + ['_Test Account Excise Duty - _TC', -3000], + ['Creditors - _TC', -27000], + ['TDS Payable - _TC', 0] ] - gl_entries = frappe.db.sql("""select account, debit, credit + gl_entries = frappe.db.sql("""select account, sum(debit - credit) as amount from `tabGL Entry` where voucher_type='Purchase Invoice' and voucher_no=%s + group by account order by account asc""", (purchase_invoice.name), as_dict=1) for i, gle in enumerate(gl_entries): self.assertEqual(expected_gle[i][0], gle.account) - self.assertEqual(expected_gle[i][1], gle.debit) - self.assertEqual(expected_gle[i][2], gle.credit) + self.assertEqual(expected_gle[i][1], gle.amount) def update_tax_witholding_category(company, account, date): from erpnext.accounts.utils import get_fiscal_year diff --git a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py index 114b7d2d35..dbc7f8632f 100644 --- a/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py +++ b/erpnext/accounts/doctype/sales_invoice/test_sales_invoice.py @@ -1957,6 +1957,33 @@ class TestSalesInvoice(unittest.TestCase): einvoice = make_einvoice(si) validate_totals(einvoice) + def test_item_tax_net_range(self): + item = create_item("T Shirt") + + item.set('taxes', []) + item.append("taxes", { + "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", + "minimum_net_rate": 0, + "maximum_net_rate": 500 + }) + + item.append("taxes", { + "item_tax_template": "_Test Account Excise Duty @ 12 - _TC", + "minimum_net_rate": 501, + "maximum_net_rate": 1000 + }) + + item.save() + + sales_invoice = create_sales_invoice(item = "T Shirt", rate=700, do_not_submit=True) + self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 12 - _TC") + + # Apply discount + sales_invoice.apply_discount_on = 'Net Total' + sales_invoice.discount_amount = 300 + sales_invoice.save() + self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC") + def get_sales_invoice_for_e_invoice(): si = make_sales_invoice_for_ewaybill() si.naming_series = 'INV-2020-.#####' @@ -1985,32 +2012,6 @@ def get_sales_invoice_for_e_invoice(): return si - def test_item_tax_net_range(self): - item = create_item("T Shirt") - - item.set('taxes', []) - item.append("taxes", { - "item_tax_template": "_Test Account Excise Duty @ 10 - _TC", - "minimum_net_rate": 0, - "maximum_net_rate": 500 - }) - - item.append("taxes", { - "item_tax_template": "_Test Account Excise Duty @ 12 - _TC", - "minimum_net_rate": 501, - "maximum_net_rate": 1000 - }) - - item.save() - - sales_invoice = create_sales_invoice(item = "T Shirt", rate=700, do_not_submit=True) - self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 12 - _TC") - - # Apply discount - sales_invoice.apply_discount_on = 'Net Total' - sales_invoice.discount_amount = 300 - sales_invoice.save() - self.assertEqual(sales_invoice.items[0].item_tax_template, "_Test Account Excise Duty @ 10 - _TC") def make_test_address_for_ewaybill(): if not frappe.db.exists('Address', '_Test Address for Eway bill-Billing'): @@ -2087,9 +2088,9 @@ def make_sales_invoice_for_ewaybill(): if not gst_account: gst_settings.append("gst_accounts", { "company": "_Test Company", - "cgst_account": "CGST - _TC", - "sgst_account": "SGST - _TC", - "igst_account": "IGST - _TC", + "cgst_account": "Output Tax CGST - _TC", + "sgst_account": "Output Tax SGST - _TC", + "igst_account": "Output Tax IGST - _TC", }) gst_settings.save() @@ -2106,7 +2107,7 @@ def make_sales_invoice_for_ewaybill(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "CGST - _TC", + "account_head": "Output Tax CGST - _TC", "cost_center": "Main - _TC", "description": "CGST @ 9.0", "rate": 9 @@ -2114,7 +2115,7 @@ def make_sales_invoice_for_ewaybill(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "SGST - _TC", + "account_head": "Output Tax SGST - _TC", "cost_center": "Main - _TC", "description": "SGST @ 9.0", "rate": 9 diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.js b/erpnext/accounts/doctype/tax_rule/tax_rule.js index 370890e4d8..bc497163e8 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.js +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.js @@ -1,24 +1,6 @@ // Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors // License: GNU General Public License v3. See license.txt -cur_frm.add_fetch("customer", "customer_group", "customer_group" ); -cur_frm.add_fetch("supplier", "supplier_group_name", "supplier_group" ); - -frappe.ui.form.on("Tax Rule", "tax_type", function(frm) { - frm.toggle_reqd("sales_tax_template", frm.doc.tax_type=="Sales"); - frm.toggle_reqd("purchase_tax_template", frm.doc.tax_type=="Purchase"); -}) - -frappe.ui.form.on("Tax Rule", "onload", function(frm) { - if(frm.doc.__islocal) { - frm.set_value("use_for_shopping_cart", 1); - } -}) - -frappe.ui.form.on("Tax Rule", "refresh", function(frm) { - frappe.ui.form.trigger("Tax Rule", "tax_type"); -}) - frappe.ui.form.on("Tax Rule", "customer", function(frm) { if(frm.doc.customer) { frappe.call({ diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.json b/erpnext/accounts/doctype/tax_rule/tax_rule.json index ef155381c0..2746748432 100644 --- a/erpnext/accounts/doctype/tax_rule/tax_rule.json +++ b/erpnext/accounts/doctype/tax_rule/tax_rule.json @@ -1,1103 +1,250 @@ { - "allow_copy": 0, - "allow_events_in_timeline": 0, - "allow_guest_to_view": 0, - "allow_import": 1, - "allow_rename": 0, - "autoname": "ACC-TAX-RULE-.YYYY.-.#####", - "beta": 0, - "creation": "2015-08-07 02:33:52.670866", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, + "actions": [], + "allow_import": 1, + "autoname": "ACC-TAX-RULE-.YYYY.-.#####", + "creation": "2015-08-07 02:33:52.670866", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "tax_type", + "use_for_shopping_cart", + "column_break_1", + "sales_tax_template", + "purchase_tax_template", + "filters", + "customer", + "supplier", + "item", + "billing_city", + "billing_county", + "billing_state", + "billing_zipcode", + "billing_country", + "tax_category", + "column_break_2", + "customer_group", + "supplier_group", + "item_group", + "shipping_city", + "shipping_county", + "shipping_state", + "shipping_zipcode", + "shipping_country", + "section_break_4", + "from_date", + "column_break_7", + "to_date", + "section_break_6", + "priority", + "column_break_20", + "company" + ], "fields": [ { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "Sales", - "fieldname": "tax_type", - "fieldtype": "Select", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Tax Type", - "length": 0, - "no_copy": 0, - "options": "Sales\nPurchase", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "Sales", + "fieldname": "tax_type", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Tax Type", + "options": "Sales\nPurchase" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "use_for_shopping_cart", - "fieldtype": "Check", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Use for Shopping Cart", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "use_for_shopping_cart", + "fieldtype": "Check", + "label": "Use for Shopping Cart" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_1", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_1", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "sales_tax_template", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Sales Tax Template", - "length": 0, - "no_copy": 0, - "options": "Sales Taxes and Charges Template", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fieldname": "sales_tax_template", + "fieldtype": "Link", + "label": "Sales Tax Template", + "options": "Sales Taxes and Charges Template" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "purchase_tax_template", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Purchase Tax Template", - "length": 0, - "no_copy": 0, - "options": "Purchase Taxes and Charges Template", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fieldname": "purchase_tax_template", + "fieldtype": "Link", + "label": "Purchase Tax Template", + "options": "Purchase Taxes and Charges Template" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "filters", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Filters", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "filters", + "fieldtype": "Section Break", + "label": "Filters" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "customer", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer", - "length": 0, - "no_copy": 0, - "options": "Customer", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fieldname": "customer", + "fieldtype": "Link", + "label": "Customer", + "options": "Customer" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "supplier", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Supplier", - "length": 0, - "no_copy": 0, - "options": "Supplier", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fieldname": "supplier", + "fieldtype": "Link", + "label": "Supplier", + "options": "Supplier" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item", - "length": 0, - "no_copy": 0, - "options": "Item", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item", + "fieldtype": "Link", + "label": "Item", + "options": "Item" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_city", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing City", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_city", + "fieldtype": "Data", + "label": "Billing City" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_county", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing County", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_county", + "fieldtype": "Data", + "label": "Billing County" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_state", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing State", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_state", + "fieldtype": "Data", + "label": "Billing State" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_zipcode", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing Zipcode", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_zipcode", + "fieldtype": "Data", + "label": "Billing Zipcode" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "billing_country", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Billing Country", - "length": 0, - "no_copy": 0, - "options": "Country", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "billing_country", + "fieldtype": "Link", + "label": "Billing Country", + "options": "Country" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "tax_category", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Tax Category", - "length": 0, - "no_copy": 0, - "options": "Tax Category", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "tax_category", + "fieldtype": "Link", + "label": "Tax Category", + "options": "Tax Category" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_2", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_2", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Sales\"", - "fieldname": "customer_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Customer Group", - "length": 0, - "no_copy": 0, - "options": "Customer Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Sales\"", + "fetch_from": "customer.customer_group", + "fieldname": "customer_group", + "fieldtype": "Link", + "label": "Customer Group", + "options": "Customer Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "depends_on": "eval:doc.tax_type==\"Purchase\"", - "fieldname": "supplier_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Supplier Group", - "length": 0, - "no_copy": 0, - "options": "Supplier Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "depends_on": "eval:doc.tax_type==\"Purchase\"", + "fetch_from": "supplier.supplier_group", + "fieldname": "supplier_group", + "fieldtype": "Link", + "label": "Supplier Group", + "options": "Supplier Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "item_group", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Item Group", - "length": 0, - "no_copy": 0, - "options": "Item Group", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "item_group", + "fieldtype": "Link", + "label": "Item Group", + "options": "Item Group" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_city", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping City", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_city", + "fieldtype": "Data", + "label": "Shipping City" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_county", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping County", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_county", + "fieldtype": "Data", + "label": "Shipping County" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_state", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping State", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_state", + "fieldtype": "Data", + "label": "Shipping State" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_zipcode", - "fieldtype": "Data", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping Zipcode", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_zipcode", + "fieldtype": "Data", + "label": "Shipping Zipcode" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "shipping_country", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Shipping Country", - "length": 0, - "no_copy": 0, - "options": "Country", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "shipping_country", + "fieldtype": "Link", + "label": "Shipping Country", + "options": "Country" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Validity", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Validity" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "from_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "From Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_7", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "to_date", - "fieldtype": "Date", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "To Date", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_6", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "section_break_6", + "fieldtype": "Section Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "default": "1", - "fieldname": "priority", - "fieldtype": "Int", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Priority", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "default": "1", + "fieldname": "priority", + "fieldtype": "Int", + "label": "Priority" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "column_break_20", - "fieldtype": "Column Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 - }, + "fieldname": "column_break_20", + "fieldtype": "Column Break" + }, { - "allow_bulk_edit": 0, - "allow_in_quick_entry": 0, - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "company", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_global_search": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Company", - "length": 0, - "no_copy": 0, - "options": "Company", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 1, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "translatable": 0, - "unique": 0 + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "remember_last_selected_value": 1 } - ], - "has_web_view": 0, - "hide_heading": 0, - "hide_toolbar": 0, - "idx": 0, - "image_view": 0, - "in_create": 0, - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2018-12-27 01:22:17.721636", - "modified_by": "Administrator", - "module": "Accounts", - "name": "Tax Rule", - "name_case": "", - "owner": "Administrator", + ], + "links": [], + "modified": "2021-06-04 23:14:27.186879", + "modified_by": "Administrator", + "module": "Accounts", + "name": "Tax Rule", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "if_owner": 0, - "import": 0, - "permlevel": 0, - "print": 1, - "read": 1, - "report": 1, - "role": "Accounts Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 0, - "read_only": 0, - "read_only_onload": 0, - "show_name_in_global_search": 1, - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 0, - "track_seen": 0, - "track_views": 0 + ], + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC" } \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py index ac1ffd9e75..cf7226822e 100644 --- a/erpnext/accounts/doctype/tax_rule/test_tax_rule.py +++ b/erpnext/accounts/doctype/tax_rule/test_tax_rule.py @@ -50,7 +50,7 @@ class TestTaxRule(unittest.TestCase): tax_rule1 = make_tax_rule(customer_group= "All Customer Groups", sales_tax_template = "_Test Sales Taxes and Charges Template - _TC", priority = 1, from_date = "2015-01-01") tax_rule1.save() - self.assertEqual(get_tax_template("2015-01-01", {"customer_group" : "Commercial", "use_for_shopping_cart":0}), + self.assertEqual(get_tax_template("2015-01-01", {"customer_group" : "Commercial", "use_for_shopping_cart":1}), "_Test Sales Taxes and Charges Template - _TC") def test_conflict_with_overlapping_dates(self): diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py index 59009ae621..25d2cf10bd 100644 --- a/erpnext/accounts/general_ledger.py +++ b/erpnext/accounts/general_ledger.py @@ -101,7 +101,7 @@ def merge_similar_entries(gl_map, precision=None): def check_if_in_list(gle, gl_map, dimensions=None): account_head_fieldnames = ['party_type', 'party', 'against_voucher', 'against_voucher_type', - 'cost_center', 'project'] + 'cost_center', 'project', 'voucher_detail_no'] if dimensions: account_head_fieldnames = account_head_fieldnames + dimensions diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py index e025fc6905..b97dc401e6 100644 --- a/erpnext/accounts/party.py +++ b/erpnext/accounts/party.py @@ -542,6 +542,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None): select company, sum(debit_in_account_currency) - sum(credit_in_account_currency) from `tabGL Entry` where party_type = %s and party=%s + and is_cancelled = 0 group by company""", (party_type, party))) for d in companies: diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py index 7793af737f..56a67bb098 100644 --- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py +++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py @@ -380,7 +380,7 @@ def set_gl_entries_by_account(from_date, to_date, root_lft, root_rgt, filters, g gl_entries = frappe.db.sql("""select gl.posting_date, gl.account, gl.debit, gl.credit, gl.is_opening, gl.company, gl.fiscal_year, gl.debit_in_account_currency, gl.credit_in_account_currency, gl.account_currency, acc.account_name, acc.account_number - from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s + from `tabGL Entry` gl, `tabAccount` acc where acc.name = gl.account and gl.company = %(company)s and gl.is_cancelled = 0 {additional_conditions} and gl.posting_date <= %(to_date)s and acc.lft >= %(lft)s and acc.rgt <= %(rgt)s order by gl.account, gl.posting_date""".format(additional_conditions=additional_conditions), { diff --git a/erpnext/accounts/report/general_ledger/general_ledger.py b/erpnext/accounts/report/general_ledger/general_ledger.py index 03808c3640..744ada9e55 100644 --- a/erpnext/accounts/report/general_ledger/general_ledger.py +++ b/erpnext/accounts/report/general_ledger/general_ledger.py @@ -222,7 +222,7 @@ def get_gl_entries(filters, accounting_dimensions): def get_conditions(filters): conditions = [] - if filters.get("account") and not filters.get("include_dimensions"): + if filters.get("account"): filters.account = get_accounts_with_children(filters.account) conditions.append("account in %(account)s") diff --git a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py index 60e675f2f1..48bd7308bc 100644 --- a/erpnext/accounts/report/profitability_analysis/profitability_analysis.py +++ b/erpnext/accounts/report/profitability_analysis/profitability_analysis.py @@ -168,21 +168,24 @@ def get_columns(filters): "label": _("Income"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 305 + }, { "fieldname": "expense", "label": _("Expense"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 305 + }, { "fieldname": "gross_profit_loss", "label": _("Gross Profit / Loss"), "fieldtype": "Currency", "options": "currency", - "width": 120 + "width": 307 + } ] diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index 66a9b60125..ed6e28da1e 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -784,7 +784,7 @@ def get_children(doctype, parent, company, is_root=False): return acc def create_payment_gateway_account(gateway, payment_channel="Email"): - from erpnext.setup.setup_wizard.operations.company_setup import create_bank_account + from erpnext.setup.setup_wizard.operations.install_fixtures import create_bank_account company = frappe.db.get_value("Global Defaults", None, "default_company") if not company: diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json index 630a1dc8cd..b9c77d59b1 100644 --- a/erpnext/buying/doctype/buying_settings/buying_settings.json +++ b/erpnext/buying/doctype/buying_settings/buying_settings.json @@ -9,13 +9,14 @@ "supp_master_name", "supplier_group", "buying_price_list", + "maintain_same_rate_action", + "role_to_override_stop_action", "column_break_3", "po_required", "pr_required", "maintain_same_rate", - "maintain_same_rate_action", - "role_to_override_stop_action", "allow_multiple_items", + "bill_for_rejected_quantity_in_purchase_invoice", "subcontract", "backflush_raw_materials_of_subcontract_based_on", "column_break_11", @@ -108,6 +109,13 @@ "fieldtype": "Link", "label": "Role Allowed to Override Stop Action", "options": "Role" + }, + { + "default": "1", + "description": "If checked, Rejected Quantity will be included while making Purchase Invoice from Purchase Receipt.", + "fieldname": "bill_for_rejected_quantity_in_purchase_invoice", + "fieldtype": "Check", + "label": "Bill for Rejected Quantity in Purchase Invoice" } ], "icon": "fa fa-cog", @@ -115,7 +123,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-04-04 20:01:44.087066", + "modified": "2021-06-24 10:38:28.934525", "modified_by": "Administrator", "module": "Buying", "name": "Buying Settings", diff --git a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json index 1dbd7c60c3..132dd1769c 100644 --- a/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json +++ b/erpnext/buying/doctype/purchase_order_item/purchase_order_item.json @@ -97,6 +97,9 @@ "is_fixed_asset", "item_tax_rate", "section_break_72", + "production_plan", + "production_plan_item", + "production_plan_sub_assembly_item", "page_break" ], "fields": [ @@ -803,13 +806,37 @@ "options": "Company:company:default_currency", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "production_plan", + "fieldtype": "Link", + "label": "Production Plan", + "options": "Production Plan", + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Item", + "no_copy": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_sub_assembly_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Sub Assembly Item", + "no_copy": 1, + "read_only": 1 } ], "idx": 1, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-03-22 11:46:12.357435", + "modified": "2021-06-28 19:22:22.715365", "modified_by": "Administrator", "module": "Buying", "name": "Purchase Order Item", diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py index 243939b275..1c086e9edc 100644 --- a/erpnext/controllers/accounts_controller.py +++ b/erpnext/controllers/accounts_controller.py @@ -828,8 +828,14 @@ class AccountsController(TransactionBase): role_allowed_to_over_bill = frappe.db.get_single_value('Accounts Settings', 'role_allowed_to_over_bill') if total_billed_amt - max_allowed_amt > 0.01 and role_allowed_to_over_bill not in frappe.get_roles(): - frappe.throw(_("Cannot overbill for Item {0} in row {1} more than {2}. To allow over-billing, please set allowance in Accounts Settings") - .format(item.item_code, item.idx, max_allowed_amt)) + if self.doctype != "Purchase Invoice": + self.throw_overbill_exception(item, max_allowed_amt) + elif not cint(frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice")): + self.throw_overbill_exception(item, max_allowed_amt) + + def throw_overbill_exception(self, item, max_allowed_amt): + frappe.throw(_("Cannot overbill for Item {0} in row {1} more than {2}. To allow over-billing, please set allowance in Accounts Settings") + .format(item.item_code, item.idx, max_allowed_amt)) def get_company_default(self, fieldname): from erpnext.accounts.utils import get_company_default diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py index 7bd739a6ad..280319321f 100644 --- a/erpnext/controllers/queries.py +++ b/erpnext/controllers/queries.py @@ -19,7 +19,7 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): fields = get_fields("Employee", ["name", "employee_name"]) return frappe.db.sql("""select {fields} from `tabEmployee` - where status = 'Active' + where status in ('Active', 'Suspended') and docstatus < 2 and ({key} like %(txt)s or employee_name like %(txt)s) @@ -315,7 +315,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters): return frappe.db.sql("""select {fields} from `tabProject` where `tabProject`.status not in ("Completed", "Cancelled") - and {cond} {match_cond} {scond} + and {cond} {scond} {match_cond} order by if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999), idx desc, diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py index 5f759b43bc..80ccc6d75b 100644 --- a/erpnext/controllers/sales_and_purchase_return.py +++ b/erpnext/controllers/sales_and_purchase_return.py @@ -99,9 +99,10 @@ def validate_returned_items(doc): frappe.throw(_("Row # {0}: Serial No {1} does not match with {2} {3}") .format(d.idx, s, doc.doctype, doc.return_against)) - if warehouse_mandatory and frappe.db.get_value("Item", d.item_code, "is_stock_item") \ - and not d.get("warehouse"): - frappe.throw(_("Warehouse is mandatory")) + if (warehouse_mandatory and not d.get("warehouse") and + frappe.db.get_value("Item", d.item_code, "is_stock_item") + ): + frappe.throw(_("Warehouse is mandatory")) items_returned = True @@ -462,4 +463,4 @@ def get_returned_serial_nos(child_doc, parent_doc): for row in frappe.get_all(parent_doc.doctype, fields = fields, filters=filters): serial_nos.extend(get_serial_nos(row.serial_no)) - return serial_nos \ No newline at end of file + return serial_nos diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py index 7f28289760..da2765dede 100644 --- a/erpnext/controllers/selling_controller.py +++ b/erpnext/controllers/selling_controller.py @@ -330,9 +330,15 @@ class SellingController(StockController): # For internal transfers use incoming rate as the valuation rate if self.is_internal_transfer(): - rate = flt(d.incoming_rate * d.conversion_factor, d.precision('rate')) - if d.rate != rate: - d.rate = rate + if d.doctype == "Packed Item": + incoming_rate = flt(d.incoming_rate * d.conversion_factor, d.precision('incoming_rate')) + if d.incoming_rate != incoming_rate: + d.incoming_rate = incoming_rate + else: + rate = flt(d.incoming_rate * d.conversion_factor, d.precision('rate')) + if d.rate != rate: + d.rate = rate + d.discount_percentage = 0 d.discount_amount = 0 frappe.msgprint(_("Row {0}: Item rate has been updated as per valuation rate since its an internal stock transfer") diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py index c83de3da9e..1749297ce3 100644 --- a/erpnext/controllers/stock_controller.py +++ b/erpnext/controllers/stock_controller.py @@ -11,7 +11,7 @@ from frappe.utils import cint, cstr, flt, get_link_to_form, getdate import erpnext from erpnext.accounts.general_ledger import make_gl_entries, make_reverse_gl_entries, process_gl_map -from erpnext.accounts.utils import check_if_stock_and_account_balance_synced, get_fiscal_year +from erpnext.accounts.utils import get_fiscal_year from erpnext.controllers.accounts_controller import AccountsController from erpnext.stock import get_warehouse_account_map from erpnext.stock.stock_ledger import get_valuation_rate @@ -523,9 +523,6 @@ class StockController(AccountsController): }) if future_sle_exists(args): create_repost_item_valuation_entry(args) - elif not is_reposting_pending(): - check_if_stock_and_account_balance_synced(self.posting_date, - self.company, self.doctype, self.name) @frappe.whitelist() def make_quality_inspections(doctype, docname, items): diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py index 2bb83ea7f0..56da5b71da 100644 --- a/erpnext/controllers/taxes_and_totals.py +++ b/erpnext/controllers/taxes_and_totals.py @@ -658,7 +658,13 @@ class calculate_taxes_and_totals(object): item.margin_type = None item.margin_rate_or_amount = 0.0 - if item.margin_type and item.margin_rate_or_amount: + if not item.pricing_rules and flt(item.rate) > flt(item.price_list_rate): + item.margin_type = "Amount" + item.margin_rate_or_amount = flt(item.rate - item.price_list_rate, + item.precision("margin_rate_or_amount")) + item.rate_with_margin = item.rate + + elif item.margin_type and item.margin_rate_or_amount: margin_value = item.margin_rate_or_amount if item.margin_type == 'Amount' else flt(item.price_list_rate) * flt(item.margin_rate_or_amount) / 100 rate_with_margin = flt(item.price_list_rate) + flt(margin_value) base_rate_with_margin = flt(rate_with_margin) * flt(self.doc.conversion_rate) diff --git a/erpnext/crm/doctype/appointment/appointment.json b/erpnext/crm/doctype/appointment/appointment.json index 8517ddec32..306be7faa7 100644 --- a/erpnext/crm/doctype/appointment/appointment.json +++ b/erpnext/crm/doctype/appointment/appointment.json @@ -102,7 +102,7 @@ } ], "links": [], - "modified": "2020-01-28 16:16:45.447213", + "modified": "2021-06-29 18:27:02.832979", "modified_by": "Administrator", "module": "CRM", "name": "Appointment", @@ -153,6 +153,18 @@ "role": "Sales User", "share": 1, "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Employee", + "share": 1, + "write": 1 } ], "quick_entry": 1, diff --git a/erpnext/crm/doctype/lead/lead.py b/erpnext/crm/doctype/lead/lead.py index d1d096843b..ce3de40fc3 100644 --- a/erpnext/crm/doctype/lead/lead.py +++ b/erpnext/crm/doctype/lead/lead.py @@ -168,12 +168,13 @@ class Lead(SellingController): if self.phone: contact.append("phone_nos", { "phone": self.phone, - "is_primary": 1 + "is_primary_phone": 1 }) if self.mobile_no: contact.append("phone_nos", { - "phone": self.mobile_no + "phone": self.mobile_no, + "is_primary_mobile_no":1 }) contact.insert(ignore_permissions=True) diff --git a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js index 3f5c95ab0a..fe5707af29 100644 --- a/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js +++ b/erpnext/crm/report/first_response_time_for_opportunity/first_response_time_for_opportunity.js @@ -22,10 +22,10 @@ frappe.query_reports["First Response Time for Opportunity"] = { get_chart_data: function (_columns, result) { return { data: { - labels: result.map(d => d[0]), + labels: result.map(d => d.creation_date), datasets: [{ name: "First Response Time", - values: result.map(d => d[1]) + values: result.map(d => d.first_response_time) }] }, type: "line", @@ -35,8 +35,7 @@ frappe.query_reports["First Response Time for Opportunity"] = { hide_days: 0, hide_seconds: 0 }; - value = frappe.utils.get_formatted_duration(d, duration_options); - return value; + return frappe.utils.get_formatted_duration(d, duration_options); } } } diff --git a/erpnext/education/utils.py b/erpnext/education/utils.py index 9db8a4a90d..3070e6a3e8 100644 --- a/erpnext/education/utils.py +++ b/erpnext/education/utils.py @@ -355,11 +355,11 @@ def get_or_create_course_enrollment(course, program): student = get_current_student() course_enrollment = get_enrollment("course", course, student.name) if not course_enrollment: - program_enrollment = get_enrollment('program', program, student.name) + program_enrollment = get_enrollment('program', program.name, student.name) if not program_enrollment: frappe.throw(_("You are not enrolled in program {0}").format(program)) return - return student.enroll_in_course(course_name=course, program_enrollment=get_enrollment('program', program, student.name)) + return student.enroll_in_course(course_name=course, program_enrollment=get_enrollment('program', program.name, student.name)) else: return frappe.get_doc('Course Enrollment', course_enrollment) diff --git a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py index 3c2e59ab82..b0e662d3f3 100644 --- a/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py +++ b/erpnext/erpnext_integrations/doctype/mpesa_settings/test_mpesa_settings.py @@ -7,16 +7,21 @@ import frappe import unittest from erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings import process_balance_info, verify_transaction from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_invoice +from erpnext.erpnext_integrations.utils import create_mode_of_payment class TestMpesaSettings(unittest.TestCase): + def setUp(self): + # create payment gateway in setup + create_mpesa_settings(payment_gateway_name="_Test") + create_mpesa_settings(payment_gateway_name="_Account Balance") + create_mpesa_settings(payment_gateway_name="Payment") + def tearDown(self): frappe.db.sql('delete from `tabMpesa Settings`') frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"') def test_creation_of_payment_gateway(self): - create_mpesa_settings(payment_gateway_name="_Test") - - mode_of_payment = frappe.get_doc("Mode of Payment", "Mpesa-_Test") + mode_of_payment = create_mode_of_payment('Mpesa-_Test', payment_type="Phone") self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"})) self.assertTrue(mode_of_payment.name) self.assertEqual(mode_of_payment.type, "Phone") @@ -47,7 +52,6 @@ class TestMpesaSettings(unittest.TestCase): integration_request.delete() def test_processing_of_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES") @@ -90,7 +94,6 @@ class TestMpesaSettings(unittest.TestCase): pos_invoice.delete() def test_processing_of_multiple_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") @@ -141,7 +144,6 @@ class TestMpesaSettings(unittest.TestCase): pos_invoice.delete() def test_processing_of_only_one_succes_callback_payload(self): - create_mpesa_settings(payment_gateway_name="Payment") mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account") frappe.db.set_value("Account", mpesa_account, "account_currency", "KES") frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500") @@ -202,6 +204,7 @@ def create_mpesa_settings(payment_gateway_name="Express"): doc = frappe.get_doc(dict( #nosec doctype="Mpesa Settings", + sandbox=1, payment_gateway_name=payment_gateway_name, consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn", consumer_secret="VI1oS3oBGPJfh3JyvLHw", diff --git a/erpnext/erpnext_integrations/utils.py b/erpnext/erpnext_integrations/utils.py index 3840e781b4..a5e162f8b5 100644 --- a/erpnext/erpnext_integrations/utils.py +++ b/erpnext/erpnext_integrations/utils.py @@ -52,7 +52,8 @@ def create_mode_of_payment(gateway, payment_type="General"): "payment_gateway": gateway }, ['payment_account']) - if not frappe.db.exists("Mode of Payment", gateway) and payment_gateway_account: + mode_of_payment = frappe.db.exists("Mode of Payment", gateway) + if not mode_of_payment and payment_gateway_account: mode_of_payment = frappe.get_doc({ "doctype": "Mode of Payment", "mode_of_payment": gateway, @@ -66,6 +67,10 @@ def create_mode_of_payment(gateway, payment_type="General"): }) mode_of_payment.insert(ignore_permissions=True) + return mode_of_payment + elif mode_of_payment: + return frappe.get_doc("Mode of Payment", mode_of_payment) + def get_tracking_url(carrier, tracking_number): # Return the formatted Tracking URL. tracking_url = '' diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 8ad77a1524..52daec9180 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -157,6 +157,7 @@ website_route_rules = [ "parents": [{"label": _("Material Request"), "route": "material-requests"}] } }, + {"from_route": "/project", "to_route": "Project"} ] standard_portal_menu_items = [ diff --git a/erpnext/hr/doctype/attendance/attendance.js b/erpnext/hr/doctype/attendance/attendance.js index c3c3cb82f9..7964078c7f 100644 --- a/erpnext/hr/doctype/attendance/attendance.js +++ b/erpnext/hr/doctype/attendance/attendance.js @@ -11,5 +11,5 @@ cur_frm.cscript.onload = function(doc, cdt, cdn) { cur_frm.fields_dict.employee.get_query = function(doc,cdt,cdn) { return{ query: "erpnext.controllers.queries.employee_query" - } + } } diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index f3b8a799b3..3412675d81 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -15,6 +15,7 @@ class Attendance(Document): validate_status(self.status, ["Present", "Absent", "On Leave", "Half Day", "Work From Home"]) self.validate_attendance_date() self.validate_duplicate_record() + self.validate_employee_status() self.check_leave_record() def validate_attendance_date(self): @@ -38,6 +39,10 @@ class Attendance(Document): frappe.throw(_("Attendance for employee {0} is already marked for the date {1}").format( frappe.bold(self.employee), frappe.bold(self.attendance_date))) + def validate_employee_status(self): + if frappe.db.get_value("Employee", self.employee, "status") == "Inactive": + frappe.throw(_("Cannot mark attendance for an Inactive employee {0}").format(self.employee)) + def check_leave_record(self): leave_record = frappe.db.sql(""" select leave_type, half_day, half_day_date diff --git a/erpnext/hr/doctype/attendance/attendance_list.js b/erpnext/hr/doctype/attendance/attendance_list.js index 0c7eafe9c6..9a3bac0eb2 100644 --- a/erpnext/hr/doctype/attendance/attendance_list.js +++ b/erpnext/hr/doctype/attendance/attendance_list.js @@ -21,6 +21,9 @@ frappe.listview_settings['Attendance'] = { label: __('For Employee'), fieldtype: 'Link', options: 'Employee', + get_query: () => { + return {query: "erpnext.controllers.queries.employee_query"} + }, reqd: 1, onchange: function() { dialog.set_df_property("unmarked_days", "hidden", 1); diff --git a/erpnext/hr/doctype/employee/employee.json b/erpnext/hr/doctype/employee/employee.json index 5442ed56c3..d592a9c79e 100644 --- a/erpnext/hr/doctype/employee/employee.json +++ b/erpnext/hr/doctype/employee/employee.json @@ -207,7 +207,7 @@ "label": "Status", "oldfieldname": "status", "oldfieldtype": "Select", - "options": "Active\nInactive\nLeft", + "options": "Active\nInactive\nSuspended\nLeft", "reqd": 1, "search_index": 1 }, @@ -813,7 +813,7 @@ "idx": 24, "image_field": "image", "links": [], - "modified": "2021-06-12 11:31:37.730760", + "modified": "2021-06-17 11:31:37.730760", "modified_by": "Administrator", "module": "HR", "name": "Employee", diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py index bc5694226a..fa017d9d4c 100755 --- a/erpnext/hr/doctype/employee/employee.py +++ b/erpnext/hr/doctype/employee/employee.py @@ -4,7 +4,7 @@ from __future__ import unicode_literals import frappe -from frappe.utils import getdate, validate_email_address, today, add_years, format_datetime, cstr +from frappe.utils import getdate, validate_email_address, today, add_years, cstr from frappe.model.naming import set_name_by_naming_series from frappe import throw, _, scrub from frappe.permissions import add_user_permission, remove_user_permission, \ @@ -12,7 +12,6 @@ from frappe.permissions import add_user_permission, remove_user_permission, \ from frappe.model.document import Document from erpnext.utilities.transaction_base import delete_events from frappe.utils.nestedset import NestedSet -from erpnext.hr.doctype.job_offer.job_offer import get_staffing_plan_detail class EmployeeUserDisabledError(frappe.ValidationError): pass class EmployeeLeftValidationError(frappe.ValidationError): pass @@ -37,7 +36,7 @@ class Employee(NestedSet): def validate(self): from erpnext.controllers.status_updater import validate_status - validate_status(self.status, ["Active", "Inactive", "Left"]) + validate_status(self.status, ["Active", "Inactive", "Suspended", "Left"]) self.employee = self.name self.set_employee_name() diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py index 285374d9f6..e853bee69f 100644 --- a/erpnext/hr/doctype/employee/employee_dashboard.py +++ b/erpnext/hr/doctype/employee/employee_dashboard.py @@ -7,7 +7,8 @@ def get_data(): 'heatmap_message': _('This is based on the attendance of this Employee'), 'fieldname': 'employee', 'non_standard_fieldnames': { - 'Bank Account': 'party' + 'Bank Account': 'party', + 'Employee Grievance': 'raised_by' }, 'transactions': [ { @@ -20,7 +21,7 @@ def get_data(): }, { 'label': _('Lifecycle'), - 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation'] + 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation', 'Employee Grievance'] }, { 'label': _('Shift'), diff --git a/erpnext/hr/doctype/employee/employee_list.js b/erpnext/hr/doctype/employee/employee_list.js index 6679e318c2..d37e1496ca 100644 --- a/erpnext/hr/doctype/employee/employee_list.js +++ b/erpnext/hr/doctype/employee/employee_list.js @@ -3,7 +3,7 @@ frappe.listview_settings['Employee'] = { filters: [["status","=", "Active"]], get_indicator: function(doc) { var indicator = [__(doc.status), frappe.utils.guess_colour(doc.status), "status,=," + doc.status]; - indicator[1] = {"Active": "green", "Inactive": "red", "Left": "gray"}[doc.status]; + indicator[1] = {"Active": "green", "Inactive": "red", "Left": "gray", "Suspended": "orange"}[doc.status]; return indicator; } }; diff --git a/erpnext/accounts/accounts b/erpnext/hr/doctype/employee_grievance/__init__.py similarity index 100% rename from erpnext/accounts/accounts rename to erpnext/hr/doctype/employee_grievance/__init__.py diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.js b/erpnext/hr/doctype/employee_grievance/employee_grievance.js new file mode 100644 index 0000000000..25c5badbc7 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.js @@ -0,0 +1,39 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Employee Grievance', { + setup: function(frm) { + frm.set_query('grievance_against_party', function() { + return { + filters: { + name: ['in', [ + 'Company', 'Department', 'Employee Group', 'Employee Grade', 'Employee'] + ] + } + }; + }); + frm.set_query('associated_document_type', function() { + let ignore_modules = ["Setup", "Core", "Integrations", "Automation", "Website", + "Utilities", "Event Streaming", "Social", "Chat", "Data Migration", "Printing", "Desk", "Custom"]; + return { + filters: { + istable: 0, + issingle: 0, + module: ["Not In", ignore_modules] + } + }; + }); + }, + + grievance_against_party: function(frm) { + let filters = {}; + if (frm.doc.grievance_against_party == 'Employee' && frm.doc.raised_by) { + filters.name = ["!=", frm.doc.raised_by]; + } + frm.set_query('grievance_against', function() { + return { + filters: filters + }; + }); + }, +}); diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.json b/erpnext/hr/doctype/employee_grievance/employee_grievance.json new file mode 100644 index 0000000000..5a918562af --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.json @@ -0,0 +1,261 @@ +{ + "actions": [], + "autoname": "HR-GRIEV-.YYYY.-.#####", + "creation": "2021-05-11 13:41:51.485295", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "subject", + "raised_by", + "employee_name", + "designation", + "column_break_3", + "date", + "status", + "reports_to", + "grievance_details_section", + "grievance_against_party", + "grievance_against", + "grievance_type", + "column_break_11", + "associated_document_type", + "associated_document", + "section_break_14", + "description", + "investigation_details_section", + "cause_of_grievance", + "resolution_details_section", + "resolved_by", + "resolution_date", + "employee_responsible", + "column_break_16", + "resolution_detail", + "amended_from" + ], + "fields": [ + { + "fieldname": "grievance_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Grievance Type", + "options": "Grievance Type", + "reqd": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date ", + "reqd": 1 + }, + { + "default": "Open", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Open\nInvestigated\nResolved\nInvalid", + "reqd": 1 + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Description", + "reqd": 1 + }, + { + "fieldname": "cause_of_grievance", + "fieldtype": "Text", + "label": "Cause of Grievance", + "mandatory_depends_on": "eval: doc.status == \"Investigated\" || doc.status == \"Resolved\"" + }, + { + "fieldname": "resolution_details_section", + "fieldtype": "Section Break", + "label": "Resolution Details" + }, + { + "fieldname": "resolved_by", + "fieldtype": "Link", + "label": "Resolved By", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"", + "options": "User" + }, + { + "fieldname": "employee_responsible", + "fieldtype": "Link", + "label": "Employee Responsible ", + "options": "Employee" + }, + { + "fieldname": "resolution_detail", + "fieldtype": "Small Text", + "label": "Resolution Details", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"" + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break" + }, + { + "fieldname": "resolution_date", + "fieldtype": "Date", + "label": "Resolution Date", + "mandatory_depends_on": "eval: doc.status == \"Resolved\"" + }, + { + "fieldname": "grievance_against", + "fieldtype": "Dynamic Link", + "label": "Grievance Against", + "options": "grievance_against_party", + "reqd": 1 + }, + { + "fieldname": "raised_by", + "fieldtype": "Link", + "label": "Raised By", + "options": "Employee", + "reqd": 1 + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Employee Grievance", + "print_hide": 1, + "read_only": 1 + }, + { + "fetch_from": "raised_by.designation", + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation", + "read_only": 1 + }, + { + "fetch_from": "raised_by.reports_to", + "fieldname": "reports_to", + "fieldtype": "Link", + "label": "Reports To", + "options": "Employee", + "read_only": 1 + }, + { + "fieldname": "grievance_details_section", + "fieldtype": "Section Break", + "label": "Grievance Details" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_14", + "fieldtype": "Section Break" + }, + { + "fieldname": "grievance_against_party", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Grievance Against Party", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "associated_document_type", + "fieldtype": "Link", + "label": "Associated Document Type", + "options": "DocType" + }, + { + "fieldname": "associated_document", + "fieldtype": "Dynamic Link", + "label": "Associated Document", + "options": "associated_document_type" + }, + { + "fieldname": "investigation_details_section", + "fieldtype": "Section Break", + "label": "Investigation Details" + }, + { + "fetch_from": "raised_by.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name", + "read_only": 1 + }, + { + "fieldname": "subject", + "fieldtype": "Data", + "label": "Subject", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2021-06-21 12:51:01.499486", + "modified_by": "Administrator", + "module": "HR", + "name": "Employee Grievance", + "owner": "Administrator", + "permissions": [ + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "amend": 1, + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "search_fields": "subject,raised_by,grievance_against_party", + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "subject", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance.py b/erpnext/hr/doctype/employee_grievance/employee_grievance.py new file mode 100644 index 0000000000..503b5ea444 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance.py @@ -0,0 +1,15 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _, bold +from frappe.model.document import Document + +class EmployeeGrievance(Document): + def on_submit(self): + if self.status not in ["Invalid", "Resolved"]: + frappe.throw(_("Only Employee Grievance with status {0} or {1} can be submitted").format( + bold("Invalid"), + bold("Resolved")) + ) + diff --git a/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js b/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js new file mode 100644 index 0000000000..fc08e21609 --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/employee_grievance_list.js @@ -0,0 +1,12 @@ +frappe.listview_settings["Employee Grievance"] = { + has_indicator_for_draft: 1, + get_indicator: function(doc) { + var colors = { + "Open": "red", + "Investigated": "orange", + "Resolved": "green", + "Invalid": "grey" + }; + return [__(doc.status), colors[doc.status], "status,=," + doc.status]; + } +}; \ No newline at end of file diff --git a/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py b/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py new file mode 100644 index 0000000000..a615b20a5a --- /dev/null +++ b/erpnext/hr/doctype/employee_grievance/test_employee_grievance.py @@ -0,0 +1,51 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +import frappe +import unittest +from frappe.utils import today +from erpnext.hr.doctype.employee.test_employee import make_employee +class TestEmployeeGrievance(unittest.TestCase): + def test_create_employee_grievance(self): + create_employee_grievance() + +def create_employee_grievance(): + grievance_type = create_grievance_type() + emp_1 = make_employee("test_emp_grievance_@example.com", company="_Test Company") + emp_2 = make_employee("testculprit@example.com", company="_Test Company") + + grievance = frappe.new_doc("Employee Grievance") + grievance.subject = "Test Employee Grievance" + grievance.raised_by = emp_1 + grievance.date = today() + grievance.grievance_type = grievance_type + grievance.grievance_against_party = "Employee" + grievance.grievance_against = emp_2 + grievance.description = "test descrip" + + #set cause + grievance.cause_of_grievance = "test cause" + + #resolution details + grievance.resolution_date = today() + grievance.resolution_detail = "test resolution detail" + grievance.resolved_by = "test_emp_grievance_@example.com" + grievance.employee_responsible = emp_2 + grievance.status = "Resolved" + + grievance.save() + grievance.submit() + + return grievance + + +def create_grievance_type(): + if frappe.db.exists("Grievance Type", "Employee Abuse"): + return frappe.get_doc("Grievance Type", "Employee Abuse") + grievance_type = frappe.new_doc("Grievance Type") + grievance_type.name = "Employee Abuse" + grievance_type.description = "Test" + grievance_type.save() + + return grievance_type.name + diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index 578eccf787..96ea686706 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -72,7 +72,8 @@ class TestExpenseClaim(unittest.TestCase): def test_expense_claim_gl_entry(self): payable_account = get_payable_account(company_name) taxes = generate_taxes() - expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", do_not_submit=True, taxes=taxes) + expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", + do_not_submit=True, taxes=taxes) expense_claim.submit() gl_entries = frappe.db.sql("""select account, debit, credit @@ -82,7 +83,7 @@ class TestExpenseClaim(unittest.TestCase): self.assertTrue(gl_entries) expected_values = dict((d[0], d) for d in [ - ['CGST - _TC4',18.0, 0.0], + ['Output Tax CGST - _TC4',18.0, 0.0], [payable_account, 0.0, 218.0], ["Travel Expenses - _TC4", 200.0, 0.0] ]) @@ -145,7 +146,7 @@ def generate_taxes(): parent_account = frappe.db.get_value('Account', {'company': company_name, 'is_group':1, 'account_type': 'Tax'}, 'name') - account = create_account(company=company_name, account_name="CGST", account_type="Tax", parent_account=parent_account) + account = create_account(company=company_name, account_name="Output Tax CGST", account_type="Tax", parent_account=parent_account) return {'taxes':[{ "account_head": account, "rate": 0, diff --git a/erpnext/hr/doctype/grievance_type/__init__.py b/erpnext/hr/doctype/grievance_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.js b/erpnext/hr/doctype/grievance_type/grievance_type.js new file mode 100644 index 0000000000..425f2fd5b5 --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.js @@ -0,0 +1,8 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Grievance Type', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.json b/erpnext/hr/doctype/grievance_type/grievance_type.json new file mode 100644 index 0000000000..1dce00a0e2 --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "autoname": "Prompt", + "creation": "2021-05-11 12:41:50.256071", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "section_break_5", + "description" + ], + "fields": [ + { + "fieldname": "section_break_5", + "fieldtype": "Section Break" + }, + { + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-06-21 12:54:37.764712", + "modified_by": "Administrator", + "module": "HR", + "name": "Grievance Type", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC" +} \ No newline at end of file diff --git a/erpnext/hr/doctype/grievance_type/grievance_type.py b/erpnext/hr/doctype/grievance_type/grievance_type.py new file mode 100644 index 0000000000..618cf0a031 --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/grievance_type.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + +class GrievanceType(Document): + pass diff --git a/erpnext/hr/doctype/grievance_type/test_grievance_type.py b/erpnext/hr/doctype/grievance_type/test_grievance_type.py new file mode 100644 index 0000000000..a02a34d41f --- /dev/null +++ b/erpnext/hr/doctype/grievance_type/test_grievance_type.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import unittest + +class TestGrievanceType(unittest.TestCase): + pass diff --git a/erpnext/hr/doctype/job_applicant/job_applicant_list.js b/erpnext/hr/doctype/job_applicant/job_applicant_list.js index 3b9141ba79..2ad0d591d8 100644 --- a/erpnext/hr/doctype/job_applicant/job_applicant_list.js +++ b/erpnext/hr/doctype/job_applicant/job_applicant_list.js @@ -2,7 +2,7 @@ // MIT License. See license.txt frappe.listview_settings['Job Applicant'] = { - add_fields: ["company", "designation", "job_applicant", "status"], + add_fields: ["status"], get_indicator: function (doc) { if (doc.status == "Accepted") { return [__(doc.status), "green", "status,=," + doc.status]; diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index ae02c512c2..3a6539ece9 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -110,6 +110,7 @@ "label": "Allocation" }, { + "allow_on_submit": 1, "bold": 1, "fieldname": "new_leaves_allocated", "fieldtype": "Float", @@ -235,7 +236,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-14 15:28:26.335104", + "modified": "2021-06-03 15:28:26.335104", "modified_by": "Administrator", "module": "HR", "name": "Leave Allocation", @@ -277,4 +278,4 @@ "sort_field": "modified", "sort_order": "DESC", "timeline_field": "employee" -} \ No newline at end of file +} diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py index 11302cad75..4757cd3b19 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py @@ -8,6 +8,7 @@ from frappe import _ from frappe.model.document import Document from erpnext.hr.utils import set_employee_name, get_leave_period from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import expire_allocation, create_leave_ledger_entry +from erpnext.hr.doctype.leave_application.leave_application import get_approved_leaves_for_period class OverlapError(frappe.ValidationError): pass class BackDatedAllocationError(frappe.ValidationError): pass @@ -55,6 +56,43 @@ class LeaveAllocation(Document): if self.carry_forward: self.set_carry_forwarded_leaves_in_previous_allocation(on_cancel=True) + def on_update_after_submit(self): + if self.has_value_changed("new_leaves_allocated"): + self.validate_against_leave_applications() + leaves_to_be_added = self.new_leaves_allocated - self.get_existing_leave_count() + args = { + "leaves": leaves_to_be_added, + "from_date": self.from_date, + "to_date": self.to_date, + "is_carry_forward": 0 + } + create_leave_ledger_entry(self, args, True) + + def get_existing_leave_count(self): + ledger_entries = frappe.get_all("Leave Ledger Entry", + filters={ + "transaction_type": "Leave Allocation", + "transaction_name": self.name, + "employee": self.employee, + "company": self.company, + "leave_type": self.leave_type + }, + pluck="leaves") + total_existing_leaves = 0 + for entry in ledger_entries: + total_existing_leaves += entry + + return total_existing_leaves + + def validate_against_leave_applications(self): + leaves_taken = get_approved_leaves_for_period(self.employee, self.leave_type, + self.from_date, self.to_date) + if flt(leaves_taken) > flt(self.total_leaves_allocated): + if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"): + frappe.msgprint(_("Note: Total allocated leaves {0} shouldn't be less than already approved leaves {1} for the period").format(self.total_leaves_allocated, leaves_taken)) + else: + frappe.throw(_("Total allocated leaves {0} cannot be less than already approved leaves {1} for the period").format(self.total_leaves_allocated, leaves_taken), LessAllocationError) + def update_leave_policy_assignments_when_no_allocations_left(self): allocations = frappe.db.get_list("Leave Allocation", filters = { "docstatus": 1, @@ -225,4 +263,4 @@ def get_unused_leaves(employee, leave_type, from_date, to_date): def validate_carry_forward(leave_type): if not frappe.db.get_value("Leave Type", leave_type, "is_carry_forward"): - frappe.throw(_("Leave Type {0} cannot be carry-forwarded").format(leave_type)) \ No newline at end of file + frappe.throw(_("Leave Type {0} cannot be carry-forwarded").format(leave_type)) diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index 6e7ae87d08..bff06e6a91 100644 --- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals import frappe +import erpnext import unittest from frappe.utils import nowdate, add_months, getdate, add_days from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type @@ -164,6 +165,51 @@ class TestLeaveAllocation(unittest.TestCase): leave_allocation.cancel() self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name})) + def test_leave_addition_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 40 + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 40) + + def test_leave_subtraction_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 10 + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 10) + + def test_against_leave_application_validation_after_submit(self): + frappe.db.sql("delete from `tabLeave Allocation`") + frappe.db.sql("delete from `tabLeave Ledger Entry`") + + leave_allocation = create_leave_allocation() + leave_allocation.submit() + self.assertTrue(leave_allocation.total_leaves_allocated, 15) + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + leave_application = frappe.get_doc({ + "doctype": 'Leave Application', + "employee": employee.name, + "leave_type": "_Test Leave Type", + "from_date": add_months(nowdate(), 2), + "to_date": add_months(add_days(nowdate(), 10), 2), + "company": erpnext.get_default_company() or "_Test Company", + "docstatus": 1, + "status": "Approved", + "leave_approver": 'test@example.com' + }) + leave_application.submit() + leave_allocation.new_leaves_allocated = 8 + leave_allocation.total_leaves_allocated = 8 + self.assertRaises(frappe.ValidationError, leave_allocation.submit) + def create_leave_allocation(**args): args = frappe._dict(args) diff --git a/erpnext/hr/doctype/staffing_plan/staffing_plan.py b/erpnext/hr/doctype/staffing_plan/staffing_plan.py index 533149a823..e6c783aca2 100644 --- a/erpnext/hr/doctype/staffing_plan/staffing_plan.py +++ b/erpnext/hr/doctype/staffing_plan/staffing_plan.py @@ -41,7 +41,7 @@ class StaffingPlan(Document): detail.total_estimated_cost = 0 if detail.number_of_positions > 0: - if detail.vacancies > 0 and detail.estimated_cost_per_position: + if detail.vacancies and detail.estimated_cost_per_position: detail.total_estimated_cost = cint(detail.vacancies) * flt(detail.estimated_cost_per_position) self.total_estimated_budget += detail.total_estimated_cost @@ -76,12 +76,12 @@ class StaffingPlan(Document): if cint(staffing_plan_detail.vacancies) > cint(parent_plan_details[0].vacancies) or \ flt(staffing_plan_detail.total_estimated_cost) > flt(parent_plan_details[0].total_estimated_cost): frappe.throw(_("You can only plan for upto {0} vacancies and budget {1} \ - for {2} as per staffing plan {3} for parent company {4}." - .format(cint(parent_plan_details[0].vacancies), + for {2} as per staffing plan {3} for parent company {4}.").format( + cint(parent_plan_details[0].vacancies), parent_plan_details[0].total_estimated_cost, frappe.bold(staffing_plan_detail.designation), parent_plan_details[0].name, - parent_company)), ParentCompanyError) + parent_company), ParentCompanyError) #Get vacanices already planned for all companies down the hierarchy of Parent Company lft, rgt = frappe.get_cached_value('Company', parent_company, ["lft", "rgt"]) @@ -98,14 +98,14 @@ class StaffingPlan(Document): (flt(parent_plan_details[0].total_estimated_cost) < \ (flt(staffing_plan_detail.total_estimated_cost) + flt(all_sibling_details.total_estimated_cost))): frappe.throw(_("{0} vacancies and {1} budget for {2} already planned for subsidiary companies of {3}. \ - You can only plan for upto {4} vacancies and and budget {5} as per staffing plan {6} for parent company {3}." - .format(cint(all_sibling_details.vacancies), + You can only plan for upto {4} vacancies and and budget {5} as per staffing plan {6} for parent company {3}.").format( + cint(all_sibling_details.vacancies), all_sibling_details.total_estimated_cost, frappe.bold(staffing_plan_detail.designation), parent_company, cint(parent_plan_details[0].vacancies), parent_plan_details[0].total_estimated_cost, - parent_plan_details[0].name))) + parent_plan_details[0].name)) def validate_with_subsidiary_plans(self, staffing_plan_detail): #Valdate this plan with all child company plan @@ -121,11 +121,11 @@ class StaffingPlan(Document): cint(staffing_plan_detail.vacancies) < cint(children_details.vacancies) or \ flt(staffing_plan_detail.total_estimated_cost) < flt(children_details.total_estimated_cost): frappe.throw(_("Subsidiary companies have already planned for {1} vacancies at a budget of {2}. \ - Staffing Plan for {0} should allocate more vacancies and budget for {3} than planned for its subsidiary companies" - .format(self.company, + Staffing Plan for {0} should allocate more vacancies and budget for {3} than planned for its subsidiary companies").format( + self.company, cint(children_details.vacancies), children_details.total_estimated_cost, - frappe.bold(staffing_plan_detail.designation))), SubsidiaryCompanyError) + frappe.bold(staffing_plan_detail.designation)), SubsidiaryCompanyError) @frappe.whitelist() def get_designation_counts(designation, company): @@ -170,4 +170,4 @@ def get_active_staffing_plan_details(company, designation, from_date=getdate(now designation, from_date, to_date) # Only a single staffing plan can be active for a designation on given date - return staffing_plan if staffing_plan else None \ No newline at end of file + return staffing_plan if staffing_plan else None diff --git a/erpnext/hr/doctype/training_event/training_event.js b/erpnext/hr/doctype/training_event/training_event.js index b7d34b178a..d5f6e5f573 100644 --- a/erpnext/hr/doctype/training_event/training_event.js +++ b/erpnext/hr/doctype/training_event/training_event.js @@ -20,11 +20,10 @@ frappe.ui.form.on('Training Event', { frappe.set_route("List", "Training Feedback"); }); } - } -}); + frm.events.set_employee_query(frm); + }, -frappe.ui.form.on("Training Event Employee", { - employee: function (frm) { + set_employee_query: function(frm) { let emp = []; for (let d in frm.doc.employees) { if (frm.doc.employees[d].employee) { @@ -34,9 +33,17 @@ frappe.ui.form.on("Training Event Employee", { frm.set_query("employee", "employees", function () { return { filters: { - name: ["NOT IN", emp] + name: ["NOT IN", emp], + status: "Active" } }; }); } }); + +frappe.ui.form.on("Training Event Employee", { + employee: function(frm) { + frm.events.set_employee_query(frm); + } +}); + diff --git a/erpnext/hr/doctype/training_event_employee/training_event_employee.json b/erpnext/hr/doctype/training_event_employee/training_event_employee.json index 2d313e9fac..bcb7d5e5bc 100644 --- a/erpnext/hr/doctype/training_event_employee/training_event_employee.json +++ b/erpnext/hr/doctype/training_event_employee/training_event_employee.json @@ -19,6 +19,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Employee", + "no_copy": 1, "options": "Employee" }, { @@ -68,7 +69,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-05-21 12:41:59.336237", + "modified": "2021-07-02 17:20:27.630176", "modified_by": "Administrator", "module": "HR", "name": "Training Event Employee", diff --git a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py index 4dd4570e8c..b8953b3eaa 100644 --- a/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py +++ b/erpnext/hr/report/employee_leave_balance/employee_leave_balance.py @@ -178,7 +178,7 @@ def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type): is_carry_forward, is_expired FROM `tabLeave Ledger Entry` WHERE employee=%(employee)s AND leave_type=%(leave_type)s - AND docstatus=1 AND leaves>0 + AND docstatus=1 AND (from_date between %(from_date)s AND %(to_date)s OR to_date between %(from_date)s AND %(to_date)s OR (from_date < %(from_date)s AND to_date > %(to_date)s)) diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json index c5201c22c9..4500ba4560 100644 --- a/erpnext/hr/workspace/hr/hr.json +++ b/erpnext/hr/workspace/hr/hr.json @@ -153,6 +153,24 @@ "onboard": 0, "type": "Link" }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Grievance Type", + "link_to": "Grievance Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Grievance", + "link_to": "Employee Grievance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, { "dependencies": "Employee", "hidden": 0, @@ -823,7 +841,7 @@ "type": "Link" } ], - "modified": "2021-04-26 13:36:15.413819", + "modified": "2021-05-13 17:19:40.524444", "modified_by": "Administrator", "module": "HR", "name": "HR", diff --git a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json index 18bd4aea78..68bac8ed8c 100644 --- a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json +++ b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.json @@ -35,7 +35,9 @@ "no_copy": 1, "options": "Loan Security Pledge", "print_hide": 1, - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fetch_from": "loan_application.applicant", @@ -45,47 +47,63 @@ "in_standard_filter": 1, "label": "Applicant", "options": "applicant_type", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_security_details_section", "fieldtype": "Section Break", - "label": "Loan Security Details" + "label": "Loan Security Details", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_3", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan", "fieldtype": "Link", "label": "Loan", - "options": "Loan" + "options": "Loan", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_application", "fieldtype": "Link", "label": "Loan Application", - "options": "Loan Application" + "options": "Loan Application", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "total_security_value", "fieldtype": "Currency", "label": "Total Security Value", "options": "Company:company:default_currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "maximum_loan_value", "fieldtype": "Currency", "label": "Maximum Loan Value", "options": "Company:company:default_currency", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "loan_details_section", "fieldtype": "Section Break", - "label": "Loan Details" + "label": "Loan Details", + "show_days": 1, + "show_seconds": 1 }, { "default": "Requested", @@ -94,37 +112,49 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "Requested\nUnpledged\nPledged\nPartially Pledged", - "read_only": 1 + "options": "Requested\nUnpledged\nPledged\nPartially Pledged\nCancelled", + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "pledge_time", "fieldtype": "Datetime", "label": "Pledge Time", - "read_only": 1 + "read_only": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "securities", "fieldtype": "Table", "label": "Securities", "options": "Pledge", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_11", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "section_break_10", "fieldtype": "Section Break", - "label": "Totals" + "label": "Totals", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "company", "fieldtype": "Link", "label": "Company", "options": "Company", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "fetch_from": "loan.applicant_type", @@ -132,35 +162,45 @@ "fieldtype": "Select", "label": "Applicant Type", "options": "Employee\nMember\nCustomer", - "reqd": 1 + "reqd": 1, + "show_days": 1, + "show_seconds": 1 }, { "collapsible": 1, "fieldname": "more_information_section", "fieldtype": "Section Break", - "label": "More Information" + "label": "More Information", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "reference_no", "fieldtype": "Data", - "label": "Reference No" + "label": "Reference No", + "show_days": 1, + "show_seconds": 1 }, { "fieldname": "column_break_18", - "fieldtype": "Column Break" + "fieldtype": "Column Break", + "show_days": 1, + "show_seconds": 1 }, { "allow_on_submit": 1, "fieldname": "description", "fieldtype": "Text", - "label": "Description" + "label": "Description", + "show_days": 1, + "show_seconds": 1 } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-04-19 18:23:16.953305", + "modified": "2021-06-29 17:15:16.082256", "modified_by": "Administrator", "module": "Loan Management", "name": "Loan Security Pledge", diff --git a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py index cbc8376aa5..c390b6c526 100644 --- a/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py +++ b/erpnext/loan_management/doctype/loan_security_pledge/loan_security_pledge.py @@ -23,6 +23,12 @@ class LoanSecurityPledge(Document): update_shortfall_status(self.loan, self.total_security_value) update_loan(self.loan, self.maximum_loan_value) + def on_cancel(self): + if self.loan: + self.db_set("status", "Cancelled") + self.db_set("pledge_time", None) + update_loan(self.loan, self.maximum_loan_value, cancel=1) + def validate_duplicate_securities(self): security_list = [] for security in self.securities: @@ -36,7 +42,7 @@ class LoanSecurityPledge(Document): existing_pledge = '' if self.loan: - existing_pledge = frappe.db.get_value('Loan Security Pledge', {'loan': self.loan}, ['name']) + existing_pledge = frappe.db.get_value('Loan Security Pledge', {'loan': self.loan, 'docstatus': 1}, ['name']) if existing_pledge: loan_security_type = frappe.db.get_value('Pledge', {'parent': existing_pledge}, ['loan_security_type']) @@ -77,8 +83,12 @@ class LoanSecurityPledge(Document): self.total_security_value = total_security_value self.maximum_loan_value = maximum_loan_value -def update_loan(loan, maximum_value_against_pledge): +def update_loan(loan, maximum_value_against_pledge, cancel=0): maximum_loan_value = frappe.db.get_value('Loan', {'name': loan}, ['maximum_loan_amount']) - frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s, is_secured_loan=1 - WHERE name=%s""", (maximum_loan_value + maximum_value_against_pledge, loan)) + if cancel: + frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s + WHERE name=%s""", (maximum_loan_value - maximum_value_against_pledge, loan)) + else: + frappe.db.sql(""" UPDATE `tabLoan` SET maximum_loan_amount=%s, is_secured_loan=1 + WHERE name=%s""", (maximum_loan_value + maximum_value_against_pledge, loan)) diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js index 4c31bd0b7d..f19a1b0868 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.js +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.js @@ -13,7 +13,7 @@ frappe.ui.form.on('Blanket Order', { refresh: function(frm) { erpnext.hide_company(); - if (frm.doc.customer && frm.doc.docstatus === 1) { + if (frm.doc.customer && frm.doc.docstatus === 1 && frm.doc.to_date > frappe.datetime.get_today()) { frm.add_custom_button(__("Sales Order"), function() { frappe.model.open_mapped_doc({ method: "erpnext.manufacturing.doctype.blanket_order.blanket_order.make_order", diff --git a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json index 0330e5c85c..a63fc4da69 100644 --- a/erpnext/manufacturing/doctype/blanket_order/blanket_order.json +++ b/erpnext/manufacturing/doctype/blanket_order/blanket_order.json @@ -1,4 +1,5 @@ { + "actions": [], "autoname": "naming_series:", "creation": "2018-05-24 07:18:08.256060", "doctype": "DocType", @@ -79,6 +80,7 @@ "reqd": 1 }, { + "allow_on_submit": 1, "fieldname": "to_date", "fieldtype": "Date", "label": "To Date", @@ -129,8 +131,10 @@ "label": "Terms and Conditions Details" } ], + "index_web_pages_for_search": 1, "is_submittable": 1, - "modified": "2019-11-18 19:37:37.151686", + "links": [], + "modified": "2021-06-29 00:30:30.621636", "modified_by": "Administrator", "module": "Manufacturing", "name": "Blanket Order", diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js index a09a5e3430..15a7c316c9 100644 --- a/erpnext/manufacturing/doctype/bom/bom.js +++ b/erpnext/manufacturing/doctype/bom/bom.js @@ -71,7 +71,6 @@ frappe.ui.form.on("BOM", { refresh: function(frm) { frm.toggle_enable("item", frm.doc.__islocal); - toggle_operations(frm); frm.set_indicator_formatter('item_code', function(doc) { @@ -326,8 +325,7 @@ frappe.ui.form.on("BOM", { freeze: true, args: { update_parent: true, - from_child_bom:false, - save: frm.doc.docstatus === 1 ? true : false + from_child_bom:false }, callback: function(r) { refresh_field("items"); @@ -651,15 +649,8 @@ frappe.ui.form.on("BOM Item", "items_remove", function(frm) { erpnext.bom.calculate_total(frm.doc); }); -var toggle_operations = function(frm) { - frm.toggle_display("operations_section", cint(frm.doc.with_operations) == 1); - frm.toggle_display("transfer_material_against", cint(frm.doc.with_operations) == 1); - frm.toggle_reqd("transfer_material_against", cint(frm.doc.with_operations) == 1); -}; - frappe.ui.form.on("BOM", "with_operations", function(frm) { if(!cint(frm.doc.with_operations)) { frm.set_value("operations", []); } - toggle_operations(frm); }); \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/bom/bom.json b/erpnext/manufacturing/doctype/bom/bom.json index f551b91597..7e539183b0 100644 --- a/erpnext/manufacturing/doctype/bom/bom.json +++ b/erpnext/manufacturing/doctype/bom/bom.json @@ -36,6 +36,9 @@ "materials_section", "inspection_required", "quality_inspection_template", + "column_break_31", + "bom_level", + "section_break_33", "items", "scrap_section", "scrap_items", @@ -193,6 +196,7 @@ }, { "default": "Work Order", + "depends_on": "with_operations", "fieldname": "transfer_material_against", "fieldtype": "Select", "label": "Transfer Material Against", @@ -235,6 +239,7 @@ { "fieldname": "operations_section", "fieldtype": "Section Break", + "hide_border": 1, "oldfieldtype": "Section Break" }, { @@ -245,6 +250,7 @@ "options": "Routing" }, { + "depends_on": "with_operations", "fieldname": "operations", "fieldtype": "Table", "label": "Operations", @@ -510,6 +516,22 @@ "no_copy": 1, "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_31", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "bom_level", + "fieldtype": "Int", + "label": "BOM Level", + "read_only": 1 + }, + { + "fieldname": "section_break_33", + "fieldtype": "Section Break", + "hide_border": 1 } ], "icon": "fa fa-sitemap", @@ -517,7 +539,7 @@ "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2020-05-21 12:29:32.634952", + "modified": "2021-05-16 12:25:09.081968", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM", diff --git a/erpnext/manufacturing/doctype/bom/bom.py b/erpnext/manufacturing/doctype/bom/bom.py index d1f63854c7..c32a8a95a1 100644 --- a/erpnext/manufacturing/doctype/bom/bom.py +++ b/erpnext/manufacturing/doctype/bom/bom.py @@ -1,7 +1,8 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals +from typing import List +from collections import deque import frappe, erpnext from frappe.utils import cint, cstr, flt, today from frappe import _ @@ -16,14 +17,85 @@ from frappe.model.mapper import get_mapped_doc import functools -from six import string_types - from operator import itemgetter form_grid_templates = { "items": "templates/form_grid/item_grid.html" } + +class BOMTree: + """Full tree representation of a BOM""" + + # specifying the attributes to save resources + # ref: https://docs.python.org/3/reference/datamodel.html#slots + __slots__ = ["name", "child_items", "is_bom", "item_code", "exploded_qty", "qty"] + + def __init__(self, name: str, is_bom: bool = True, exploded_qty: float = 1.0, qty: float = 1) -> None: + self.name = name # name of node, BOM number if is_bom else item_code + self.child_items: List["BOMTree"] = [] # list of child items + self.is_bom = is_bom # true if the node is a BOM and not a leaf item + self.item_code: str = None # item_code associated with node + self.qty = qty # required unit quantity to make one unit of parent item. + self.exploded_qty = exploded_qty # total exploded qty required for making root of tree. + if not self.is_bom: + self.item_code = self.name + else: + self.__create_tree() + + def __create_tree(self): + bom = frappe.get_cached_doc("BOM", self.name) + self.item_code = bom.item + + for item in bom.get("items", []): + qty = item.qty / bom.quantity # quantity per unit + exploded_qty = self.exploded_qty * qty + if item.bom_no: + child = BOMTree(item.bom_no, exploded_qty=exploded_qty, qty=qty) + self.child_items.append(child) + else: + self.child_items.append( + BOMTree(item.item_code, is_bom=False, exploded_qty=exploded_qty, qty=qty) + ) + + def level_order_traversal(self) -> List["BOMTree"]: + """Get level order traversal of tree. + E.g. for following tree the traversal will return list of nodes in order from top to bottom. + BOM: + - SubAssy1 + - item1 + - item2 + - SubAssy2 + - item3 + - item4 + + returns = [SubAssy1, item1, item2, SubAssy2, item3, item4] + """ + traversal = [] + q = deque() + q.append(self) + + while q: + node = q.popleft() + + for child in node.child_items: + traversal.append(child) + q.append(child) + + return traversal + + def __str__(self) -> str: + return ( + f"{self.item_code}{' - ' + self.name if self.is_bom else ''} qty(per unit): {self.qty}" + f" exploded_qty: {self.exploded_qty}" + ) + + def __repr__(self, level: int = 0) -> str: + rep = "┃ " * (level - 1) + "┣━ " * (level > 0) + str(self) + "\n" + for child in self.child_items: + rep += child.__repr__(level=level + 1) + return rep + class BOM(WebsiteGenerator): website = frappe._dict( # page_title_field = "item_name", @@ -81,7 +153,8 @@ class BOM(WebsiteGenerator): self.validate_operations() self.calculate_cost() self.update_stock_qty() - self.update_cost(update_parent=False, from_child_bom=True, save=False) + self.update_cost(update_parent=False, from_child_bom=True, update_hour_rate = False, save=False) + self.set_bom_level() def get_context(self, context): context.parents = [{'name': 'boms', 'title': _('All BOMs') }] @@ -152,7 +225,7 @@ class BOM(WebsiteGenerator): if not args: args = frappe.form_dict.get('args') - if isinstance(args, string_types): + if isinstance(args, str): import json args = json.loads(args) @@ -213,7 +286,7 @@ class BOM(WebsiteGenerator): return flt(rate) * flt(self.plc_conversion_rate or 1) / (self.conversion_rate or 1) @frappe.whitelist() - def update_cost(self, update_parent=True, from_child_bom=False, save=True): + def update_cost(self, update_parent=True, from_child_bom=False, update_hour_rate = True, save=True): if self.docstatus == 2: return @@ -242,7 +315,7 @@ class BOM(WebsiteGenerator): if self.docstatus == 1: self.flags.ignore_validate_update_after_submit = True - self.calculate_cost() + self.calculate_cost(update_hour_rate) if save: self.db_update() @@ -403,32 +476,47 @@ class BOM(WebsiteGenerator): bom_list.reverse() return bom_list - def calculate_cost(self): + def calculate_cost(self, update_hour_rate = False): """Calculate bom totals""" - self.calculate_op_cost() + self.calculate_op_cost(update_hour_rate) self.calculate_rm_cost() self.calculate_sm_cost() self.total_cost = self.operating_cost + self.raw_material_cost - self.scrap_material_cost self.base_total_cost = self.base_operating_cost + self.base_raw_material_cost - self.base_scrap_material_cost - def calculate_op_cost(self): + def calculate_op_cost(self, update_hour_rate = False): """Update workstation rate and calculates totals""" self.operating_cost = 0 self.base_operating_cost = 0 for d in self.get('operations'): if d.workstation: - if not d.hour_rate: - hour_rate = flt(frappe.db.get_value("Workstation", d.workstation, "hour_rate")) - d.hour_rate = hour_rate / flt(self.conversion_rate) if self.conversion_rate else hour_rate - - if d.hour_rate and d.time_in_mins: - d.base_hour_rate = flt(d.hour_rate) * flt(self.conversion_rate) - d.operating_cost = flt(d.hour_rate) * flt(d.time_in_mins) / 60.0 - d.base_operating_cost = flt(d.operating_cost) * flt(self.conversion_rate) + self.update_rate_and_time(d, update_hour_rate) self.operating_cost += flt(d.operating_cost) self.base_operating_cost += flt(d.base_operating_cost) + def update_rate_and_time(self, row, update_hour_rate = False): + if not row.hour_rate or update_hour_rate: + hour_rate = flt(frappe.get_cached_value("Workstation", row.workstation, "hour_rate")) + row.hour_rate = (hour_rate / flt(self.conversion_rate) + if self.conversion_rate and hour_rate else hour_rate) + + if self.routing: + row.time_in_mins = flt(frappe.db.get_value("BOM Operation", { + "workstation": row.workstation, + "operation": row.operation, + "sequence_id": row.sequence_id, + "parent": self.routing + }, ["time_in_mins"])) + + if row.hour_rate and row.time_in_mins: + row.base_hour_rate = flt(row.hour_rate) * flt(self.conversion_rate) + row.operating_cost = flt(row.hour_rate) * flt(row.time_in_mins) / 60.0 + row.base_operating_cost = flt(row.operating_cost) * flt(self.conversion_rate) + + if update_hour_rate: + row.db_update() + def calculate_rm_cost(self): """Fetch RM rate as per today's valuation rate and calculate totals""" total_rm_cost = 0 @@ -575,7 +663,7 @@ class BOM(WebsiteGenerator): self.get_routing() def validate_operations(self): - if self.with_operations and not self.get('operations'): + if self.with_operations and not self.get('operations') and self.docstatus == 1: frappe.throw(_("Operations cannot be left blank")) if self.with_operations: @@ -585,6 +673,24 @@ class BOM(WebsiteGenerator): if not d.batch_size or d.batch_size <= 0: d.batch_size = 1 + def get_tree_representation(self) -> BOMTree: + """Get a complete tree representation preserving order of child items.""" + return BOMTree(self.name) + + def set_bom_level(self, update=False): + levels = [] + + self.bom_level = 0 + for row in self.items: + if row.bom_no: + levels.append(frappe.get_cached_value("BOM", row.bom_no, "bom_level") or 0) + + if levels: + self.bom_level = max(levels) + 1 + + if update: + self.db_set("bom_level", self.bom_level) + def get_bom_item_rate(args, bom_doc): if bom_doc.rm_cost_as_per == 'Valuation Rate': rate = get_valuation_rate(args) * (args.get("conversion_factor") or 1) @@ -768,7 +874,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): frappe.form_dict.parent = parent if frappe.form_dict.parent: - bom_doc = frappe.get_doc("BOM", frappe.form_dict.parent) + bom_doc = frappe.get_cached_doc("BOM", frappe.form_dict.parent) frappe.has_permission("BOM", doc=bom_doc, throw=True) bom_items = frappe.get_all('BOM Item', @@ -779,7 +885,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): item_names = tuple(d.get('item_code') for d in bom_items) items = frappe.get_list('Item', - fields=['image', 'description', 'name', 'stock_uom', 'item_name'], + fields=['image', 'description', 'name', 'stock_uom', 'item_name', 'is_sub_contracted_item'], filters=[['name', 'in', item_names]]) # to get only required item dicts for bom_item in bom_items: @@ -792,6 +898,7 @@ def get_children(doctype, parent=None, is_root=False, **filters): bom_item.parent_bom_qty = bom_doc.quantity bom_item.expandable = 0 if bom_item.value in ('', None) else 1 + bom_item.image = frappe.db.escape(bom_item.image) return bom_items @@ -975,7 +1082,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters): if filters and filters.get("is_stock_item"): query_filters["is_stock_item"] = 1 - + return frappe.get_all("Item", fields = fields, filters=query_filters, or_filters = or_cond_filters, order_by=order_by, @@ -1008,6 +1115,8 @@ def make_variant_bom(source_name, bom_no, item, variant_items, target_doc=None): }, 'BOM Item': { 'doctype': 'BOM Item', + # stop get_mapped_doc copying parent bom_no to children + 'field_no_map': ['bom_no'], 'condition': lambda doc: doc.has_variants == 0 }, }, target_doc, postprocess) diff --git a/erpnext/manufacturing/doctype/bom/bom_item_preview.html b/erpnext/manufacturing/doctype/bom/bom_item_preview.html index 6cd5f8cb3c..6088e46265 100644 --- a/erpnext/manufacturing/doctype/bom/bom_item_preview.html +++ b/erpnext/manufacturing/doctype/bom/bom_item_preview.html @@ -1,13 +1,31 @@
- {% if data.image %} - -
- {% endif %} -

- {{ __("Description") }} -

-
- {{ data.description }} +
+
+ {% if data.image %} +
+ +
+ {% endif %} +
+
+

+ {{ __("Description") }} +

+
+ {{ data.description }} +
+
+

+ {% if data.value %} + + {{ __("Open BOM {0}", [data.value.bold()]) }} + {% endif %} + {% if data.item_code %} + + {{ __("Open Item {0}", [data.item_code.bold()]) }} + {% endif %} +

+

diff --git a/erpnext/manufacturing/doctype/bom/bom_tree.js b/erpnext/manufacturing/doctype/bom/bom_tree.js index 185b9ed4bc..60fb377f47 100644 --- a/erpnext/manufacturing/doctype/bom/bom_tree.js +++ b/erpnext/manufacturing/doctype/bom/bom_tree.js @@ -64,7 +64,7 @@ frappe.treeview_settings["BOM"] = { if(node.is_root && node.data.value!="BOM") { frappe.model.with_doc("BOM", node.data.value, function() { var bom = frappe.model.get_doc("BOM", node.data.value); - node.data.image = bom.image || ""; + node.data.image = escape(bom.image) || ""; node.data.description = bom.description || ""; }); } diff --git a/erpnext/manufacturing/doctype/bom/test_bom.py b/erpnext/manufacturing/doctype/bom/test_bom.py index 42b23f223d..57a5458726 100644 --- a/erpnext/manufacturing/doctype/bom/test_bom.py +++ b/erpnext/manufacturing/doctype/bom/test_bom.py @@ -2,14 +2,13 @@ # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals +from collections import deque import unittest import frappe from frappe.utils import cstr, flt from frappe.test_runner import make_test_records from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import create_stock_reconciliation from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost -from six import string_types from erpnext.stock.doctype.item.test_item import make_item from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order from erpnext.tests.test_subcontracting import set_backflush_based_on @@ -123,7 +122,7 @@ class TestBOM(unittest.TestCase): bom.items[0].conversion_factor = 5 bom.insert() - bom.update_cost() + bom.update_cost(update_hour_rate = False) # test amounts in selected currency self.assertEqual(bom.items[0].rate, 300) @@ -227,11 +226,88 @@ class TestBOM(unittest.TestCase): supplied_items = sorted([d.rm_item_code for d in po.supplied_items]) self.assertEqual(bom_items, supplied_items) + def test_bom_tree_representation(self): + bom_tree = { + "Assembly": { + "SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},}, + "SubAssembly2": {"ChildPart3": {}}, + "SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}}, + "ChildPart5": {}, + "ChildPart6": {}, + "SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}}, + } + } + parent_bom = create_nested_bom(bom_tree, prefix="") + created_tree = parent_bom.get_tree_representation() + + reqd_order = level_order_traversal(bom_tree)[1:] # skip first item + created_order = created_tree.level_order_traversal() + + self.assertEqual(len(reqd_order), len(created_order)) + + for reqd_item, created_item in zip(reqd_order, created_order): + self.assertEqual(reqd_item, created_item.item_code) + + 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}) + + + +def level_order_traversal(node): + traversal = [] + q = deque() + q.append(node) + + while q: + node = q.popleft() + + for node_name, subtree in node.items(): + traversal.append(node_name) + q.append(subtree) + + return traversal + +def create_nested_bom(tree, prefix="_Test bom "): + """ Helper function to create a simple nested bom from tree describing item names. (along with required items) + """ + + def create_items(bom_tree): + for item_code, subtree in bom_tree.items(): + bom_item_code = prefix + item_code + if not frappe.db.exists("Item", bom_item_code): + frappe.get_doc(doctype="Item", item_code=bom_item_code, item_group="_Test Item Group").insert() + create_items(subtree) + create_items(tree) + + def dfs(tree, node): + """naive implementation for searching right subtree""" + for node_name, subtree in tree.items(): + if node_name == node: + return subtree + else: + result = dfs(subtree, node) + if result is not None: + return result + + order_of_creating_bom = reversed(level_order_traversal(tree)) + + for item in order_of_creating_bom: + child_items = dfs(tree, item) + if child_items: + bom_item_code = prefix + item + bom = frappe.get_doc(doctype="BOM", item=bom_item_code) + for child_item in child_items.keys(): + bom.append("items", {"item_code": prefix + child_item}) + bom.insert() + bom.submit() + + return bom # parent bom is last bom + + def reset_item_valuation_rate(item_code, warehouse_list=None, qty=None, rate=None): - if warehouse_list and isinstance(warehouse_list, string_types): + if warehouse_list and isinstance(warehouse_list, str): warehouse_list = [warehouse_list] if not warehouse_list: diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 07464e3e76..4458e6db23 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -13,10 +13,10 @@ "col_break1", "hour_rate", "time_in_mins", - "batch_size", "operating_cost", "base_hour_rate", "base_operating_cost", + "batch_size", "image" ], "fields": [ @@ -61,6 +61,8 @@ }, { "description": "In minutes", + "fetch_from": "operation.total_operation_time", + "fetch_if_empty": 1, "fieldname": "time_in_mins", "fieldtype": "Float", "in_list_view": 1, @@ -104,7 +106,8 @@ "label": "Image" }, { - "default": "1", + "fetch_from": "operation.batch_size", + "fetch_if_empty": 1, "fieldname": "batch_size", "fieldtype": "Int", "label": "Batch Size" @@ -120,7 +123,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-13 18:14:10.018774", + "modified": "2021-01-12 14:48:09.596843", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 4e8dd41022..81860c9fbc 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -11,6 +11,16 @@ frappe.ui.form.on('Job Card', { } }; }); + + frm.set_indicator_formatter('sub_operation', + function(doc) { + if (doc.status == "Pending") { + return "red"; + } else { + return doc.status === "Complete" ? "green" : "orange"; + } + } + ); }, refresh: function(frm) { @@ -31,6 +41,10 @@ frappe.ui.form.on('Job Card', { } } + if (frm.doc.docstatus == 1 && !frm.doc.is_corrective_job_card) { + frm.trigger('setup_corrective_job_card'); + } + frm.set_query("quality_inspection", function() { return { query: "erpnext.stock.doctype.quality_inspection.quality_inspection.quality_inspection_query", @@ -43,12 +57,62 @@ frappe.ui.form.on('Job Card', { frm.trigger("toggle_operation_number"); - if (frm.doc.docstatus == 0 && (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) + if (frm.doc.docstatus == 0 && !frm.is_new() && + (frm.doc.for_quantity > frm.doc.total_completed_qty || !frm.doc.for_quantity) && (frm.doc.items || !frm.doc.items.length || frm.doc.for_quantity == frm.doc.transferred_qty)) { frm.trigger("prepare_timer_buttons"); } }, + setup_corrective_job_card: function(frm) { + frm.add_custom_button(__('Corrective Job Card'), () => { + let operations = frm.doc.sub_operations.map(d => d.sub_operation).concat(frm.doc.operation); + + let fields = [ + { + fieldtype: 'Link', label: __('Corrective Operation'), options: 'Operation', + fieldname: 'operation', get_query() { + return { + filters: { + "is_corrective_operation": 1 + } + }; + } + }, { + fieldtype: 'Link', label: __('For Operation'), options: 'Operation', + fieldname: 'for_operation', get_query() { + return { + filters: { + "name": ["in", operations] + } + }; + } + } + ]; + + frappe.prompt(fields, d => { + frm.events.make_corrective_job_card(frm, d.operation, d.for_operation); + }, __("Select Corrective Operation")); + }, __('Make')); + }, + + make_corrective_job_card: function(frm, operation, for_operation) { + frappe.call({ + method: 'erpnext.manufacturing.doctype.job_card.job_card.make_corrective_job_card', + args: { + source_name: frm.doc.name, + operation: operation, + for_operation: for_operation + }, + callback: function(r) { + if (r.message) { + frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + } + }); + }, + operation: function(frm) { frm.trigger("toggle_operation_number"); @@ -97,101 +161,105 @@ frappe.ui.form.on('Job Card', { prepare_timer_buttons: function(frm) { frm.trigger("make_dashboard"); - if (!frm.doc.job_started) { - frm.add_custom_button(__("Start"), () => { - if (!frm.doc.employee) { - frappe.prompt({fieldtype: 'Link', label: __('Employee'), options: "Employee", - fieldname: 'employee'}, d => { - if (d.employee) { - frm.set_value("employee", d.employee); - } else { - frm.events.start_job(frm); - } - }, __("Enter Value"), __("Start")); + + if (!frm.doc.started_time && !frm.doc.current_time) { + frm.add_custom_button(__("Start Job"), () => { + if ((frm.doc.employee && !frm.doc.employee.length) || !frm.doc.employee) { + frappe.prompt({fieldtype: 'Table MultiSelect', label: __('Select Employees'), + options: "Job Card Time Log", fieldname: 'employees'}, d => { + frm.events.start_job(frm, "Work In Progress", d.employees); + }, __("Assign Job to Employee")); } else { - frm.events.start_job(frm); + frm.events.start_job(frm, "Work In Progress", frm.doc.employee); } }).addClass("btn-primary"); } else if (frm.doc.status == "On Hold") { - frm.add_custom_button(__("Resume"), () => { - frappe.flags.resume_job = 1; - frm.events.start_job(frm); + frm.add_custom_button(__("Resume Job"), () => { + frm.events.start_job(frm, "Resume Job", frm.doc.employee); }).addClass("btn-primary"); } else { - frm.add_custom_button(__("Pause"), () => { - frappe.flags.pause_job = 1; - frm.set_value("status", "On Hold"); - frm.events.complete_job(frm); + frm.add_custom_button(__("Pause Job"), () => { + frm.events.complete_job(frm, "On Hold"); }); - frm.add_custom_button(__("Complete"), () => { - let completed_time = frappe.datetime.now_datetime(); - frm.trigger("hide_timer"); + frm.add_custom_button(__("Complete Job"), () => { + var sub_operations = frm.doc.sub_operations; - if (frm.doc.for_quantity) { + let set_qty = true; + if (sub_operations && sub_operations.length > 1) { + set_qty = false; + let last_op_row = sub_operations[sub_operations.length - 2]; + + if (last_op_row.status == 'Complete') { + set_qty = true; + } + } + + if (set_qty) { frappe.prompt({fieldtype: 'Float', label: __('Completed Quantity'), - fieldname: 'qty', reqd: 1, default: frm.doc.for_quantity}, data => { - frm.events.complete_job(frm, completed_time, data.qty); - }, __("Enter Value"), __("Complete")); + fieldname: 'qty', default: frm.doc.for_quantity}, data => { + frm.events.complete_job(frm, "Complete", data.qty); + }, __("Enter Value")); } else { - frm.events.complete_job(frm, completed_time, 0); + frm.events.complete_job(frm, "Complete", 0.0); } }).addClass("btn-primary"); } }, - start_job: function(frm) { - let row = frappe.model.add_child(frm.doc, 'Job Card Time Log', 'time_logs'); - row.from_time = frappe.datetime.now_datetime(); - frm.set_value('job_started', 1); - frm.set_value('started_time' , row.from_time); - frm.set_value("status", "Work In Progress"); - - if (!frappe.flags.resume_job) { - frm.set_value('current_time' , 0); - } - - frm.save(); + start_job: function(frm, status, employee) { + const args = { + job_card_id: frm.doc.name, + start_time: frappe.datetime.now_datetime(), + employees: employee, + status: status + }; + frm.events.make_time_log(frm, args); }, - complete_job: function(frm, completed_time, completed_qty) { - frm.doc.time_logs.forEach(d => { - if (d.from_time && !d.to_time) { - d.to_time = completed_time || frappe.datetime.now_datetime(); - d.completed_qty = completed_qty || 0; + complete_job: function(frm, status, completed_qty) { + const args = { + job_card_id: frm.doc.name, + complete_time: frappe.datetime.now_datetime(), + status: status, + completed_qty: completed_qty + }; + frm.events.make_time_log(frm, args); + }, - if(frappe.flags.pause_job) { - let currentIncrement = moment(d.to_time).diff(moment(d.from_time),"seconds") || 0; - frm.set_value('current_time' , currentIncrement + (frm.doc.current_time || 0)); - } else { - frm.set_value('started_time' , ''); - frm.set_value('job_started', 0); - frm.set_value('current_time' , 0); - } + make_time_log: function(frm, args) { + frm.events.update_sub_operation(frm, args); - frm.save(); + frappe.call({ + method: "erpnext.manufacturing.doctype.job_card.job_card.make_time_log", + args: { + args: args + }, + freeze: true, + callback: function () { + frm.reload_doc(); + frm.trigger("make_dashboard"); } }); }, + update_sub_operation: function(frm, args) { + if (frm.doc.sub_operations && frm.doc.sub_operations.length) { + let sub_operations = frm.doc.sub_operations.filter(d => d.status != 'Complete'); + if (sub_operations && sub_operations.length) { + args["sub_operation"] = sub_operations[0].sub_operation; + } + } + }, + validate: function(frm) { if ((!frm.doc.time_logs || !frm.doc.time_logs.length) && frm.doc.started_time) { frm.trigger("reset_timer"); } }, - employee: function(frm) { - if (frm.doc.job_started && !frm.doc.current_time) { - frm.trigger("reset_timer"); - } else { - frm.events.start_job(frm); - } - }, - reset_timer: function(frm) { frm.set_value('started_time' , ''); - frm.set_value('job_started', 0); - frm.set_value('current_time' , 0); }, make_dashboard: function(frm) { @@ -297,7 +365,6 @@ frappe.ui.form.on('Job Card Time Log', { }, to_time: function(frm) { - frm.set_value('job_started', 0); frm.set_value('started_time', ''); } }) \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card/job_card.json b/erpnext/manufacturing/doctype/job_card/job_card.json index 5713f697e9..046e2fd182 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.json +++ b/erpnext/manufacturing/doctype/job_card/job_card.json @@ -9,38 +9,49 @@ "naming_series", "work_order", "bom_no", - "workstation", - "operation", - "operation_row_number", "column_break_4", "posting_date", "company", - "remarks", "production_section", "production_item", "item_name", "for_quantity", - "quality_inspection", - "wip_warehouse", + "serial_no", "column_break_12", - "employee", - "employee_name", - "status", + "wip_warehouse", + "quality_inspection", "project", + "batch_no", + "operation_section_section", + "operation", + "operation_row_number", + "column_break_18", + "workstation", + "employee", + "section_break_21", + "sub_operations", "timing_detail", "time_logs", "section_break_13", "total_completed_qty", - "total_time_in_mins", "column_break_15", + "total_time_in_mins", "section_break_8", "items", + "corrective_operation_section", + "for_job_card", + "is_corrective_job_card", + "column_break_33", + "hour_rate", + "for_operation", "more_information", "operation_id", "sequence_id", "transferred_qty", "requested_qty", + "status", "column_break_20", + "remarks", "barcode", "job_started", "started_time", @@ -117,13 +128,6 @@ "fieldtype": "Section Break", "label": "Timing Detail" }, - { - "fieldname": "employee", - "fieldtype": "Link", - "in_standard_filter": 1, - "label": "Employee", - "options": "Employee" - }, { "allow_bulk_edit": 1, "fieldname": "time_logs", @@ -133,9 +137,11 @@ }, { "fieldname": "section_break_13", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { + "default": "0", "fieldname": "total_completed_qty", "fieldtype": "Float", "label": "Total Completed Qty", @@ -160,8 +166,7 @@ "fieldname": "items", "fieldtype": "Table", "label": "Items", - "options": "Job Card Item", - "read_only": 1 + "options": "Job Card Item" }, { "collapsible": 1, @@ -251,12 +256,7 @@ "reqd": 1 }, { - "fetch_from": "employee.employee_name", - "fieldname": "employee_name", - "fieldtype": "Read Only", - "label": "Employee Name" - }, - { + "collapsible": 1, "fieldname": "production_section", "fieldtype": "Section Break", "label": "Production" @@ -314,11 +314,89 @@ "label": "Quality Inspection", "no_copy": 1, "options": "Quality Inspection" + }, + { + "allow_bulk_edit": 1, + "fieldname": "sub_operations", + "fieldtype": "Table", + "label": "Sub Operations", + "options": "Job Card Operation", + "read_only": 1 + }, + { + "fieldname": "operation_section_section", + "fieldtype": "Section Break", + "label": "Operation Section" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "fieldname": "section_break_21", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "depends_on": "is_corrective_job_card", + "fieldname": "hour_rate", + "fieldtype": "Currency", + "label": "Hour Rate" + }, + { + "collapsible": 1, + "depends_on": "is_corrective_job_card", + "fieldname": "corrective_operation_section", + "fieldtype": "Section Break", + "label": "Corrective Operation" + }, + { + "default": "0", + "fieldname": "is_corrective_job_card", + "fieldtype": "Check", + "label": "Is Corrective Job Card", + "read_only": 1 + }, + { + "fieldname": "column_break_33", + "fieldtype": "Column Break" + }, + { + "fieldname": "for_job_card", + "fieldtype": "Link", + "label": "For Job Card", + "options": "Job Card", + "read_only": 1 + }, + { + "fetch_from": "for_job_card.operation", + "fetch_if_empty": 1, + "fieldname": "for_operation", + "fieldtype": "Link", + "label": "For Operation", + "options": "Operation" + }, + { + "fieldname": "employee", + "fieldtype": "Table MultiSelect", + "label": "Employee", + "options": "Job Card Time Log" + }, + { + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial No" + }, + { + "fieldname": "batch_no", + "fieldtype": "Link", + "label": "Batch No", + "options": "Batch" } ], "is_submittable": 1, "links": [], - "modified": "2020-11-19 18:26:50.531664", + "modified": "2021-03-16 15:59:32.766484", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card", diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py index cdc4518894..420bb00803 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.py +++ b/erpnext/manufacturing/doctype/job_card/job_card.py @@ -5,11 +5,12 @@ from __future__ import unicode_literals import frappe import datetime +import json from frappe import _, bold from frappe.model.mapper import get_mapped_doc from frappe.model.document import Document from frappe.utils import (flt, cint, time_diff_in_hours, get_datetime, getdate, - get_time, add_to_date, time_diff, add_days, get_datetime_str, get_link_to_form) + get_time, add_to_date, time_diff, add_days, get_datetime_str, get_link_to_form, time_diff_in_seconds) from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings import get_mins_between_operations @@ -25,10 +26,21 @@ class JobCard(Document): self.set_status() self.validate_operation_id() self.validate_sequence_id() + self.get_sub_operations() + self.update_sub_operation_status() + + def get_sub_operations(self): + if self.operation: + self.sub_operations = [] + for row in frappe.get_all("Sub Operation", + filters = {"parent": self.operation}, fields=["operation", "idx"]): + row.status = "Pending" + row.sub_operation = row.operation + self.append("sub_operations", row) def validate_time_logs(self): - self.total_completed_qty = 0.0 self.total_time_in_mins = 0.0 + self.total_completed_qty = 0.0 if self.get('time_logs'): for d in self.get('time_logs'): @@ -44,11 +56,14 @@ class JobCard(Document): d.time_in_mins = time_diff_in_hours(d.to_time, d.from_time) * 60 self.total_time_in_mins += d.time_in_mins - if d.completed_qty: + if d.completed_qty and not self.sub_operations: self.total_completed_qty += d.completed_qty self.total_completed_qty = flt(self.total_completed_qty, self.precision("total_completed_qty")) + for row in self.sub_operations: + self.total_completed_qty += row.completed_qty + def get_overlap_for(self, args, check_next_available_slot=False): production_capacity = 1 @@ -57,7 +72,7 @@ class JobCard(Document): self.workstation, 'production_capacity') or 1 validate_overlap_for = " and jc.workstation = %(workstation)s " - if self.employee: + if args.get("employee"): # override capacity for employee production_capacity = 1 validate_overlap_for = " and jc.employee = %(employee)s " @@ -80,7 +95,7 @@ class JobCard(Document): "to_time": args.to_time, "name": args.name or "No Name", "parent": args.parent or "No Name", - "employee": self.employee, + "employee": args.get("employee"), "workstation": self.workstation }, as_dict=True) @@ -158,6 +173,108 @@ class JobCard(Document): row.planned_start_time = datetime.datetime.combine(start_date, get_time(workstation_doc.working_hours[0].start_time)) + def add_time_log(self, args): + last_row = [] + employees = args.employees + if isinstance(employees, str): + employees = json.loads(employees) + + if self.time_logs and len(self.time_logs) > 0: + last_row = self.time_logs[-1] + + self.reset_timer_value(args) + if last_row and args.get("complete_time"): + for row in self.time_logs: + if not row.to_time: + row.update({ + "to_time": get_datetime(args.get("complete_time")), + "operation": args.get("sub_operation"), + "completed_qty": args.get("completed_qty") or 0.0 + }) + elif args.get("start_time"): + new_args = { + "from_time": get_datetime(args.get("start_time")), + "operation": args.get("sub_operation"), + "completed_qty": 0.0 + } + + if employees: + for name in employees: + new_args.employee = name.get('employee') + self.add_start_time_log(new_args) + else: + self.add_start_time_log(new_args) + + if not self.employee and employees: + self.set_employees(employees) + + if self.status == "On Hold": + self.current_time = time_diff_in_seconds(last_row.to_time, last_row.from_time) + + self.save() + + def add_start_time_log(self, args): + self.append("time_logs", args) + + def set_employees(self, employees): + for name in employees: + self.append('employee', { + 'employee': name.get('employee'), + 'completed_qty': 0.0 + }) + + def reset_timer_value(self, args): + self.started_time = None + + if args.get("status") in ["Work In Progress", "Complete"]: + self.current_time = 0.0 + + if args.get("status") == "Work In Progress": + self.started_time = get_datetime(args.get("start_time")) + + if args.get("status") == "Resume Job": + args["status"] = "Work In Progress" + + if args.get("status"): + self.status = args.get("status") + + def update_sub_operation_status(self): + if not (self.sub_operations and self.time_logs): + return + + operation_wise_completed_time = {} + for time_log in self.time_logs: + if time_log.operation not in operation_wise_completed_time: + operation_wise_completed_time.setdefault(time_log.operation, + frappe._dict({"status": "Pending", "completed_qty":0.0, "completed_time": 0.0, "employee": []})) + + op_row = operation_wise_completed_time[time_log.operation] + op_row.status = "Work In Progress" if not time_log.time_in_mins else "Complete" + if self.status == 'On Hold': + op_row.status = 'Pause' + + op_row.employee.append(time_log.employee) + if time_log.time_in_mins: + op_row.completed_time += time_log.time_in_mins + op_row.completed_qty += time_log.completed_qty + + for row in self.sub_operations: + operation_deatils = operation_wise_completed_time.get(row.sub_operation) + if operation_deatils: + if row.status != 'Complete': + row.status = operation_deatils.status + + row.completed_time = operation_deatils.completed_time + if operation_deatils.employee: + row.completed_time = row.completed_time / len(set(operation_deatils.employee)) + + if operation_deatils.completed_qty: + row.completed_qty = operation_deatils.completed_qty / len(set(operation_deatils.employee)) + else: + row.status = 'Pending' + row.completed_time = 0.0 + row.completed_qty = 0.0 + def update_time_logs(self, row): self.append("time_logs", { "from_time": row.planned_start_time, @@ -182,15 +299,18 @@ class JobCard(Document): if self.get('operation') == d.operation: self.append('items', { - 'item_code': d.item_code, - 'source_warehouse': d.source_warehouse, - 'uom': frappe.db.get_value("Item", d.item_code, 'stock_uom'), - 'item_name': d.item_name, - 'description': d.description, - 'required_qty': (d.required_qty * flt(self.for_quantity)) / doc.qty + "item_code": d.item_code, + "source_warehouse": d.source_warehouse, + "uom": frappe.db.get_value("Item", d.item_code, 'stock_uom'), + "item_name": d.item_name, + "description": d.description, + "required_qty": (d.required_qty * flt(self.for_quantity)) / doc.qty, + "rate": d.rate, + "amount": d.amount }) def on_submit(self): + self.validate_transfer_qty() self.validate_job_card() self.update_work_order() self.set_transferred_qty() @@ -199,7 +319,16 @@ class JobCard(Document): self.update_work_order() self.set_transferred_qty() + def validate_transfer_qty(self): + if self.items and self.transferred_qty < self.for_quantity: + frappe.throw(_('Materials needs to be transferred to the work in progress warehouse for the job card {0}') + .format(self.name)) + def validate_job_card(self): + if self.work_order and frappe.get_cached_value('Work Order', self.work_order, 'status') == 'Stopped': + frappe.throw(_("Transaction not allowed against stopped Work Order {0}") + .format(get_link_to_form('Work Order', self.work_order))) + if not self.time_logs: frappe.throw(_("Time logs are required for {0} {1}") .format(bold("Job Card"), get_link_to_form("Job Card", self.name))) @@ -215,6 +344,10 @@ class JobCard(Document): if not self.work_order: return + if self.is_corrective_job_card and not cint(frappe.db.get_single_value('Manufacturing Settings', + 'add_corrective_operation_cost_in_finished_good_valuation')): + return + for_quantity, time_in_mins = 0, 0 from_time_list, to_time_list = [], [] @@ -225,10 +358,24 @@ class JobCard(Document): time_in_mins = flt(data[0].time_in_mins) wo = frappe.get_doc('Work Order', self.work_order) - if self.operation_id: + + if self.is_corrective_job_card: + self.update_corrective_in_work_order(wo) + + elif self.operation_id: self.validate_produced_quantity(for_quantity, wo) self.update_work_order_data(for_quantity, time_in_mins, wo) + def update_corrective_in_work_order(self, wo): + wo.corrective_operation_cost = 0.0 + for row in frappe.get_all('Job Card', fields = ['total_time_in_mins', 'hour_rate'], + filters = {'is_corrective_job_card': 1, 'docstatus': 1, 'work_order': self.work_order}): + wo.corrective_operation_cost += flt(row.total_time_in_mins) * flt(row.hour_rate) + + wo.calculate_operating_cost() + wo.flags.ignore_validate_update_after_submit = True + wo.save() + def validate_produced_quantity(self, for_quantity, wo): if self.docstatus < 2: return @@ -248,8 +395,8 @@ class JobCard(Document): min(from_time) as start_time, max(to_time) as end_time FROM `tabJob Card` jc, `tabJob Card Time Log` jctl WHERE - jctl.parent = jc.name and jc.work_order = %s - and jc.operation_id = %s and jc.docstatus = 1 + jctl.parent = jc.name and jc.work_order = %s and jc.operation_id = %s + and jc.docstatus = 1 and IFNULL(jc.is_corrective_job_card, 0) = 0 """, (self.work_order, self.operation_id), as_dict=1) for data in wo.operations: @@ -271,7 +418,8 @@ class JobCard(Document): def get_current_operation_data(self): return frappe.get_all('Job Card', fields = ["sum(total_time_in_mins) as time_in_mins", "sum(total_completed_qty) as completed_qty"], - filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id}) + filters = {"docstatus": 1, "work_order": self.work_order, "operation_id": self.operation_id, + "is_corrective_job_card": 0}) def set_transferred_qty_in_job_card(self, ste_doc): for row in ste_doc.items: @@ -354,7 +502,11 @@ class JobCard(Document): .format(bold(self.operation), work_order), OperationMismatchError) def validate_sequence_id(self): - if not (self.work_order and self.sequence_id): return + if self.is_corrective_job_card: + return + + if not (self.work_order and self.sequence_id): + return current_operation_qty = 0.0 data = self.get_current_operation_data() @@ -376,6 +528,17 @@ class JobCard(Document): frappe.throw(_("{0}, complete the operation {1} before the operation {2}.") .format(message, bold(row.operation), bold(self.operation)), OperationSequenceError) + +@frappe.whitelist() +def make_time_log(args): + if isinstance(args, str): + args = json.loads(args) + + args = frappe._dict(args) + doc = frappe.get_doc("Job Card", args.job_card_id) + doc.validate_sequence_id() + doc.add_time_log(args) + @frappe.whitelist() def get_operation_details(work_order, operation): if work_order and operation: @@ -511,3 +674,28 @@ def get_job_details(start, end, filters=None): events.append(job_card_data) return events + +@frappe.whitelist() +def make_corrective_job_card(source_name, operation=None, for_operation=None, target_doc=None): + def set_missing_values(source, target): + target.is_corrective_job_card = 1 + target.operation = operation + target.for_operation = for_operation + + target.set('time_logs', []) + target.set('employee', []) + target.set('items', []) + target.get_sub_operations() + target.get_required_items() + target.validate_time_logs() + + doclist = get_mapped_doc("Job Card", source_name, { + "Job Card": { + "doctype": "Job Card", + "field_map": { + "name": "for_job_card", + }, + } + }, target_doc, set_missing_values) + + return doclist \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json index 100ef4ca3a..d91530dd3b 100644 --- a/erpnext/manufacturing/doctype/job_card_item/job_card_item.json +++ b/erpnext/manufacturing/doctype/job_card_item/job_card_item.json @@ -25,8 +25,7 @@ "fieldtype": "Link", "in_list_view": 1, "label": "Item Code", - "options": "Item", - "read_only": 1 + "options": "Item" }, { "fieldname": "source_warehouse", @@ -67,8 +66,7 @@ "fieldname": "required_qty", "fieldtype": "Float", "in_list_view": 1, - "label": "Required Qty", - "read_only": 1 + "label": "Required Qty" }, { "fieldname": "column_break_9", @@ -107,7 +105,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-02-11 13:50:13.804108", + "modified": "2021-04-22 18:50:00.003444", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Item", diff --git a/erpnext/manufacturing/doctype/job_card_operation/__init__.py b/erpnext/manufacturing/doctype/job_card_operation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json new file mode 100644 index 0000000000..9a8692b84d --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.json @@ -0,0 +1,59 @@ +{ + "actions": [], + "creation": "2020-12-07 16:58:38.449041", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "sub_operation", + "completed_time", + "status", + "completed_qty" + ], + "fields": [ + { + "default": "Pending", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Complete\nPause\nPending\nWork In Progress", + "read_only": 1 + }, + { + "description": "In mins", + "fieldname": "completed_time", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Completed Time", + "read_only": 1 + }, + { + "fieldname": "sub_operation", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Operation", + "options": "Operation", + "read_only": 1 + }, + { + "fieldname": "completed_qty", + "fieldtype": "Float", + "label": "Completed Qty", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-03-16 18:24:35.399593", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Job Card Operation", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py new file mode 100644 index 0000000000..85d72982ed --- /dev/null +++ b/erpnext/manufacturing/doctype/job_card_operation/job_card_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class JobCardOperation(Document): + pass diff --git a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json index 9dd54dd618..a7102d7d23 100644 --- a/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json +++ b/erpnext/manufacturing/doctype/job_card_time_log/job_card_time_log.json @@ -1,14 +1,17 @@ { + "actions": [], "creation": "2019-03-08 23:56:43.187569", "doctype": "DocType", "editable_grid": 1, "engine": "InnoDB", "field_order": [ + "employee", "from_time", "to_time", "column_break_2", "time_in_mins", - "completed_qty" + "completed_qty", + "operation" ], "fields": [ { @@ -41,10 +44,27 @@ "in_list_view": 1, "label": "Completed Qty", "reqd": 1 + }, + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Employee", + "options": "Employee" + }, + { + "fieldname": "operation", + "fieldtype": "Link", + "label": "Operation", + "no_copy": 1, + "options": "Operation", + "read_only": 1 } ], + "index_web_pages_for_search": 1, "istable": 1, - "modified": "2019-12-03 12:56:02.285448", + "links": [], + "modified": "2020-12-23 14:30:00.970916", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Time Log", diff --git a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json index b7634da87c..024f784725 100644 --- a/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json +++ b/erpnext/manufacturing/doctype/manufacturing_settings/manufacturing_settings.json @@ -26,7 +26,10 @@ "column_break_16", "overproduction_percentage_for_work_order", "other_settings_section", - "update_bom_costs_automatically" + "update_bom_costs_automatically", + "add_corrective_operation_cost_in_finished_good_valuation", + "column_break_23", + "make_serial_no_batch_from_work_order" ], "fields": [ { @@ -155,13 +158,30 @@ { "fieldname": "column_break_5", "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "System will automatically create the serial numbers / batch for the Finished Good on submission of work order", + "fieldname": "make_serial_no_batch_from_work_order", + "fieldtype": "Check", + "label": "Make Serial No / Batch from Work Order" + }, + { + "default": "0", + "fieldname": "add_corrective_operation_cost_in_finished_good_valuation", + "fieldtype": "Check", + "label": "Add Corrective Operation Cost in Finished Good Valuation" } ], "icon": "icon-wrench", "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2020-10-13 10:55:43.996581", + "modified": "2021-03-16 15:54:38.967341", "modified_by": "Administrator", "module": "Manufacturing", "name": "Manufacturing Settings", @@ -178,4 +198,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.js b/erpnext/manufacturing/doctype/operation/operation.js index 5c2aba6f09..102b6780e5 100644 --- a/erpnext/manufacturing/doctype/operation/operation.js +++ b/erpnext/manufacturing/doctype/operation/operation.js @@ -2,7 +2,13 @@ // For license information, please see license.txt frappe.ui.form.on('Operation', { - refresh: function(frm) { - + setup: function(frm) { + frm.set_query('operation', 'sub_operations', function() { + return { + filters: { + 'name': ['not in', [frm.doc.name]] + } + }; + }); } -}); +}); \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.json b/erpnext/manufacturing/doctype/operation/operation.json index c231fba2fa..10a97eda76 100644 --- a/erpnext/manufacturing/doctype/operation/operation.json +++ b/erpnext/manufacturing/doctype/operation/operation.json @@ -1,167 +1,132 @@ { - "allow_copy": 0, - "allow_import": 1, - "allow_rename": 1, - "autoname": "Prompt", - "beta": 0, - "creation": "2014-11-07 16:20:30.683186", - "custom": 0, - "docstatus": 0, - "doctype": "DocType", - "document_type": "Setup", - "editable_grid": 0, - "engine": "InnoDB", + "actions": [], + "allow_import": 1, + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2014-11-07 16:20:30.683186", + "doctype": "DocType", + "document_type": "Setup", + "engine": "InnoDB", + "field_order": [ + "workstation", + "data_2", + "is_corrective_operation", + "job_card_section", + "create_job_card_based_on_batch_size", + "column_break_6", + "batch_size", + "sub_operations_section", + "sub_operations", + "total_operation_time", + "section_break_4", + "description" + ], "fields": [ { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "workstation", - "fieldtype": "Link", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Default Workstation", - "length": 0, - "no_copy": 0, - "options": "Workstation", - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "fieldname": "workstation", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Default Workstation", + "options": "Workstation" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "section_break_4", - "fieldtype": "Section Break", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 - }, + "collapsible": 1, + "fieldname": "section_break_4", + "fieldtype": "Section Break", + "label": "Operation Description" + }, { - "allow_on_submit": 0, - "bold": 0, - "collapsible": 0, - "columns": 0, - "fieldname": "description", - "fieldtype": "Text", - "hidden": 0, - "ignore_user_permissions": 0, - "ignore_xss_filter": 0, - "in_filter": 0, - "in_list_view": 0, - "in_standard_filter": 0, - "label": "Description", - "length": 0, - "no_copy": 0, - "permlevel": 0, - "precision": "", - "print_hide": 0, - "print_hide_if_no_value": 0, - "read_only": 0, - "remember_last_selected_value": 0, - "report_hide": 0, - "reqd": 0, - "search_index": 0, - "set_only_once": 0, - "unique": 0 + "fieldname": "description", + "fieldtype": "Text", + "label": "Description" + }, + { + "collapsible": 1, + "fieldname": "sub_operations_section", + "fieldtype": "Section Break", + "label": "Sub Operations" + }, + { + "fieldname": "sub_operations", + "fieldtype": "Table", + "options": "Sub Operation" + }, + { + "description": "Time in mins.", + "fieldname": "total_operation_time", + "fieldtype": "Float", + "label": "Total Operation Time", + "read_only": 1 + }, + { + "fieldname": "data_2", + "fieldtype": "Column Break" + }, + { + "default": "1", + "depends_on": "create_job_card_based_on_batch_size", + "fieldname": "batch_size", + "fieldtype": "Int", + "label": "Batch Size", + "mandatory_depends_on": "create_job_card_based_on_batch_size" + }, + { + "default": "0", + "fieldname": "create_job_card_based_on_batch_size", + "fieldtype": "Check", + "label": "Create Job Card based on Batch Size" + }, + { + "collapsible": 1, + "fieldname": "job_card_section", + "fieldtype": "Section Break", + "label": "Job Card" + }, + { + "fieldname": "column_break_6", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "is_corrective_operation", + "fieldtype": "Check", + "label": "Is Corrective Operation" } - ], - "hide_heading": 0, - "hide_toolbar": 0, - "icon": "fa fa-wrench", - "idx": 0, - "image_view": 0, - "in_create": 0, - - "is_submittable": 0, - "issingle": 0, - "istable": 0, - "max_attachments": 0, - "modified": "2016-11-07 05:28:27.462413", - "modified_by": "Administrator", - "module": "Manufacturing", - "name": "Operation", - "name_case": "", - "owner": "Administrator", + ], + "icon": "fa fa-wrench", + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-01-12 15:09:23.593338", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Operation", + "owner": "Administrator", "permissions": [ { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 0, - "export": 1, - "if_owner": 0, - "import": 1, - "is_custom": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 0, - "role": "Manufacturing User", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "export": 1, + "import": 1, + "read": 1, + "role": "Manufacturing User", + "share": 1, "write": 1 - }, + }, { - "amend": 0, - "apply_user_permissions": 0, - "cancel": 0, - "create": 1, - "delete": 1, - "email": 0, - "export": 1, - "if_owner": 0, - "import": 1, - "is_custom": 0, - "permlevel": 0, - "print": 0, - "read": 1, - "report": 1, - "role": "Manufacturing Manager", - "set_user_permissions": 0, - "share": 1, - "submit": 0, + "create": 1, + "delete": 1, + "export": 1, + "import": 1, + "read": 1, + "report": 1, + "role": "Manufacturing Manager", + "share": 1, "write": 1 } - ], - "quick_entry": 1, - "read_only": 0, - "read_only_onload": 0, - "sort_field": "modified", - "sort_order": "DESC", - "track_seen": 0 + ], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/operation/operation.py b/erpnext/manufacturing/doctype/operation/operation.py index 69e83292ff..374f32019b 100644 --- a/erpnext/manufacturing/doctype/operation/operation.py +++ b/erpnext/manufacturing/doctype/operation/operation.py @@ -2,9 +2,34 @@ # For license information, please see license.txt from __future__ import unicode_literals + +import frappe +from frappe import _ from frappe.model.document import Document class Operation(Document): def validate(self): if not self.description: self.description = self.name + + self.duplicate_sub_operation() + self.set_total_time() + + def duplicate_sub_operation(self): + operation_list = [] + for row in self.sub_operations: + if row.operation in operation_list: + frappe.throw(_("The operation {0} can not add multiple times") + .format(frappe.bold(row.operation))) + + if self.name == row.operation: + frappe.throw(_("The operation {0} can not be the sub operation") + .format(frappe.bold(row.operation))) + + operation_list.append(row.operation) + + def set_total_time(self): + self.total_operation_time = 0.0 + + for row in self.sub_operations: + self.total_operation_time += row.time_in_mins diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.js b/erpnext/manufacturing/doctype/production_plan/production_plan.js index 64d584118f..d198a6962a 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.js +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.js @@ -4,7 +4,7 @@ frappe.ui.form.on('Production Plan', { setup: function(frm) { frm.custom_make_buttons = { - 'Work Order': 'Work Order', + 'Work Order': 'Work Order / Subcontract PO', 'Material Request': 'Material Request', }; @@ -68,17 +68,13 @@ frappe.ui.form.on('Production Plan', { frm.trigger("show_progress"); if (frm.doc.status !== "Completed") { - if (frm.doc.po_items && frm.doc.status !== "Closed") { - frm.add_custom_button(__("Work Order"), ()=> { - frm.trigger("make_work_order"); - }, __('Create')); - } + frm.add_custom_button(__("Work Order Tree"), ()=> { + frappe.set_route('Tree', 'Work Order', {production_plan: frm.doc.name}); + }, __('View')); - if (frm.doc.mr_items && !in_list(['Material Requested', 'Closed'], frm.doc.status)) { - frm.add_custom_button(__("Material Request"), ()=> { - frm.trigger("make_material_request"); - }, __('Create')); - } + frm.add_custom_button(__("Production Plan Summary"), ()=> { + frappe.set_route('query-report', 'Production Plan Summary', {production_plan: frm.doc.name}); + }, __('View')); if (frm.doc.status === "Closed") { frm.add_custom_button(__("Re-open"), function() { @@ -89,6 +85,18 @@ frappe.ui.form.on('Production Plan', { frm.events.close_open_production_plan(frm, true); }, __("Status")); } + + if (frm.doc.po_items && frm.doc.status !== "Closed") { + frm.add_custom_button(__("Work Order / Subcontract PO"), ()=> { + frm.trigger("make_work_order"); + }, __('Create')); + } + + if (frm.doc.mr_items && !in_list(['Material Requested', 'Closed'], frm.doc.status)) { + frm.add_custom_button(__("Material Request"), ()=> { + frm.trigger("make_material_request"); + }, __('Create')); + } } } @@ -233,6 +241,17 @@ frappe.ui.form.on('Production Plan', { }); }, + get_sub_assembly_items: function(frm) { + frappe.call({ + method: "get_sub_assembly_items", + freeze: true, + doc: frm.doc, + callback: function() { + refresh_field("sub_assembly_items"); + } + }); + }, + get_items_for_mr: function(frm) { if (!frm.doc.for_warehouse) { frappe.throw(__("Select warehouse for material requests")); @@ -306,8 +325,25 @@ frappe.ui.form.on('Production Plan', { }, download_materials_required: function(frm) { - let get_template_url = 'erpnext.manufacturing.doctype.production_plan.production_plan.download_raw_materials'; - open_url_post(frappe.request.url, { cmd: get_template_url, doc: frm.doc }); + const fields = [{ + fieldname: 'warehouses', + fieldtype: 'Table MultiSelect', + label: __('Warehouses'), + default: frm.doc.from_warehouse, + options: "Production Plan Material Request Warehouse", + get_query: function () { + return { + filters: { + company: frm.doc.company + } + }; + }, + }]; + + frappe.prompt(fields, (row) => { + let get_template_url = 'erpnext.manufacturing.doctype.production_plan.production_plan.download_raw_materials'; + open_url_post(frappe.request.url, { cmd: get_template_url, doc: frm.doc, warehouses: row.warehouses }); + }, __('Select Warehouses to get Stock for Materials Planning'), __('Get Stock')); }, show_progress: function(frm) { diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.json b/erpnext/manufacturing/doctype/production_plan/production_plan.json index 1c0dde227c..84378956c6 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.json +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.json @@ -32,6 +32,9 @@ "po_items", "section_break_25", "prod_plan_references", + "section_break_24", + "get_sub_assembly_items", + "sub_assembly_items", "material_request_planning", "include_non_stock_items", "include_subcontracted_items", @@ -187,7 +190,7 @@ "depends_on": "get_items_from", "fieldname": "get_items", "fieldtype": "Button", - "label": "Get Items For Work Order" + "label": "Get Finished Goods for Manufacture" }, { "fieldname": "po_items", @@ -199,7 +202,7 @@ { "fieldname": "material_request_planning", "fieldtype": "Section Break", - "label": "Material Request Planning" + "label": "Material Requirement Planning" }, { "default": "1", @@ -237,12 +240,13 @@ }, { "fieldname": "section_break_27", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "hide_border": 1 }, { "fieldname": "mr_items", "fieldtype": "Table", - "label": "Material Request Plan Item", + "label": "Raw Materials", "no_copy": 1, "options": "Material Request Plan Item" }, @@ -337,13 +341,30 @@ "hidden": 1, "label": "Production Plan Item Reference", "options": "Production Plan Item Reference" + }, + { + "fieldname": "section_break_24", + "fieldtype": "Section Break", + "hide_border": 1 + }, + { + "fieldname": "sub_assembly_items", + "fieldtype": "Table", + "label": "Sub Assembly Items", + "no_copy": 1, + "options": "Production Plan Sub Assembly Item" + }, + { + "fieldname": "get_sub_assembly_items", + "fieldtype": "Button", + "label": "Get Sub Assembly Items" } ], "icon": "fa fa-calendar", "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-05-24 16:59:03.643211", + "modified": "2021-06-28 20:00:33.905114", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan", diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py index 46e047654b..38a0ee77ad 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py @@ -5,10 +5,11 @@ from __future__ import unicode_literals import frappe, json, copy from frappe import msgprint, _ -from six import string_types, iteritems +from six import iteritems from frappe.model.document import Document -from frappe.utils import cstr, flt, cint, nowdate, add_days, comma_and, now_datetime, ceil +from frappe.utils import (flt, cint, nowdate, add_days, comma_and, now_datetime, + ceil, get_link_to_form, getdate) from frappe.utils.csvutils import build_csv_response from erpnext.manufacturing.doctype.bom.bom import validate_bom_no, get_children from erpnext.manufacturing.doctype.work_order.work_order import get_item_details @@ -98,7 +99,7 @@ class ProductionPlan(Document): def get_items(self): self.set('po_items', []) if self.get_items_from == "Sales Order": - self.get_so_items() + self.get_so_items() elif self.get_items_from == "Material Request": self.get_mr_items() @@ -170,11 +171,11 @@ class ProductionPlan(Document): refs = {} for data in items: item_details = get_item_details(data.item_code) - if self.combine_items: + if self.combine_items: if item_details.bom_no in refs: refs[item_details.bom_no]['so_details'].append({ 'sales_order': data.parent, - 'sales_order_item': data.name, + 'sales_order_item': data.name, 'qty': data.pending_qty }) refs[item_details.bom_no]['qty'] += data.pending_qty @@ -188,10 +189,10 @@ class ProductionPlan(Document): } refs[item_details.bom_no]['so_details'].append({ 'sales_order': data.parent, - 'sales_order_item': data.name, + 'sales_order_item': data.name, 'qty': data.pending_qty }) - + pi = self.append('po_items', { 'include_exploded_items': 1, 'warehouse': data.warehouse, @@ -209,12 +210,12 @@ class ProductionPlan(Document): pi.sales_order = data.parent pi.sales_order_item = data.name pi.description = data.description - + elif self.get_items_from == "Material Request": pi.material_request = data.parent pi.material_request_item = data.name pi.description = data.description - + if refs: for po_item in self.po_items: po_item.planned_qty = refs[po_item.bom_no]['qty'] @@ -349,49 +350,88 @@ class ProductionPlan(Document): @frappe.whitelist() def make_work_order(self): - wo_list = [] + wo_list, po_list = [], [] + subcontracted_po = {} + self.validate_data() + self.make_work_order_for_finished_goods(wo_list) + self.make_work_order_for_subassembly_items(wo_list, subcontracted_po) + self.make_subcontracted_purchase_order(subcontracted_po, po_list) + self.show_list_created_message('Work Order', wo_list) + self.show_list_created_message('Purchase Order', po_list) + + def make_work_order_for_finished_goods(self, wo_list): items_data = self.get_production_items() for key, item in items_data.items(): + if self.sub_assembly_items: + item['use_multi_level_bom'] = 0 + work_order = self.create_work_order(item) if work_order: wo_list.append(work_order) - if item.get("make_work_order_for_sub_assembly_items"): - work_orders = self.make_work_order_for_sub_assembly_items(item) - wo_list.extend(work_orders) + def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po): + for row in self.sub_assembly_items: + if row.type_of_manufacturing == 'Subcontract': + subcontracted_po.setdefault(row.supplier, []).append(row) + continue + + args = {} + self.prepare_args_for_sub_assembly_items(row, args) + work_order = self.create_work_order(args) + if work_order: + wo_list.append(work_order) + + def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders): + if not subcontracted_po: + return + + for supplier, po_list in subcontracted_po.items(): + po = frappe.new_doc('Purchase Order') + po.supplier = supplier + po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate() + po.is_subcontracted_item = 'Yes' + for row in po_list: + args = { + 'item_code': row.production_item, + 'warehouse': row.fg_warehouse, + 'production_plan_sub_assembly_item': row.name, + 'bom': row.bom_no, + 'production_plan': self.name + } + + for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name', + 'description', 'production_plan_item']: + args[field] = row.get(field) + + po.append('items', args) + + po.set_missing_values() + po.flags.ignore_mandatory = True + po.flags.ignore_validate = True + po.insert() + purchase_orders.append(po.name) + + def show_list_created_message(self, doctype, doc_list=None): + if not doc_list: + return frappe.flags.mute_messages = False + if doc_list: + doc_list = [get_link_to_form(doctype, p) for p in doc_list] + msgprint(_("{0} created").format(comma_and(doc_list))) - if wo_list: - wo_list = ["""%s""" % \ - (p, p) for p in wo_list] - msgprint(_("{0} created").format(comma_and(wo_list))) - else : - msgprint(_("No Work Orders created")) + def prepare_args_for_sub_assembly_items(self, row, args): + for field in ["production_item", "item_name", "qty", "fg_warehouse", + "description", "bom_no", "stock_uom", "bom_level", "production_plan_item"]: + args[field] = row.get(field) - def make_work_order_for_sub_assembly_items(self, item): - work_orders = [] - bom_data = {} - - get_sub_assembly_items(item.get("bom_no"), bom_data, item.get("qty")) - - for key, data in bom_data.items(): - data.update({ - 'qty': data.get("stock_qty"), - 'production_plan': self.name, - 'use_multi_level_bom': item.get("use_multi_level_bom"), - 'company': self.company, - 'fg_warehouse': item.get("fg_warehouse"), - 'update_consumed_material_cost_in_project': 0 - }) - - work_order = self.create_work_order(data) - if work_order: - work_orders.append(work_order) - - return work_orders + args.update({ + "use_multi_level_bom": 0, + "production_plan": self.name, + "production_plan_sub_assembly_item": row.name + }) def create_work_order(self, item): from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError, get_default_warehouse @@ -476,19 +516,43 @@ class ProductionPlan(Document): else : msgprint(_("No material request created")) + @frappe.whitelist() + def get_sub_assembly_items(self, manufacturing_type=None): + self.sub_assembly_items = [] + for row in self.po_items: + bom_data = [] + get_sub_assembly_items(row.bom_no, bom_data, row.planned_qty) + self.set_sub_assembly_items_based_on_level(row, bom_data, manufacturing_type) + + self.save() + + def set_sub_assembly_items_based_on_level(self, row, bom_data, manufacturing_type=None): + bom_data = sorted(bom_data, key = lambda i: i.bom_level) + + for data in bom_data: + data.qty = data.stock_qty + data.production_plan_item = row.name + data.fg_warehouse = row.warehouse + data.schedule_date = row.planned_start_date + data.type_of_manufacturing = manufacturing_type or ("Subcontract" if data.is_sub_contracted_item + else "In House") + + self.append("sub_assembly_items", data) + @frappe.whitelist() -def download_raw_materials(doc): - if isinstance(doc, string_types): +def download_raw_materials(doc, warehouses=None): + if isinstance(doc, str): doc = frappe._dict(json.loads(doc)) item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM', - 'Projected Qty', 'Actual Qty', 'Ordered Qty', 'Reserved Qty for Production', - 'Safety Stock', 'Required Qty']] + 'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty', + 'Reserved Qty for Production', 'Safety Stock', 'Required Qty']] - for d in get_items_for_material_requests(doc): + doc.warehouse = None + for d in get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True): item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'), d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'), - d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) + d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')]) if not doc.get('for_warehouse'): row = {'item_code': d.get('item_code')} @@ -507,7 +571,7 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p ifnull(sum(bei.stock_qty/ifnull(bom.quantity, 1)), 0)*%s as qty, item.item_name, bei.description, bei.stock_uom, item.min_order_qty, bei.source_warehouse, item.default_material_request_type, item.min_order_qty, item_default.default_warehouse, - item.purchase_uom, item_uom.conversion_factor + item.purchase_uom, item_uom.conversion_factor, item.safety_stock from `tabBOM Explosion Item` bei JOIN `tabBOM` bom ON bom.name = bei.parent @@ -659,7 +723,7 @@ def get_sales_orders(self): @frappe.whitelist() def get_bin_details(row, company, for_warehouse=None, all_warehouse=False): - if isinstance(row, string_types): + if isinstance(row, str): row = frappe._dict(json.loads(row)) company = frappe.db.escape(company) @@ -677,32 +741,39 @@ def get_bin_details(row, company, for_warehouse=None, all_warehouse=False): return frappe.db.sql(""" select ifnull(sum(projected_qty),0) as projected_qty, ifnull(sum(actual_qty),0) as actual_qty, ifnull(sum(ordered_qty),0) as ordered_qty, - ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse from `tabBin` - where item_code = %(item_code)s {conditions} + ifnull(sum(reserved_qty_for_production),0) as reserved_qty_for_production, warehouse, + ifnull(sum(planned_qty),0) as planned_qty + from `tabBin` where item_code = %(item_code)s {conditions} group by item_code, warehouse """.format(conditions=conditions), { "item_code": row['item_code'] }, as_dict=1) +def get_warehouse_list(warehouses, warehouse_list=None): + if not warehouse_list: + warehouse_list = [] + + if isinstance(warehouses, str): + warehouses = json.loads(warehouses) + + for row in warehouses: + child_warehouses = frappe.db.get_descendants('Warehouse', row.get("warehouse")) + if child_warehouses: + warehouse_list.extend(child_warehouses) + else: + warehouse_list.append(row.get("warehouse")) + @frappe.whitelist() -def get_items_for_material_requests(doc, warehouses=None): - if isinstance(doc, string_types): +def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_data=None): + if isinstance(doc, str): doc = frappe._dict(json.loads(doc)) warehouse_list = [] if warehouses: - if isinstance(warehouses, string_types): - warehouses = json.loads(warehouses) - - for row in warehouses: - child_warehouses = frappe.db.get_descendants('Warehouse', row.get("warehouse")) - if child_warehouses: - warehouse_list.extend(child_warehouses) - else: - warehouse_list.append(row.get("warehouse")) + get_warehouse_list(warehouses, warehouse_list) if warehouse_list: warehouses = list(set(warehouse_list)) - if doc.get("for_warehouse") and doc.get("for_warehouse") in warehouses: + if doc.get("for_warehouse") and not get_parent_warehouse_data and doc.get("for_warehouse") in warehouses: warehouses.remove(doc.get("for_warehouse")) warehouse_list = None @@ -721,6 +792,9 @@ def get_items_for_material_requests(doc, warehouses=None): so_item_details = frappe._dict() for data in po_items: + if not data.get("include_exploded_items") and doc.get("sub_assembly_items"): + data["include_exploded_items"] = 1 + planned_qty = data.get('required_qty') or data.get('planned_qty') ignore_existing_ordered_qty = data.get('ignore_existing_ordered_qty') or ignore_existing_ordered_qty warehouse = doc.get('for_warehouse') @@ -795,7 +869,7 @@ def get_items_for_material_requests(doc, warehouses=None): if items: mr_items.append(items) - if not ignore_existing_ordered_qty and warehouses: + if (not ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses: new_mr_items = [] for item in mr_items: get_materials_from_other_locations(item, warehouses, new_mr_items, company) @@ -852,23 +926,28 @@ def get_item_data(item_code): # "description": item_details.get("description") } -def get_sub_assembly_items(bom_no, bom_data, to_produce_qty): +def get_sub_assembly_items(bom_no, bom_data, to_produce_qty, indent=0): data = get_children('BOM', parent = bom_no) for d in data: if d.expandable: - key = (d.name, d.value) - if key not in bom_data: - bom_data.setdefault(key, { - 'stock_qty': 0, - 'description': d.description, - 'production_item': d.item_code, - 'item_name': d.item_name, - 'stock_uom': d.stock_uom, - 'uom': d.stock_uom, - 'bom_no': d.value - }) + parent_item_code = frappe.get_cached_value("BOM", bom_no, "item") + bom_level = (frappe.get_cached_value("BOM", d.value, "bom_level") + if d.value else 0) - bom_item = bom_data.get(key) - bom_item["stock_qty"] += (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + stock_qty = (d.stock_qty / d.parent_bom_qty) * flt(to_produce_qty) + bom_data.append(frappe._dict({ + 'parent_item_code': parent_item_code, + 'description': d.description, + 'production_item': d.item_code, + 'item_name': d.item_name, + 'stock_uom': d.stock_uom, + 'uom': d.stock_uom, + 'bom_no': d.value, + 'is_sub_contracted_item': d.is_sub_contracted_item, + 'bom_level': bom_level, + 'indent': indent, + 'stock_qty': stock_qty + })) - get_sub_assembly_items(bom_item.get("bom_no"), bom_data, bom_item["stock_qty"]) + if d.value: + get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1) diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py index 09ec24a67a..ca597f6327 100644 --- a/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py +++ b/erpnext/manufacturing/doctype/production_plan/production_plan_dashboard.py @@ -9,5 +9,9 @@ def get_data(): 'label': _('Transactions'), 'items': ['Work Order', 'Material Request'] }, + { + 'label': _('Subcontract'), + 'items': ['Purchase Order'] + }, ] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py index 768f99eb43..cce1bb61b6 100644 --- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py +++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py @@ -169,7 +169,7 @@ class TestProductionPlan(unittest.TestCase): pln.get_items() pln.submit() - self.assertTrue(pln.po_items[0].planned_qty, 3) + self.assertTrue(pln.po_items[0].planned_qty, 3) pln.make_work_order() work_order = frappe.db.get_value('Work Order', { @@ -193,10 +193,10 @@ class TestProductionPlan(unittest.TestCase): for so_item in so_items: so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty') self.assertEqual(so_wo_qty, 0.0) - + latest_plan = frappe.get_doc('Production Plan', pln.name) latest_plan.cancel() - + def test_pp_to_mr_customer_provided(self): #Material Request from Production Plan for Customer Provided create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0) @@ -236,10 +236,10 @@ class TestProductionPlan(unittest.TestCase): pln.append("po_items", { "item_code": item_code, "bom_no": frappe.db.get_value('BOM', {'item': "Test BOM 1"}), - "planned_qty": 3, - "make_work_order_for_sub_assembly_items": 1 + "planned_qty": 3 }) + pln.get_sub_assembly_items('In House') pln.submit() pln.make_work_order() diff --git a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json index 89ab7aa0a0..f829d57475 100644 --- a/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json +++ b/erpnext/manufacturing/doctype/production_plan_item/production_plan_item.json @@ -9,18 +9,17 @@ "include_exploded_items", "item_code", "bom_no", - "planned_qty", "column_break_6", - "make_work_order_for_sub_assembly_items", + "planned_qty", "warehouse", "planned_start_date", "section_break_9", "pending_qty", "ordered_qty", - "produced_qty", "column_break_17", "description", "stock_uom", + "produced_qty", "reference_section", "sales_order", "sales_order_item", @@ -32,11 +31,10 @@ ], "fields": [ { - "columns": 2, - "default": "0", + "columns": 1, + "default": "1", "fieldname": "include_exploded_items", "fieldtype": "Check", - "in_list_view": 1, "label": "Include Exploded Items" }, { @@ -80,13 +78,6 @@ "fieldname": "column_break_6", "fieldtype": "Column Break" }, - { - "default": "0", - "description": "If enabled, system will create the work order for the exploded items against which BOM is available.", - "fieldname": "make_work_order_for_sub_assembly_items", - "fieldtype": "Check", - "label": "Make Work Order for Sub Assembly Items" - }, { "fieldname": "warehouse", "fieldtype": "Link", @@ -218,7 +209,7 @@ "idx": 1, "istable": 1, "links": [], - "modified": "2021-04-28 19:14:57.772123", + "modified": "2021-06-28 18:31:06.822168", "modified_by": "Administrator", "module": "Manufacturing", "name": "Production Plan Item", diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/__init__.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json new file mode 100644 index 0000000000..657ee35a85 --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.json @@ -0,0 +1,202 @@ +{ + "actions": [], + "creation": "2020-12-27 16:08:36.127199", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "production_item", + "item_name", + "fg_warehouse", + "parent_item_code", + "schedule_date", + "column_break_3", + "qty", + "bom_no", + "bom_level", + "type_of_manufacturing", + "supplier", + "work_order_details_section", + "work_order", + "purchase_order", + "production_plan_item", + "column_break_7", + "produced_qty", + "received_qty", + "indent", + "section_break_19", + "uom", + "stock_uom", + "column_break_22", + "description" + ], + "fields": [ + { + "fetch_from": "sub_assembly_item_code.item_name", + "fieldname": "item_name", + "fieldtype": "Data", + "label": "Item Name", + "read_only": 1 + }, + { + "fieldname": "column_break_3", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.type_of_manufacturing == \"In House\"", + "fieldname": "work_order_details_section", + "fieldtype": "Section Break", + "label": "Reference" + }, + { + "fieldname": "work_order", + "fieldtype": "Link", + "label": "Work Order", + "options": "Work Order", + "read_only": 1 + }, + { + "fieldname": "column_break_7", + "fieldtype": "Column Break" + }, + { + "columns": 1, + "fieldname": "qty", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Required Qty", + "read_only": 1 + }, + { + "fieldname": "purchase_order", + "fieldtype": "Link", + "label": "Purchase Order", + "options": "Purchase Order", + "read_only": 1 + }, + { + "fieldname": "received_qty", + "fieldtype": "Float", + "label": "Received Qty" + }, + { + "fieldname": "bom_no", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Bom No", + "options": "BOM" + }, + { + "fieldname": "production_plan_item", + "fieldtype": "Data", + "hidden": 1, + "label": "Production Plan Item", + "read_only": 1 + }, + { + "fieldname": "parent_item_code", + "fieldtype": "Link", + "label": "Finished Good", + "options": "Item", + "read_only": 1 + }, + { + "columns": 1, + "fetch_from": "bom_no.bom_level", + "fieldname": "bom_level", + "fieldtype": "Int", + "in_list_view": 1, + "label": "Level (BOM)", + "read_only": 1 + }, + { + "collapsible": 1, + "fieldname": "section_break_19", + "fieldtype": "Section Break", + "label": "Item Details" + }, + { + "fieldname": "uom", + "fieldtype": "Link", + "label": "UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "stock_uom", + "fieldtype": "Link", + "label": "Stock UOM", + "options": "UOM", + "read_only": 1 + }, + { + "fieldname": "column_break_22", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "description", + "read_only": 1 + }, + { + "fieldname": "production_item", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Sub Assembly Item Code", + "options": "Item", + "read_only": 1 + }, + { + "fieldname": "indent", + "fieldtype": "Int", + "label": "Indent" + }, + { + "fieldname": "fg_warehouse", + "fieldtype": "Link", + "label": "Target Warehouse", + "options": "Warehouse" + }, + { + "fieldname": "produced_qty", + "fieldtype": "Data", + "label": "Produced Quantity", + "read_only": 1 + }, + { + "default": "In House", + "fieldname": "type_of_manufacturing", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Manufacturing Type", + "options": "In House\nSubcontract" + }, + { + "fieldname": "supplier", + "fieldtype": "Link", + "label": "Supplier", + "mandatory_depends_on": "eval:doc.type_of_manufacturing == 'Subcontract'", + "options": "Supplier" + }, + { + "fieldname": "schedule_date", + "fieldtype": "Datetime", + "in_list_view": 1, + "label": "Schedule Date" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2021-06-28 20:10:56.296410", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Sub Assembly Item", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py new file mode 100644 index 0000000000..6850a2eb4e --- /dev/null +++ b/erpnext/manufacturing/doctype/production_plan_sub_assembly_item/production_plan_sub_assembly_item.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class ProductionPlanSubAssemblyItem(Document): + pass diff --git a/erpnext/manufacturing/doctype/routing/routing.py b/erpnext/manufacturing/doctype/routing/routing.py index 8312d7436c..ece0db717a 100644 --- a/erpnext/manufacturing/doctype/routing/routing.py +++ b/erpnext/manufacturing/doctype/routing/routing.py @@ -4,14 +4,24 @@ from __future__ import unicode_literals import frappe -from frappe.utils import cint +from frappe.utils import cint, flt from frappe import _ from frappe.model.document import Document class Routing(Document): def validate(self): + self.calculate_operating_cost() self.set_routing_id() + def on_update(self): + self.calculate_operating_cost() + + def calculate_operating_cost(self): + for operation in self.operations: + if not operation.hour_rate: + operation.hour_rate = frappe.db.get_value("Workstation", operation.workstation, 'hour_rate') + operation.operating_cost = flt(flt(operation.hour_rate) * flt(operation.time_in_mins) / 60, 2) + def set_routing_id(self): sequence_id = 0 for row in self.operations: @@ -21,4 +31,4 @@ class Routing(Document): frappe.throw(_("At row #{0}: the sequence id {1} cannot be less than previous row sequence id {2}") .format(row.idx, row.sequence_id, sequence_id)) - sequence_id = row.sequence_id \ No newline at end of file + sequence_id = row.sequence_id diff --git a/erpnext/manufacturing/doctype/routing/test_routing.py b/erpnext/manufacturing/doctype/routing/test_routing.py index 6a38dcfa03..92f26946ab 100644 --- a/erpnext/manufacturing/doctype/routing/test_routing.py +++ b/erpnext/manufacturing/doctype/routing/test_routing.py @@ -7,9 +7,7 @@ import unittest import frappe from frappe.test_runner import make_test_records from erpnext.stock.doctype.item.test_item import make_item -from erpnext.manufacturing.doctype.operation.test_operation import make_operation from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError -from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record class TestRouting(unittest.TestCase): @@ -48,7 +46,53 @@ class TestRouting(unittest.TestCase): wo_doc.cancel() wo_doc.delete() + def test_update_bom_operation_time(self): + operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "hour_rate_rent": 300, + "hour_rate_labour": 750 , + "time_in_mins": 30 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation B", + "hour_rate_labour": 200, + "hour_rate_rent": 1000, + "time_in_mins": 20 + } + ] + + test_routing_operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "time_in_mins": 30 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation A", + "time_in_mins": 20 + } + ] + setup_operations(operations) + routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations) + bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency = 'INR') + self.assertEqual(routing_doc.operations[0].time_in_mins, 30) + self.assertEqual(routing_doc.operations[1].time_in_mins, 20) + routing_doc.operations[0].time_in_mins = 90 + routing_doc.operations[1].time_in_mins = 42.2 + routing_doc.save() + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(bom_doc.operations[0].time_in_mins, 90) + self.assertEqual(bom_doc.operations[1].time_in_mins, 42.2) + + def setup_operations(rows): + from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation + from erpnext.manufacturing.doctype.operation.test_operation import make_operation for row in rows: make_workstation(row) make_operation(row) @@ -61,12 +105,14 @@ def create_routing(**args): if not args.do_not_save: try: - for operation in args.operations: - doc.append("operations", operation) - doc.insert() except frappe.DuplicateEntryError: doc = frappe.get_doc("Routing", args.routing_name) + doc.delete_key('operations') + for operation in args.operations: + doc.append("operations", operation) + + doc.save() return doc @@ -91,7 +137,7 @@ def setup_bom(**args): name = frappe.db.get_value('BOM', {'item': args.item_code}, 'name') if not name: bom_doc = make_bom(item = args.item_code, raw_materials = args.get("raw_materials"), - routing = args.routing, with_operations=1) + routing = args.routing, with_operations=1, currency = args.currency) else: bom_doc = frappe.get_doc("BOM", name) diff --git a/erpnext/manufacturing/doctype/sub_operation/__init__.py b/erpnext/manufacturing/doctype/sub_operation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.js b/erpnext/manufacturing/doctype/sub_operation/sub_operation.js new file mode 100644 index 0000000000..be9db6a408 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.js @@ -0,0 +1,8 @@ +// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Sub Operation', { + // refresh: function(frm) { + + // } +}); diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.json b/erpnext/manufacturing/doctype/sub_operation/sub_operation.json new file mode 100644 index 0000000000..f63d2b9864 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.json @@ -0,0 +1,51 @@ +{ + "actions": [], + "creation": "2020-12-07 15:39:47.488519", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "operation", + "time_in_mins", + "column_break_5", + "description" + ], + "fields": [ + { + "fieldname": "operation", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Operation", + "options": "Operation" + }, + { + "description": "Time in mins", + "fieldname": "time_in_mins", + "fieldtype": "Float", + "in_list_view": 1, + "label": "Operation Time" + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "description", + "fieldtype": "Small Text", + "label": "Description" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2020-12-07 18:09:18.005578", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Sub Operation", + "owner": "Administrator", + "permissions": [], + "quick_entry": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/sub_operation/sub_operation.py b/erpnext/manufacturing/doctype/sub_operation/sub_operation.py new file mode 100644 index 0000000000..f4b27758e9 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/sub_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +# import frappe +from frappe.model.document import Document + +class SubOperation(Document): + pass diff --git a/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py b/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py new file mode 100644 index 0000000000..d3410ca312 --- /dev/null +++ b/erpnext/manufacturing/doctype/sub_operation/test_sub_operation.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +# import frappe +import unittest + +class TestSubOperation(unittest.TestCase): + pass diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py index cb1ee92196..68de0b29d3 100644 --- a/erpnext/manufacturing/doctype/work_order/test_work_order.py +++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py @@ -389,17 +389,12 @@ class TestWorkOrder(unittest.TestCase): ste.submit() stock_entries.append(ste) - job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}) + job_cards = frappe.get_all('Job Card', filters = {'work_order': work_order.name}, order_by='creation asc') self.assertEqual(len(job_cards), len(bom.operations)) for i, job_card in enumerate(job_cards): doc = frappe.get_doc("Job Card", job_card) - doc.append("time_logs", { - "from_time": add_to_date(None, i), - "hours": 1, - "to_time": add_to_date(None, i + 1), - "completed_qty": doc.for_quantity - }) + doc.time_logs[0].completed_qty = 1 doc.submit() ste1 = frappe.get_doc(make_stock_entry(work_order.name, "Manufacture", 1)) diff --git a/erpnext/manufacturing/doctype/work_order/work_order.js b/erpnext/manufacturing/doctype/work_order/work_order.js index 3e5a72db9a..512048512e 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.js +++ b/erpnext/manufacturing/doctype/work_order/work_order.js @@ -141,8 +141,7 @@ frappe.ui.form.on("Work Order", { } if (frm.doc.docstatus === 1 - && frm.doc.operations && frm.doc.operations.length - && frm.doc.qty != frm.doc.material_transferred_for_manufacturing) { + && frm.doc.operations && frm.doc.operations.length) { const not_completed = frm.doc.operations.filter(d => { if(d.status != 'Completed') { @@ -190,35 +189,41 @@ frappe.ui.form.on("Work Order", { const dialog = frappe.prompt({fieldname: 'operations', fieldtype: 'Table', label: __('Operations'), fields: [ { - fieldtype:'Link', - fieldname:'operation', + fieldtype: 'Link', + fieldname: 'operation', label: __('Operation'), - read_only:1, - in_list_view:1 + read_only: 1, + in_list_view: 1 }, { - fieldtype:'Link', - fieldname:'workstation', + fieldtype: 'Link', + fieldname: 'workstation', label: __('Workstation'), - read_only:1, - in_list_view:1 + read_only: 1, + in_list_view: 1 }, { - fieldtype:'Data', - fieldname:'name', + fieldtype: 'Data', + fieldname: 'name', label: __('Operation Id') }, { - fieldtype:'Float', - fieldname:'pending_qty', + fieldtype: 'Float', + fieldname: 'pending_qty', label: __('Pending Qty'), }, { - fieldtype:'Float', - fieldname:'qty', + fieldtype: 'Float', + fieldname: 'qty', label: __('Quantity to Manufacture'), - read_only:0, - in_list_view:1, + read_only: 0, + in_list_view: 1, + }, + { + fieldtype: 'Float', + fieldname: 'batch_size', + label: __('Batch Size'), + read_only: 1 }, ], data: operations_data, @@ -229,9 +234,13 @@ frappe.ui.form.on("Work Order", { }, function(data) { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.make_job_card", + freeze: true, args: { work_order: frm.doc.name, operations: data.operations, + }, + callback: function() { + frm.reload_doc(); } }); }, __("Job Card"), __("Create")); @@ -243,13 +252,16 @@ frappe.ui.form.on("Work Order", { if(data.completed_qty != frm.doc.qty) { pending_qty = frm.doc.qty - flt(data.completed_qty); - dialog.fields_dict.operations.df.data.push({ - 'name': data.name, - 'operation': data.operation, - 'workstation': data.workstation, - 'qty': pending_qty, - 'pending_qty': pending_qty, - }); + if (pending_qty) { + dialog.fields_dict.operations.df.data.push({ + 'name': data.name, + 'operation': data.operation, + 'workstation': data.workstation, + 'batch_size': data.batch_size, + 'qty': pending_qty, + 'pending_qty': pending_qty + }); + } } }); dialog.fields_dict.operations.grid.refresh(); @@ -704,6 +716,8 @@ erpnext.work_order = { stop_work_order: function(frm, status) { frappe.call({ method: "erpnext.manufacturing.doctype.work_order.work_order.stop_unstop", + freeze: true, + freeze_message: __("Updating Work Order status"), args: { work_order: frm.doc.name, status: status diff --git a/erpnext/manufacturing/doctype/work_order/work_order.json b/erpnext/manufacturing/doctype/work_order/work_order.json index cd9edeeea8..3b56854aaf 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.json +++ b/erpnext/manufacturing/doctype/work_order/work_order.json @@ -21,6 +21,12 @@ "produced_qty", "sales_order", "project", + "serial_no_and_batch_for_finished_good_section", + "has_serial_no", + "has_batch_no", + "column_break_17", + "serial_no", + "batch_size", "settings_section", "allow_alternative_item", "use_multi_level_bom", @@ -52,16 +58,22 @@ "actual_operating_cost", "additional_operating_cost", "column_break_24", + "corrective_operation_cost", "total_operating_cost", "more_info", "description", "stock_uom", "column_break2", + "references_section", "material_request", "material_request_item", "sales_order_item", + "column_break_61", "production_plan", "production_plan_item", + "production_plan_sub_assembly_item", + "parent_work_order", + "bom_level", "product_bundle_item", "amended_from" ], @@ -488,17 +500,77 @@ "fieldtype": "Float", "label": "Lead Time", "read_only": 1 - } + }, + { + "collapsible": 1, + "depends_on": "eval:!doc.__islocal", + "fieldname": "serial_no_and_batch_for_finished_good_section", + "fieldtype": "Section Break", + "label": "Serial No and Batch for Finished Good" + }, + { + "fieldname": "column_break_17", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fetch_from": "production_item.has_serial_no", + "fieldname": "has_serial_no", + "fieldtype": "Check", + "label": "Has Serial No", + "read_only": 1 + }, + { + "default": "0", + "fetch_from": "production_item.has_batch_no", + "fieldname": "has_batch_no", + "fieldtype": "Check", + "label": "Has Batch No", + "read_only": 1 + }, + { + "depends_on": "has_serial_no", + "fieldname": "serial_no", + "fieldtype": "Small Text", + "label": "Serial Nos", + "no_copy": 1 + }, + { + "default": "0", + "depends_on": "has_batch_no", + "fieldname": "batch_size", + "fieldtype": "Float", + "label": "Batch Size" + }, + { + "allow_on_submit": 1, + "description": "From Corrective Job Card", + "fieldname": "corrective_operation_cost", + "fieldtype": "Currency", + "label": "Corrective Operation Cost", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + }, + { + "fieldname": "production_plan_sub_assembly_item", + "fieldtype": "Data", + "label": "Production Plan Sub-assembly Item", + "no_copy": 1, + "print_hide": 1, + "read_only": 1 + } ], "icon": "fa fa-cogs", "idx": 1, "image_field": "image", "is_submittable": 1, "links": [], - "modified": "2021-03-16 13:27:51.116484", + "modified": "2021-06-28 16:19:14.902699", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order", + "nsm_parent_field": "parent_work_order", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py index 2600790a59..779ae42d65 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order.py +++ b/erpnext/manufacturing/doctype/work_order/work_order.py @@ -1,7 +1,6 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt -from __future__ import unicode_literals import frappe import json import math @@ -19,18 +18,17 @@ from frappe.utils.csvutils import getlink from erpnext.stock.utils import get_bin, validate_warehouse_company, get_latest_stock_qty from erpnext.utilities.transaction_base import validate_uom_is_integer from frappe.model.mapper import get_mapped_doc +from erpnext.stock.doctype.batch.batch import make_batch +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, get_auto_serial_nos, auto_make_serial_nos class OverProductionError(frappe.ValidationError): pass class CapacityError(frappe.ValidationError): pass class StockOverProductionError(frappe.ValidationError): pass class OperationTooLongError(frappe.ValidationError): pass class ItemHasVariantError(frappe.ValidationError): pass +class SerialNoQtyError(frappe.ValidationError): + pass -from six import string_types - -form_grid_templates = { - "operations": "templates/form_grid/work_order_grid.html" -} class WorkOrder(Document): def onload(self): @@ -127,7 +125,9 @@ class WorkOrder(Document): variable_cost = self.actual_operating_cost if self.actual_operating_cost \ else self.planned_operating_cost - self.total_operating_cost = flt(self.additional_operating_cost) + flt(variable_cost) + + self.total_operating_cost = (flt(self.additional_operating_cost) + + flt(variable_cost) + flt(self.corrective_operation_cost)) def validate_work_order_against_so(self): # already ordered qty @@ -235,12 +235,15 @@ class WorkOrder(Document): production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item) + def before_submit(self): + self.create_serial_no_batch_no() + def on_submit(self): if not self.wip_warehouse: frappe.throw(_("Work-in-Progress Warehouse is required before Submit")) if not self.fg_warehouse: frappe.throw(_("For Warehouse is required before Submit")) - + if self.production_plan and frappe.db.exists('Production Plan Item Reference',{'parent':self.production_plan}): self.update_work_order_qty_in_combined_so() else: @@ -260,12 +263,76 @@ class WorkOrder(Document): self.update_work_order_qty_in_combined_so() else: self.update_work_order_qty_in_so() - + self.delete_job_card() self.update_completed_qty_in_material_request() self.update_planned_qty() self.update_ordered_qty() self.update_reserved_qty_for_production() + self.delete_auto_created_batch_and_serial_no() + + def create_serial_no_batch_no(self): + if not (self.has_serial_no or self.has_batch_no): + return + + if not cint(frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")): + return + + if self.has_batch_no: + self.create_batch_for_finished_good() + + args = { + "item_code": self.production_item, + "work_order": self.name + } + + if self.has_serial_no: + self.make_serial_nos(args) + + def create_batch_for_finished_good(self): + total_qty = self.qty + if not self.batch_size: + self.batch_size = total_qty + + while total_qty > 0: + qty = self.batch_size + if self.batch_size >= total_qty: + qty = total_qty + + if total_qty > self.batch_size: + total_qty -= self.batch_size + else: + qty = total_qty + total_qty = 0 + + make_batch(frappe._dict({ + "item": self.production_item, + "qty_to_produce": qty, + "reference_doctype": self.doctype, + "reference_name": self.name + })) + + def delete_auto_created_batch_and_serial_no(self): + for row in frappe.get_all("Serial No", filters = {"work_order": self.name}): + frappe.delete_doc("Serial No", row.name) + self.db_set("serial_no", "") + + for row in frappe.get_all("Batch", filters = {"reference_name": self.name}): + frappe.delete_doc("Batch", row.name) + + def make_serial_nos(self, args): + serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") + if serial_no_series: + self.serial_no = get_auto_serial_nos(serial_no_series, self.qty) + + if self.serial_no: + args.update({"serial_no": self.serial_no, "actual_qty": self.qty}) + auto_make_serial_nos(args) + + serial_nos_length = len(get_serial_nos(self.serial_no)) + if serial_nos_length != self.qty: + frappe.throw(_("{0} Serial Numbers required for Item {1}. You have provided {2}.") + .format(self.qty, self.production_item, serial_nos_length), SerialNoQtyError) def create_job_card(self): manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings") @@ -273,32 +340,40 @@ class WorkOrder(Document): enable_capacity_planning = not cint(manufacturing_settings_doc.disable_capacity_planning) plan_days = cint(manufacturing_settings_doc.capacity_planning_for_days) or 30 - for i, row in enumerate(self.operations): - self.set_operation_start_end_time(i, row) - - if not row.workstation: - frappe.throw(_("Row {0}: select the workstation against the operation {1}") - .format(row.idx, row.operation)) - - original_start_time = row.planned_start_time - job_card_doc = create_job_card(self, row, - enable_capacity_planning=enable_capacity_planning, auto_create=True) - - if enable_capacity_planning and job_card_doc: - row.planned_start_time = job_card_doc.time_logs[-1].from_time - row.planned_end_time = job_card_doc.time_logs[-1].to_time - - if date_diff(row.planned_start_time, original_start_time) > plan_days: - frappe.message_log.pop() - frappe.throw(_("Unable to find the time slot in the next {0} days for the operation {1}.") - .format(plan_days, row.operation), CapacityError) - - row.db_update() + for index, row in enumerate(self.operations): + qty = self.qty + while qty > 0: + qty = split_qty_based_on_batch_size(self, row, qty) + if row.job_card_qty > 0: + self.prepare_data_for_job_card(row, index, + plan_days, enable_capacity_planning) planned_end_date = self.operations and self.operations[-1].planned_end_time if planned_end_date: self.db_set("planned_end_date", planned_end_date) + def prepare_data_for_job_card(self, row, index, plan_days, enable_capacity_planning): + self.set_operation_start_end_time(index, row) + + if not row.workstation: + frappe.throw(_("Row {0}: select the workstation against the operation {1}") + .format(row.idx, row.operation)) + + original_start_time = row.planned_start_time + job_card_doc = create_job_card(self, row, auto_create=True, + enable_capacity_planning=enable_capacity_planning) + + if enable_capacity_planning and job_card_doc: + row.planned_start_time = job_card_doc.time_logs[-1].from_time + row.planned_end_time = job_card_doc.time_logs[-1].to_time + + if date_diff(row.planned_start_time, original_start_time) > plan_days: + frappe.message_log.pop() + frappe.throw(_("Unable to find the time slot in the next {0} days for the operation {1}.") + .format(plan_days, row.operation), CapacityError) + + row.db_update() + def set_operation_start_end_time(self, idx, row): """Set start and end time for given operation. If first operation, set start as `planned_start_date`, else add time diff to end time of earlier operation.""" @@ -365,7 +440,7 @@ class WorkOrder(Document): work_order_qty = qty[0][0] if qty and qty[0][0] else 0 frappe.db.set_value('Sales Order Item', self.sales_order_item, 'work_order_qty', flt(work_order_qty/total_bundle_qty, 2)) - + def update_work_order_qty_in_combined_so(self): total_bundle_qty = 1 if self.product_bundle_item: @@ -378,7 +453,7 @@ class WorkOrder(Document): prod_plan = frappe.get_doc('Production Plan', self.production_plan) item_reference = frappe.get_value('Production Plan Item', self.production_plan_item, 'sales_order_item') - + for plan_reference in prod_plan.prod_plan_references: work_order_qty = 0.0 if plan_reference.item_reference == item_reference: @@ -386,53 +461,54 @@ class WorkOrder(Document): work_order_qty = flt(plan_reference.qty) / total_bundle_qty frappe.db.set_value('Sales Order Item', plan_reference.sales_order_item, 'work_order_qty', work_order_qty) - + def update_completed_qty_in_material_request(self): if self.material_request: frappe.get_doc("Material Request", self.material_request).update_completed_qty([self.material_request_item]) def set_work_order_operations(self): """Fetch operations from BOM and set in 'Work Order'""" - self.set('operations', []) - if not self.bom_no: + def _get_operations(bom_no, qty=1): + return frappe.db.sql( + f"""select + operation, description, workstation, idx, + base_hour_rate as hour_rate, time_in_mins * {qty} as time_in_mins, + "Pending" as status, parent as bom, batch_size, sequence_id + from + `tabBOM Operation` + where + parent = %s order by idx + """, bom_no, as_dict=1) + + + self.set('operations', []) + if not self.bom_no or not frappe.get_cached_value('BOM', self.bom_no, 'with_operations'): return - if self.use_multi_level_bom: - bom_list = frappe.get_doc("BOM", self.bom_no).traverse_tree() + operations = [] + if not self.use_multi_level_bom: + bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") + operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty)) else: - bom_list = [self.bom_no] + bom_tree = frappe.get_doc("BOM", self.bom_no).get_tree_representation() + bom_traversal = list(reversed(bom_tree.level_order_traversal())) + bom_traversal.append(bom_tree) # add operation on top level item last + + for d in bom_traversal: + if d.is_bom: + operations.extend(_get_operations(d.name, qty=d.exploded_qty)) + + for correct_index, operation in enumerate(operations, start=1): + operation.idx = correct_index - operations = frappe.db.sql(""" - select - operation, description, workstation, idx, - base_hour_rate as hour_rate, time_in_mins, - "Pending" as status, parent as bom, batch_size, sequence_id - from - `tabBOM Operation` - where - parent in (%s) order by idx - """ % ", ".join(["%s"]*len(bom_list)), tuple(bom_list), as_dict=1) self.set('operations', operations) - - if self.use_multi_level_bom and self.get('operations') and self.get('items'): - raw_material_operations = [d.operation for d in self.get('items')] - operations = [d.operation for d in self.get('operations')] - - for operation in raw_material_operations: - if operation not in operations: - self.append('operations', { - 'operation': operation - }) - self.calculate_time() def calculate_time(self): - bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity") - for d in self.get("operations"): - d.time_in_mins = flt(d.time_in_mins) / flt(bom_qty) * (flt(self.qty) / flt(d.batch_size)) + d.time_in_mins = flt(d.time_in_mins) * (flt(self.qty) / flt(d.batch_size)) self.calculate_operating_cost() @@ -514,6 +590,7 @@ class WorkOrder(Document): def validate_operation_time(self): for d in self.operations: if not d.time_in_mins > 0: + print(self.bom_no, self.production_item) frappe.throw(_("Operation Time must be greater than 0 for Operation {0}").format(d.operation)) def update_required_items(self): @@ -669,6 +746,17 @@ class WorkOrder(Document): bom.set_bom_material_details() return bom + def update_batch_produced_qty(self, stock_entry_doc): + if not cint(frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")): + return + + for row in stock_entry_doc.items: + if row.batch_no and (row.is_finished_item or row.is_scrap_item): + qty = frappe.get_all("Stock Entry Detail", filters = {"batch_no": row.batch_no, "docstatus": 1}, + or_filters= {"is_finished_item": 1, "is_scrap_item": 1}, fields = ["sum(qty)"], as_list=1)[0][0] + + frappe.db.set_value("Batch", row.batch_no, "produced_qty", flt(qty)) + @frappe.whitelist() @frappe.validate_and_sanitize_search_inputs def get_bom_operations(doctype, txt, searchfield, start, page_len, filters): @@ -746,7 +834,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None): return wo_doc def add_variant_item(variant_items, wo_doc, bom_no, table_name="items"): - if isinstance(variant_items, string_types): + if isinstance(variant_items, str): variant_items = json.loads(variant_items) for item in variant_items: @@ -826,6 +914,7 @@ def make_stock_entry(work_order_id, purpose, qty=None): stock_entry.set_stock_entry_type() stock_entry.get_items() + stock_entry.set_serial_no_batch_for_finished_good() return stock_entry.as_dict() @frappe.whitelist() @@ -867,13 +956,47 @@ def query_sales_order(production_item): @frappe.whitelist() def make_job_card(work_order, operations): - if isinstance(operations, string_types): + if isinstance(operations, str): operations = json.loads(operations) work_order = frappe.get_doc('Work Order', work_order) for row in operations: + row = frappe._dict(row) validate_operation_data(row) - create_job_card(work_order, row, row.get("qty"), auto_create=True) + qty = row.get("qty") + while qty > 0: + qty = split_qty_based_on_batch_size(work_order, row, qty) + if row.job_card_qty > 0: + create_job_card(work_order, row, auto_create=True) + +def split_qty_based_on_batch_size(wo_doc, row, qty): + if not cint(frappe.db.get_value("Operation", + row.operation, "create_job_card_based_on_batch_size")): + row.batch_size = row.get("qty") or wo_doc.qty + + row.job_card_qty = row.batch_size + if row.batch_size and qty >= row.batch_size: + qty -= row.batch_size + elif qty > 0: + row.job_card_qty = qty + qty = 0 + + get_serial_nos_for_job_card(row, wo_doc) + + return qty + +def get_serial_nos_for_job_card(row, wo_doc): + if not wo_doc.serial_no: + return + + serial_nos = get_serial_nos(wo_doc.serial_no) + used_serial_nos = [] + for d in frappe.get_all('Job Card', fields=['serial_no'], + filters={'docstatus': ('<', 2), 'work_order': wo_doc.name, 'operation_id': row.name}): + used_serial_nos.extend(get_serial_nos(d.serial_no)) + + serial_nos = sorted(list(set(serial_nos) - set(used_serial_nos))) + row.serial_no = '\n'.join(serial_nos[0:row.job_card_qty]) def validate_operation_data(row): if row.get("qty") <= 0: @@ -892,20 +1015,22 @@ def validate_operation_data(row): ) ) -def create_job_card(work_order, row, qty=0, enable_capacity_planning=False, auto_create=False): +def create_job_card(work_order, row, enable_capacity_planning=False, auto_create=False): doc = frappe.new_doc("Job Card") doc.update({ 'work_order': work_order.name, 'operation': row.get("operation"), 'workstation': row.get("workstation"), 'posting_date': nowdate(), - 'for_quantity': qty or work_order.get('qty', 0), + 'for_quantity': row.job_card_qty or work_order.get('qty', 0), 'operation_id': row.get("name"), 'bom_no': work_order.bom_no, 'project': work_order.project, 'company': work_order.company, 'sequence_id': row.get("sequence_id"), - 'wip_warehouse': work_order.wip_warehouse + 'wip_warehouse': work_order.wip_warehouse, + 'hour_rate': row.get("hour_rate"), + 'serial_no': row.get("serial_no") }) if work_order.transfer_material_against == 'Job Card' and not work_order.skip_transfer: diff --git a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py index 87c090f99c..9aa0715e7f 100644 --- a/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py +++ b/erpnext/manufacturing/doctype/work_order/work_order_dashboard.py @@ -4,10 +4,17 @@ from frappe import _ def get_data(): return { 'fieldname': 'work_order', + 'non_standard_fieldnames': { + 'Batch': 'reference_name' + }, 'transactions': [ { 'label': _('Transactions'), 'items': ['Stock Entry', 'Job Card', 'Pick List'] + }, + { + 'label': _('Reference'), + 'items': ['Serial No', 'Batch'] } ] } \ No newline at end of file diff --git a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json index 8c5cde9a13..f7b8787a0b 100644 --- a/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json +++ b/erpnext/manufacturing/doctype/work_order_operation/work_order_operation.json @@ -2,14 +2,14 @@ "actions": [], "creation": "2014-10-16 14:35:41.950175", "doctype": "DocType", - "editable_grid": 1, "engine": "InnoDB", "field_order": [ "details", "operation", "bom", - "sequence_id", + "column_break_4", "description", + "sequence_id", "col_break1", "completed_qty", "status", @@ -48,6 +48,7 @@ { "fieldname": "bom", "fieldtype": "Link", + "in_list_view": 1, "label": "BOM", "no_copy": 1, "options": "BOM", @@ -67,6 +68,7 @@ "fieldtype": "Column Break" }, { + "columns": 1, "description": "Operation completed for how many finished goods?", "fieldname": "completed_qty", "fieldtype": "Float", @@ -76,6 +78,7 @@ "read_only": 1 }, { + "columns": 1, "default": "Pending", "fieldname": "status", "fieldtype": "Select", @@ -118,6 +121,7 @@ "fieldtype": "Column Break" }, { + "columns": 1, "description": "in Minutes", "fieldname": "time_in_mins", "fieldtype": "Float", @@ -195,12 +199,16 @@ "label": "Sequence ID", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2020-10-14 12:58:49.241252", + "modified": "2021-06-24 14:36:12.835543", "modified_by": "Administrator", "module": "Manufacturing", "name": "Work Order Operation", @@ -209,4 +217,4 @@ "sort_field": "modified", "sort_order": "DESC", "track_changes": 1 -} \ No newline at end of file +} diff --git a/erpnext/manufacturing/doctype/workstation/test_workstation.py b/erpnext/manufacturing/doctype/workstation/test_workstation.py index c6699bee48..9b73aca601 100644 --- a/erpnext/manufacturing/doctype/workstation/test_workstation.py +++ b/erpnext/manufacturing/doctype/workstation/test_workstation.py @@ -1,16 +1,19 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt from __future__ import unicode_literals +from erpnext.manufacturing.doctype.operation.test_operation import make_operation import frappe import unittest from erpnext.manufacturing.doctype.workstation.workstation import check_if_within_operating_hours, NotInWorkingHoursError, WorkstationHolidayError +from erpnext.manufacturing.doctype.routing.test_routing import setup_bom, create_routing +from frappe.test_runner import make_test_records test_dependencies = ["Warehouse"] test_records = frappe.get_test_records('Workstation') +make_test_records('Workstation') class TestWorkstation(unittest.TestCase): - def test_validate_timings(self): check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00") check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00") @@ -21,6 +24,58 @@ class TestWorkstation(unittest.TestCase): self.assertRaises(WorkstationHolidayError, check_if_within_operating_hours, "_Test Workstation 1", "Operation 1", "2013-02-01 10:00:00", "2013-02-02 20:00:00") + def test_update_bom_operation_rate(self): + operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "hour_rate_rent": 300, + "time_in_mins": 60 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation B", + "hour_rate_rent": 1000, + "time_in_mins": 60 + } + ] + + for row in operations: + make_workstation(row) + make_operation(row) + + test_routing_operations = [ + { + "operation": "Test Operation A", + "workstation": "_Test Workstation A", + "time_in_mins": 60 + }, + { + "operation": "Test Operation B", + "workstation": "_Test Workstation A", + "time_in_mins": 60 + } + ] + routing_doc = create_routing(routing_name = "Routing Test", operations=test_routing_operations) + bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR") + w1 = frappe.get_doc("Workstation", "_Test Workstation A") + #resets values + w1.hour_rate_rent = 300 + w1.hour_rate_labour = 0 + w1.save() + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(w1.hour_rate, 300) + self.assertEqual(bom_doc.operations[0].hour_rate, 300) + w1.hour_rate_rent = 250 + w1.save() + #updating after setting new rates in workstations + bom_doc.update_cost() + bom_doc.reload() + self.assertEqual(w1.hour_rate, 250) + self.assertEqual(bom_doc.operations[0].hour_rate, 250) + self.assertEqual(bom_doc.operations[1].hour_rate, 250) + def make_workstation(*args, **kwargs): args = args if args else kwargs if isinstance(args, tuple): @@ -34,9 +89,10 @@ def make_workstation(*args, **kwargs): "doctype": "Workstation", "workstation_name": workstation_name }) - + doc.hour_rate_rent = args.get("hour_rate_rent") + doc.hour_rate_labour = args.get("hour_rate_labour") doc.insert() return doc except frappe.DuplicateEntryError: - return frappe.get_doc("Workstation", workstation_name) \ No newline at end of file + return frappe.get_doc("Workstation", workstation_name) diff --git a/erpnext/manufacturing/doctype/workstation/workstation.py b/erpnext/manufacturing/doctype/workstation/workstation.py index 3512e59045..f4483f7547 100644 --- a/erpnext/manufacturing/doctype/workstation/workstation.py +++ b/erpnext/manufacturing/doctype/workstation/workstation.py @@ -39,7 +39,8 @@ class Workstation(Document): def update_bom_operation(self): bom_list = frappe.db.sql("""select DISTINCT parent from `tabBOM Operation` - where workstation = %s""", self.name) + where workstation = %s and parenttype = 'routing' """, self.name) + for bom_no in bom_list: frappe.db.sql("""update `tabBOM Operation` set hour_rate = %s where parent = %s and workstation = %s""", @@ -71,7 +72,7 @@ def check_if_within_operating_hours(workstation, operation, from_datetime, to_da def is_within_operating_hours(workstation, operation, from_datetime, to_datetime): operation_length = time_diff_in_seconds(to_datetime, from_datetime) workstation = frappe.get_doc("Workstation", workstation) - + if not workstation.working_hours: return diff --git a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py index 48907adc5f..858b5546b0 100644 --- a/erpnext/manufacturing/report/bom_explorer/bom_explorer.py +++ b/erpnext/manufacturing/report/bom_explorer/bom_explorer.py @@ -20,17 +20,20 @@ def get_exploded_items(bom, data, indent=0, qty=1): fields= ['qty','bom_no','qty','scrap','item_code','item_name','description','uom']) for item in exploded_items: + print(item.bom_no, indent) item["indent"] = indent data.append({ 'item_code': item.item_code, 'item_name': item.item_name, 'indent': indent, + 'bom_level': (frappe.get_cached_value("BOM", item.bom_no, "bom_level") + if item.bom_no else ""), 'bom': item.bom_no, 'qty': item.qty * qty, 'uom': item.uom, 'description': item.description, 'scrap': item.scrap - }) + }) if item.bom_no: get_exploded_items(item.bom_no, data, indent=indent+1, qty=item.qty) @@ -68,6 +71,12 @@ def get_columns(): "fieldname": "uom", "width": 100 }, + { + "label": "BOM Level", + "fieldtype": "Data", + "fieldname": "bom_level", + "width": 100 + }, { "label": "Standard Description", "fieldtype": "data", diff --git a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py index 1c6758e6f3..ed8b93929a 100644 --- a/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py +++ b/erpnext/manufacturing/report/bom_stock_report/bom_stock_report.py @@ -70,12 +70,12 @@ def get_bom_stock(filters): ON bom_item.item_code = ledger.item_code {conditions} WHERE - bom_item.parent = '{bom}' and bom_item.parenttype='BOM' + bom_item.parent = {bom} and bom_item.parenttype='BOM' GROUP BY bom_item.item_code""".format( qty_field=qty_field, table=table, conditions=conditions, - bom=bom, + bom=frappe.db.escape(bom), qty_to_produce=qty_to_produce or 1) ) diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/__init__.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js new file mode 100644 index 0000000000..97e7e0a7d2 --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.js @@ -0,0 +1,105 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Cost of Poor Quality Report"] = { + "filters": [ + { + label: __("Company"), + fieldname: "company", + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + label: __("From Date"), + fieldname:"from_date", + fieldtype: "Datetime", + default: frappe.datetime.convert_to_system_tz(frappe.datetime.add_months(frappe.datetime.now_datetime(), -1)), + reqd: 1 + }, + { + label: __("To Date"), + fieldname:"to_date", + fieldtype: "Datetime", + default: frappe.datetime.now_datetime(), + reqd: 1, + }, + { + label: __("Job Card"), + fieldname: "name", + fieldtype: "Link", + options: "Job Card", + get_query: function() { + return { + filters: { + is_corrective_job_card: 1, + docstatus: 1 + } + } + } + }, + { + label: __("Work Order"), + fieldname: "work_order", + fieldtype: "Link", + options: "Work Order" + }, + { + label: __("Operation"), + fieldname: "operation", + fieldtype: "Link", + options: "Operation", + get_query: function() { + return { + filters: { + is_corrective_operation: 1 + } + } + } + }, + { + label: __("Workstation"), + fieldname: "workstation", + fieldtype: "Link", + options: "Workstation" + }, + { + label: __("Item"), + fieldname: "production_item", + fieldtype: "Link", + options: "Item" + }, + { + label: __("Serial No"), + fieldname: "serial_no", + fieldtype: "Link", + options: "Serial No", + depends_on: "eval: doc.production_item", + get_query: function() { + var item_code = frappe.query_report.get_filter_value('production_item'); + return { + filters: { + item_code: item_code + } + } + } + }, + { + label: __("Batch No"), + fieldname: "batch_no", + fieldtype: "Link", + options: "Batch No", + depends_on: "eval: doc.production_item", + get_query: function() { + var item_code = frappe.query_report.get_filter_value('production_item'); + return { + filters: { + item: item_code + } + } + } + }, + ] +}; diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json new file mode 100644 index 0000000000..ee63bc1c28 --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.json @@ -0,0 +1,33 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-01-11 11:10:58.292896", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2021-01-11 11:11:03.594242", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Cost of Poor Quality Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Job Card", + "report_name": "Cost of Poor Quality Report", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "Manufacturing User" + }, + { + "role": "Manufacturing Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py new file mode 100644 index 0000000000..9f81e7d26a --- /dev/null +++ b/erpnext/manufacturing/report/cost_of_poor_quality_report/cost_of_poor_quality_report.py @@ -0,0 +1,127 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + + columns = get_columns(filters) + data = get_data(filters) + + return columns, data + +def get_data(report_filters): + data = [] + operations = frappe.get_all("Operation", filters = {"is_corrective_operation": 1}) + if operations: + operations = [d.name for d in operations] + fields = ["production_item as item_code", "item_name", "work_order", "operation", + "workstation", "total_time_in_mins", "name", "hour_rate", "serial_no", "batch_no"] + + filters = get_filters(report_filters, operations) + + job_cards = frappe.get_all("Job Card", fields = fields, + filters = filters) + + for row in job_cards: + row.operating_cost = flt(row.hour_rate) * (flt(row.total_time_in_mins) / 60.0) + update_raw_material_cost(row, report_filters) + data.append(row) + + return data + +def get_filters(report_filters, operations): + filters = {"docstatus": 1, "operation": ("in", operations), "is_corrective_job_card": 1} + for field in ["name", "work_order", "operation", "workstation", "company", "serial_no", "batch_no", "production_item"]: + if report_filters.get(field): + if field != 'serial_no': + filters[field] = report_filters.get(field) + else: + filters[field] = ('like', '% {} %'.format(report_filters.get(field))) + + return filters + +def update_raw_material_cost(row, filters): + row.rm_cost = 0.0 + for data in frappe.get_all("Job Card Item", fields = ["amount"], + filters={"parent": row.name, "docstatus": 1}): + row.rm_cost += data.amount + +def get_columns(filters): + return [ + { + "label": _("Job Card"), + "fieldtype": "Link", + "fieldname": "name", + "options": "Job Card", + "width": "100" + }, + { + "label": _("Work Order"), + "fieldtype": "Link", + "fieldname": "work_order", + "options": "Work Order", + "width": "100" + }, + { + "label": _("Item Code"), + "fieldtype": "Link", + "fieldname": "item_code", + "options": "Item", + "width": "100" + }, + { + "label": _("Item Name"), + "fieldtype": "Data", + "fieldname": "item_name", + "width": "100" + }, + { + "label": _("Operation"), + "fieldtype": "Link", + "fieldname": "operation", + "options": "Operation", + "width": "100" + }, + { + "label": _("Serial No"), + "fieldtype": "Data", + "fieldname": "serial_no", + "width": "100" + }, + { + "label": _("Batch No"), + "fieldtype": "Data", + "fieldname": "batch_no", + "width": "100" + }, + { + "label": _("Workstation"), + "fieldtype": "Link", + "fieldname": "workstation", + "options": "Workstation", + "width": "100" + }, + { + "label": _("Operating Cost"), + "fieldtype": "Currency", + "fieldname": "operating_cost", + "width": "100" + }, + { + "label": _("Raw Material Cost"), + "fieldtype": "Currency", + "fieldname": "rm_cost", + "width": "100" + }, + { + "label": _("Total Time (in Mins)"), + "fieldtype": "Float", + "fieldname": "total_time_in_mins", + "width": "100" + } + ] \ No newline at end of file diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js index bd68db190e..cb771e4994 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.js +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.js @@ -68,6 +68,18 @@ frappe.query_reports["Job Card Summary"] = { get_data: function(txt) { return frappe.db.get_link_options('Item', txt); } + }, + { + label: __("Workstation"), + fieldname: "workstation", + fieldtype: "Link", + options: "Workstation" + }, + { + label: __("Operation"), + fieldname: "operation", + fieldtype: "Link", + options: "Operation" } ] }; diff --git a/erpnext/manufacturing/report/job_card_summary/job_card_summary.json b/erpnext/manufacturing/report/job_card_summary/job_card_summary.json index 9f08fc34cb..ecf2b74bbe 100644 --- a/erpnext/manufacturing/report/job_card_summary/job_card_summary.json +++ b/erpnext/manufacturing/report/job_card_summary/job_card_summary.json @@ -1,14 +1,16 @@ { - "add_total_row": 0, + "add_total_row": 1, + "columns": [], "creation": "2020-04-20 12:00:21.436619", "disable_prepared_report": 0, "disabled": 0, "docstatus": 0, "doctype": "Report", + "filters": [], "idx": 0, "is_standard": "Yes", - "letter_head": "Gadgets International", - "modified": "2020-04-20 12:00:21.436619", + "letter_head": "", + "modified": "2020-12-30 11:49:21.713561", "modified_by": "Administrator", "module": "Manufacturing", "name": "Job Card Summary", diff --git a/erpnext/manufacturing/report/production_plan_summary/__init__.py b/erpnext/manufacturing/report/production_plan_summary/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js new file mode 100644 index 0000000000..59396fef16 --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.js @@ -0,0 +1,32 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Production Plan Summary"] = { + "filters": [ + { + fieldname: "production_plan", + label: __("Production Plan"), + fieldtype: "Link", + options: "Production Plan", + reqd: 1, + get_query: function() { + return { + filters: { + "docstatus": 1 + } + }; + } + } + ], + "formatter": function(value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + + if (column.fieldname == "document_name") { + var color = data.pending_qty > 0 ? 'red': 'green'; + value = `${data['document_name']}`; + } + + return value; + }, +}; diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json new file mode 100644 index 0000000000..33aca21a6e --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.json @@ -0,0 +1,26 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2020-12-27 11:43:39.781793", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2020-12-27 11:43:42.677584", + "modified_by": "Administrator", + "module": "Manufacturing", + "name": "Production Plan Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Production Plan", + "report_name": "Production Plan Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "Manufacturing User" + } + ] +} \ No newline at end of file diff --git a/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py new file mode 100644 index 0000000000..81b1791ae8 --- /dev/null +++ b/erpnext/manufacturing/report/production_plan_summary/production_plan_summary.py @@ -0,0 +1,136 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + data = get_data(filters) + columns = get_column(filters) + + return columns, data + +def get_data(filters): + data = [] + + order_details = {} + get_work_order_details(filters, order_details) + get_purchase_order_details(filters, order_details) + get_production_plan_item_details(filters, data, order_details) + + return data + +def get_production_plan_item_details(filters, data, order_details): + itemwise_indent = {} + + production_plan_doc = frappe.get_cached_doc("Production Plan", filters.get("production_plan")) + for row in production_plan_doc.po_items: + work_order = frappe.get_cached_value("Work Order", {"production_plan_item": row.name, + "bom_no": row.bom_no, "production_item": row.item_code}, "name") + + if row.item_code not in itemwise_indent: + itemwise_indent.setdefault(row.item_code, {}) + + data.append({ + "indent": 0, + "item_code": row.item_code, + "item_name": frappe.get_cached_value("Item", row.item_code, "item_name"), + "qty": row.planned_qty, + "document_type": "Work Order", + "document_name": work_order, + "bom_level": frappe.get_cached_value("BOM", row.bom_no, "bom_level"), + "produced_qty": order_details.get((work_order, row.item_code)).get("produced_qty"), + "pending_qty": flt(row.planned_qty) - flt(order_details.get((work_order, row.item_code)).get("produced_qty")) + }) + + get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details) + +def get_production_plan_sub_assembly_item_details(filters, row, production_plan_doc, data, order_details): + for item in production_plan_doc.sub_assembly_items: + if row.name == item.production_plan_item: + subcontracted_item = (item.type_of_manufacturing == 'Subcontract') + + if subcontracted_item: + docname = frappe.get_cached_value("Purchase Order Item", + {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, "parent") + else: + docname = frappe.get_cached_value("Work Order", + {"production_plan_sub_assembly_item": item.name, "docstatus": ("<", 2)}, "name") + + data.append({ + "indent": 1, + "item_code": item.production_item, + "item_name": item.item_name, + "qty": item.qty, + "document_type": "Work Order" if not subcontracted_item else "Purchase Order", + "document_name": docname, + "bom_level": item.bom_level, + "produced_qty": order_details.get((docname, item.production_item)).get("produced_qty"), + "pending_qty": flt(item.qty) - flt(order_details.get((docname, item.production_item)).get("produced_qty")) + }) + +def get_work_order_details(filters, order_details): + for row in frappe.get_all("Work Order", filters = {"production_plan": filters.get("production_plan")}, + fields=["name", "produced_qty", "production_plan", "production_item"]): + order_details.setdefault((row.name, row.production_item), row) + +def get_purchase_order_details(filters, order_details): + for row in frappe.get_all("Purchase Order Item", filters = {"production_plan": filters.get("production_plan")}, + fields=["parent", "received_qty as produced_qty", "item_code"]): + order_details.setdefault((row.parent, row.item_code), row) + +def get_column(filters): + return [ + { + "label": "Finished Good", + "fieldtype": "Link", + "fieldname": "item_code", + "width": 300, + "options": "Item" + }, + { + "label": "Item Name", + "fieldtype": "data", + "fieldname": "item_name", + "width": 100 + }, + { + "label": "Document Type", + "fieldtype": "Link", + "fieldname": "document_type", + "width": 150, + "options": "DocType" + }, + { + "label": "Document Name", + "fieldtype": "Dynamic Link", + "fieldname": "document_name", + "width": 150 + }, + { + "label": "BOM Level", + "fieldtype": "Int", + "fieldname": "bom_level", + "width": 100 + }, + { + "label": "Order Qty", + "fieldtype": "Float", + "fieldname": "qty", + "width": 120 + }, + { + "label": "Received Qty", + "fieldtype": "Float", + "fieldname": "produced_qty", + "width": 160 + }, + { + "label": "Pending Qty", + "fieldtype": "Float", + "fieldname": "pending_qty", + "width": 110 + } + ] diff --git a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py index fb047b230c..612dad0bf5 100644 --- a/erpnext/manufacturing/report/work_order_summary/work_order_summary.py +++ b/erpnext/manufacturing/report/work_order_summary/work_order_summary.py @@ -19,7 +19,7 @@ def execute(filters=None): return columns, data, None, chart_data def get_data(filters): - query_filters = {"docstatus": 1} + query_filters = {"docstatus": ("<", 2)} fields = ["name", "status", "sales_order", "production_item", "qty", "produced_qty", "planned_start_date", "planned_end_date", "actual_start_date", "actual_end_date", "lead_time"] @@ -62,7 +62,8 @@ def get_chart_based_on_status(data): "Not Started": 0, "In Process": 0, "Stopped": 0, - "Completed": 0 + "Completed": 0, + "Draft": 0 } for d in data: diff --git a/erpnext/patches.txt b/erpnext/patches.txt index ed6fefdd87..29376f00a1 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -288,3 +288,6 @@ execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True) erpnext.patches.v13_0.update_timesheet_changes erpnext.patches.v13_0.set_training_event_attendance erpnext.patches.v13_0.rename_issue_status_hold_to_on_hold +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 diff --git a/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py b/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py new file mode 100644 index 0000000000..be85cfdeef --- /dev/null +++ b/erpnext/patches/v13_0/bill_for_rejected_quantity_in_purchase_invoice.py @@ -0,0 +1,8 @@ +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doctype("Buying Settings") + buying_settings = frappe.get_single("Buying Settings") + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 0 + buying_settings.save() \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_job_card_details.py b/erpnext/patches/v13_0/update_job_card_details.py new file mode 100644 index 0000000000..d4e65c6f2f --- /dev/null +++ b/erpnext/patches/v13_0/update_job_card_details.py @@ -0,0 +1,16 @@ +# Copyright (c) 2019, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + frappe.reload_doc("manufacturing", "doctype", "job_card") + frappe.reload_doc("manufacturing", "doctype", "job_card_item") + frappe.reload_doc("manufacturing", "doctype", "work_order_operation") + + frappe.db.sql(""" update `tabJob Card` jc, `tabWork Order Operation` wo + SET jc.hour_rate = wo.hour_rate + WHERE + jc.operation_id = wo.name and jc.docstatus < 2 and wo.hour_rate > 0 + """) \ No newline at end of file diff --git a/erpnext/patches/v13_0/update_level_in_bom.py b/erpnext/patches/v13_0/update_level_in_bom.py new file mode 100644 index 0000000000..0d03c42e98 --- /dev/null +++ b/erpnext/patches/v13_0/update_level_in_bom.py @@ -0,0 +1,30 @@ +# Copyright (c) 2020, Frappe and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe + +def execute(): + for document in ["bom", "bom_item", "bom_explosion_item"]: + frappe.reload_doc('manufacturing', 'doctype', document) + + frappe.db.sql(" update `tabBOM` set bom_level = 0 where docstatus = 1") + + bom_list = frappe.db.sql_list("""select name from `tabBOM` bom + where docstatus=1 and is_active=1 and not exists(select bom_no from `tabBOM Item` + where parent=bom.name and ifnull(bom_no, '')!='')""") + + count = 0 + while(count < len(bom_list)): + for parent_bom in get_parent_boms(bom_list[count]): + bom_doc = frappe.get_cached_doc("BOM", parent_bom) + bom_doc.set_bom_level(update=True) + bom_list.append(parent_bom) + count += 1 + +def get_parent_boms(bom_no): + return frappe.db.sql_list(""" + select distinct bom_item.parent from `tabBOM Item` bom_item + where bom_item.bom_no = %s and bom_item.docstatus=1 and bom_item.parenttype='BOM' + and exists(select bom.name from `tabBOM` bom where bom.name=bom_item.parent and bom.is_active=1) + """, bom_no) diff --git a/erpnext/payroll/doctype/additional_salary/additional_salary.js b/erpnext/payroll/doctype/additional_salary/additional_salary.js index d1ed91fac7..24ffce537c 100644 --- a/erpnext/payroll/doctype/additional_salary/additional_salary.js +++ b/erpnext/payroll/doctype/additional_salary/additional_salary.js @@ -12,8 +12,12 @@ frappe.ui.form.on('Additional Salary', { } }; }); + }, - frm.trigger('set_earning_component'); + onload: function(frm) { + if (frm.doc.type) { + frm.trigger('set_component_query'); + } }, employee: function(frm) { @@ -46,14 +50,19 @@ frappe.ui.form.on('Additional Salary', { }, company: function(frm) { - frm.trigger('set_earning_component'); + frm.set_value("type", ""); + frm.trigger('set_component_query'); }, - set_earning_component: function(frm) { + set_component_query: function(frm) { if (!frm.doc.company) return; + let filters = {company: frm.doc.company}; + if (frm.doc.type) { + filters.type = frm.doc.type; + } frm.set_query("salary_component", function() { return { - filters: {type: ["in", ["earning", "deduction"]], company: frm.doc.company} + filters: filters }; }); }, diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js index f2892600d1..496c37b2fa 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.js +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.js @@ -135,10 +135,26 @@ frappe.ui.form.on('Payroll Entry', { }); frm.set_query('employee', 'employees', () => { - if (!frm.doc.company) { - frappe.msgprint(__("Please set a Company")); - return []; + let error_fields = []; + let mandatory_fields = ['company', 'payroll_frequency', 'start_date', 'end_date']; + + let message = __('Mandatory fields required in {0}', [__(frm.doc.doctype)]); + + mandatory_fields.forEach(field => { + if (!frm.doc[field]) { + error_fields.push(frappe.unscrub(field)); + } + }); + + if (error_fields && error_fields.length) { + message = message + '

"; + frappe.throw({ + message: message, + indicator: 'red', + title: __('Missing Fields') + }); } + return { query: "erpnext.payroll.doctype.payroll_entry.payroll_entry.employee_query", filters: frm.events.get_employee_filters(frm) @@ -148,25 +164,22 @@ frappe.ui.form.on('Payroll Entry', { get_employee_filters: function (frm) { let filters = {}; - filters['company'] = frm.doc.company; - filters['start_date'] = frm.doc.start_date; - filters['end_date'] = frm.doc.end_date; filters['salary_slip_based_on_timesheet'] = frm.doc.salary_slip_based_on_timesheet; - filters['payroll_frequency'] = frm.doc.payroll_frequency; - filters['payroll_payable_account'] = frm.doc.payroll_payable_account; - filters['currency'] = frm.doc.currency; - if (frm.doc.department) { - filters['department'] = frm.doc.department; - } - if (frm.doc.branch) { - filters['branch'] = frm.doc.branch; - } - if (frm.doc.designation) { - filters['designation'] = frm.doc.designation; - } + let fields = ['company', 'start_date', 'end_date', 'payroll_frequency', 'payroll_payable_account', + 'currency', 'department', 'branch', 'designation']; + + fields.forEach(field => { + if (frm.doc[field]) { + filters[field] = frm.doc[field]; + } + }); + if (frm.doc.employees) { - filters['employees'] = frm.doc.employees.filter(d => d.employee).map(d => d.employee); + let employees = frm.doc.employees.filter(d => d.employee).map(d => d.employee); + if (employees && employees.length) { + filters['employees'] = employees; + } } return filters; }, diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py index 7a70679db4..13cc423fc2 100644 --- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py +++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py @@ -11,6 +11,7 @@ from frappe import _ from erpnext.accounts.utils import get_fiscal_year from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from frappe.desk.reportview import get_match_cond, get_filters_cond +from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_accounting_dimensions class PayrollEntry(Document): def onload(self): @@ -41,7 +42,7 @@ class PayrollEntry(Document): emp_with_sal_slip.append(employee_details.employee) if len(emp_with_sal_slip): - frappe.throw(_("Salary Slip already exists for {0} ").format(comma_and(emp_with_sal_slip))) + frappe.throw(_("Salary Slip already exists for {0}").format(comma_and(emp_with_sal_slip))) def on_cancel(self): frappe.delete_doc("Salary Slip", frappe.db.sql_list("""select name from `tabSalary Slip` @@ -116,7 +117,6 @@ class PayrollEntry(Document): Creates salary slip for selected employees if already not created """ self.check_permission('write') - self.created = 1 employees = [emp.employee for emp in self.employees] if employees: args = frappe._dict({ @@ -211,7 +211,7 @@ class PayrollEntry(Document): return account_dict def make_accrual_jv_entry(self): - self.check_permission('write') + self.check_permission("write") earnings = self.get_salary_component_total(component_type = "earnings") or {} deductions = self.get_salary_component_total(component_type = "deductions") or {} payroll_payable_account = self.payroll_payable_account @@ -219,12 +219,13 @@ class PayrollEntry(Document): precision = frappe.get_precision("Journal Entry Account", "debit_in_account_currency") if earnings or deductions: - journal_entry = frappe.new_doc('Journal Entry') - journal_entry.voucher_type = 'Journal Entry' - journal_entry.user_remark = _('Accrual Journal Entry for salaries from {0} to {1}')\ + journal_entry = frappe.new_doc("Journal Entry") + journal_entry.voucher_type = "Journal Entry" + journal_entry.user_remark = _("Accrual Journal Entry for salaries from {0} to {1}")\ .format(self.start_date, self.end_date) journal_entry.company = self.company journal_entry.posting_date = self.posting_date + accounting_dimensions = get_accounting_dimensions() or [] accounts = [] currencies = [] @@ -236,37 +237,34 @@ class PayrollEntry(Document): for acc_cc, amount in earnings.items(): exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount += flt(amount, precision) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": acc_cc[0], "debit_in_account_currency": flt(amt, precision), "exchange_rate": flt(exchange_rate), - "party_type": '', "cost_center": acc_cc[1] or self.cost_center, "project": self.project - }) + }, accounting_dimensions)) # Deductions for acc_cc, amount in deductions.items(): exchange_rate, amt = self.get_amount_and_exchange_rate_for_journal_entry(acc_cc[0], amount, company_currency, currencies) payable_amount -= flt(amount, precision) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": acc_cc[0], "credit_in_account_currency": flt(amt, precision), "exchange_rate": flt(exchange_rate), "cost_center": acc_cc[1] or self.cost_center, - "party_type": '', "project": self.project - }) + }, accounting_dimensions)) # Payable amount exchange_rate, payable_amt = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, payable_amount, company_currency, currencies) - accounts.append({ + accounts.append(self.update_accounting_dimensions({ "account": payroll_payable_account, "credit_in_account_currency": flt(payable_amt, precision), "exchange_rate": flt(exchange_rate), - "party_type": '', "cost_center": self.cost_center - }) + }, accounting_dimensions)) journal_entry.set("accounts", accounts) if len(currencies) > 1: @@ -286,6 +284,12 @@ class PayrollEntry(Document): return jv_name + def update_accounting_dimensions(self, row, accounting_dimensions): + for dimension in accounting_dimensions: + row.update({dimension: self.get(dimension)}) + + return row + def get_amount_and_exchange_rate_for_journal_entry(self, account, amount, company_currency, currencies): conversion_rate = 1 exchange_rate = self.exchange_rate @@ -454,6 +458,7 @@ def get_emp_list(sal_struct, cond, end_date, payroll_payable_account): where t1.name = t2.employee and t2.docstatus = 1 + and t1.status != 'Inactive' %s order by t2.from_date desc """ % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True) @@ -674,9 +679,13 @@ def employee_query(doctype, txt, searchfield, start, page_len, filters): conditions = [] include_employees = [] emp_cond = '' + + if not filters.payroll_frequency: + frappe.throw(_('Select Payroll Frequency.')) + if filters.start_date and filters.end_date: employee_list = get_employee_list(filters) - emp = filters.get('employees') + emp = filters.get('employees') or [] include_employees = [employee.employee for employee in employee_list if employee.employee not in emp] filters.pop('start_date') filters.pop('end_date') diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py index 9e7db977ab..ce88cc3f1e 100644 --- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py +++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py @@ -481,6 +481,7 @@ def make_employee_salary_slip(user, payroll_frequency, salary_structure=None): if not salary_structure: salary_structure = payroll_frequency + " Salary Structure Test for Salary Slip" + employee = frappe.db.get_value("Employee", {"user_id": user}) salary_structure_doc = make_salary_structure(salary_structure, payroll_frequency, employee=employee) salary_slip_name = frappe.db.get_value("Salary Slip", {"employee": frappe.db.get_value("Employee", {"user_id": user})}) diff --git a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py index dce6b7aa3d..e7d123c996 100644 --- a/erpnext/payroll/doctype/salary_structure/test_salary_structure.py +++ b/erpnext/payroll/doctype/salary_structure/test_salary_structure.py @@ -124,8 +124,8 @@ def make_salary_structure(salary_structure, payroll_frequency, employee=None, "doctype": "Salary Structure", "name": salary_structure, "company": company or erpnext.get_default_company(), - "earnings": make_earning_salary_component(test_tax=test_tax, company_list=["_Test Company"]), - "deductions": make_deduction_salary_component(test_tax=test_tax, company_list=["_Test Company"]), + "earnings": make_earning_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]), + "deductions": make_deduction_salary_component(setup=True, test_tax=test_tax, company_list=["_Test Company"]), "payroll_frequency": payroll_frequency, "payment_account": get_random("Account", filters={'account_currency': currency}), "currency": currency diff --git a/erpnext/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py index 3521e7e8bf..daaba67173 100644 --- a/erpnext/portal/product_configurator/test_product_configurator.py +++ b/erpnext/portal/product_configurator/test_product_configurator.py @@ -43,6 +43,30 @@ class TestProductConfigurator(unittest.TestCase): "show_variant_in_website": 1 }).insert() + def create_regular_web_item(self, name, item_group=None): + if not frappe.db.exists('Item', name): + doc = frappe.get_doc({ + "description": name, + "item_code": name, + "item_name": name, + "doctype": "Item", + "is_stock_item": 1, + "item_group": item_group or "_Test Item Group", + "stock_uom": "_Test UOM", + "item_defaults": [{ + "company": "_Test Company", + "default_warehouse": "_Test Warehouse - _TC", + "expense_account": "_Test Account Cost for Goods Sold - _TC", + "buying_cost_center": "_Test Cost Center - _TC", + "selling_cost_center": "_Test Cost Center - _TC", + "income_account": "Sales - _TC" + }], + "show_in_website": 1 + }).insert() + else: + doc = frappe.get_doc("Item", name) + return doc + def test_product_list(self): template_items = frappe.get_all('Item', {'show_in_website': 1}) variant_items = frappe.get_all('Item', {'show_variant_in_website': 1}) @@ -79,3 +103,42 @@ class TestProductConfigurator(unittest.TestCase): 'Test Size': ['2XL'] }) self.assertEqual(len(items), 1) + + def test_products_in_multiple_item_groups(self): + """Check if product is visible on multiple item group pages barring its own.""" + from erpnext.shopping_cart.product_query import ProductQuery + + if not frappe.db.exists("Item Group", {"name": "Tech Items"}): + item_group_doc = frappe.get_doc({ + "doctype": "Item Group", + "item_group_name": "Tech Items", + "parent_item_group": "All Item Groups", + "show_in_website": 1 + }).insert() + else: + item_group_doc = frappe.get_doc("Item Group", "Tech Items") + + doc = self.create_regular_web_item("Portal Item", item_group="Tech Items") + if not frappe.db.exists("Website Item Group", {"parent": "Portal Item"}): + doc.append("website_item_groups", { + "item_group": "_Test Item Group Desktops" + }) + doc.save() + + # check if item is visible in its own Item Group's page + engine = ProductQuery() + items = engine.query({}, {"item_group": "Tech Items"}, None, start=0, item_group="Tech Items") + self.assertEqual(len(items), 1) + self.assertEqual(items[0].item_code, "Portal Item") + + # check if item is visible in configured foreign Item Group's page + engine = ProductQuery() + items = engine.query({}, {"item_group": "_Test Item Group Desktops"}, None, start=0, item_group="_Test Item Group Desktops") + item_codes = [row.item_code for row in items] + + self.assertIn(len(items), [2, 3]) + self.assertIn("Portal Item", item_codes) + + # teardown + doc.delete() + item_group_doc.delete() \ No newline at end of file diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js index e5a5fcfe3b..1de9ec1a7d 100644 --- a/erpnext/public/js/controllers/taxes_and_totals.js +++ b/erpnext/public/js/controllers/taxes_and_totals.js @@ -270,11 +270,14 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ let me = this; let item_codes = []; let item_rates = {}; + let item_tax_templates = {}; + $.each(this.frm.doc.items || [], function(i, item) { if (item.item_code) { // Use combination of name and item code in case same item is added multiple times item_codes.push([item.item_code, item.name]); item_rates[item.name] = item.net_rate; + item_tax_templates[item.name] = item.item_tax_template; } }); @@ -285,18 +288,16 @@ erpnext.taxes_and_totals = erpnext.payments.extend({ company: me.frm.doc.company, tax_category: cstr(me.frm.doc.tax_category), item_codes: item_codes, - item_rates: item_rates + item_rates: item_rates, + item_tax_templates: item_tax_templates }, callback: function(r) { if (!r.exc) { $.each(me.frm.doc.items || [], function(i, item) { - if (item.name && r.message.hasOwnProperty(item.name)) { + if (item.name && r.message.hasOwnProperty(item.name) && r.message[item.name].item_tax_template) { item.item_tax_template = r.message[item.name].item_tax_template; item.item_tax_rate = r.message[item.name].item_tax_rate; me.add_taxes_from_item_tax_template(item.item_tax_rate); - } else { - item.item_tax_template = ""; - item.item_tax_rate = "{}"; } }); } diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 6dc40f05e7..b3af3d67ea 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -868,9 +868,6 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({ } - if (this.frm.doc.posting_date) var date = this.frm.doc.posting_date; - else var date = this.frm.doc.transaction_date; - if (frappe.meta.get_docfield(this.frm.doctype, "shipping_address") && in_list(['Purchase Order', 'Purchase Receipt', 'Purchase Invoice'], this.frm.doctype)) { erpnext.utils.get_shipping_address(this.frm, function(){ diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js index ef03b01698..6f5d67c746 100644 --- a/erpnext/public/js/setup_wizard.js +++ b/erpnext/public/js/setup_wizard.js @@ -147,7 +147,7 @@ erpnext.setup.slides_settings = [ } // Validate bank name - if(me.values.bank_account){ + if(me.values.bank_account) { frappe.call({ async: false, method: "erpnext.accounts.doctype.account.chart_of_accounts.chart_of_accounts.validate_bank_account", diff --git a/erpnext/public/js/utils/party.js b/erpnext/public/js/utils/party.js index 808dd5add0..a79eadc761 100644 --- a/erpnext/public/js/utils/party.js +++ b/erpnext/public/js/utils/party.js @@ -274,9 +274,9 @@ erpnext.utils.validate_mandatory = function(frm, label, value, trigger_on) { return true; } -erpnext.utils.get_shipping_address = function(frm, callback){ +erpnext.utils.get_shipping_address = function(frm, callback) { if (frm.doc.company) { - if (!(frm.doc.inter_com_order_reference || frm.doc.internal_invoice_reference || + if ((frm.doc.inter_company_order_reference || frm.doc.internal_invoice_reference || frm.doc.internal_order_reference)) { if (callback) { return callback(); diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss index 9402cf9ea4..5962859be5 100644 --- a/erpnext/public/scss/shopping_cart.scss +++ b/erpnext/public/scss/shopping_cart.scss @@ -467,11 +467,15 @@ body.product-page { .btn-change-address { color: var(--blue-500); - box-shadow: none; - border: 1px solid var(--blue-500); } } +.btn-new-address:hover, .btn-change-address:hover { + box-shadow: none; + color: var(--blue-500) !important; + border: 1px solid var(--blue-500); +} + .modal .address-card { .card-body { padding: var(--padding-sm); diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.js b/erpnext/regional/doctype/gst_settings/gst_settings.js index 808f9bc078..cd682c5403 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.js +++ b/erpnext/regional/doctype/gst_settings/gst_settings.js @@ -35,6 +35,7 @@ frappe.ui.form.on('GST Settings', { return { filters: { company: row.company, + account_type: "Tax", is_group: 0 } }; diff --git a/erpnext/regional/doctype/gst_settings/gst_settings.py b/erpnext/regional/doctype/gst_settings/gst_settings.py index bc956e9fa8..af3d92e59a 100644 --- a/erpnext/regional/doctype/gst_settings/gst_settings.py +++ b/erpnext/regional/doctype/gst_settings/gst_settings.py @@ -19,6 +19,21 @@ class GSTSettings(Document): from tabAddress where country = "India" and ifnull(gstin, '')!='' ''') self.set_onload('data', data) + def validate(self): + # Validate duplicate accounts + self.validate_duplicate_accounts() + + def validate_duplicate_accounts(self): + account_list = [] + for account in self.get('gst_accounts'): + for fieldname in ['cgst_account', 'sgst_account', 'igst_account', 'cess_account']: + if account.get(fieldname) in account_list: + frappe.throw(_("Account {0} appears multiple times").format( + frappe.bold(account.get(fieldname)))) + + if account.get(fieldname): + account_list.append(account.get(fieldname)) + @frappe.whitelist() def send_reminder(): frappe.has_permission('GST Settings', throw=True) diff --git a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py index 3857ce1cdb..065f80d610 100644 --- a/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py +++ b/erpnext/regional/doctype/gstr_3b_report/test_gstr_3b_report.py @@ -46,14 +46,14 @@ class TestGSTR3BReport(unittest.TestCase): make_sales_invoice() create_purchase_invoices() - if frappe.db.exists("GSTR 3B Report", "GSTR3B-March-2019-_Test Address-Billing"): - report = frappe.get_doc("GSTR 3B Report", "GSTR3B-March-2019-_Test Address-Billing") + if frappe.db.exists("GSTR 3B Report", "GSTR3B-March-2019-_Test Address GST-Billing"): + report = frappe.get_doc("GSTR 3B Report", "GSTR3B-March-2019-_Test Address GST-Billing") report.save() else: report = frappe.get_doc({ "doctype": "GSTR 3B Report", "company": "_Test Company GST", - "company_address": "_Test Address-Billing", + "company_address": "_Test Address GST-Billing", "year": getdate().year, "month": month_number_mapping.get(getdate().month) }).insert() @@ -89,7 +89,7 @@ class TestGSTR3BReport(unittest.TestCase): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -117,7 +117,7 @@ def make_sales_invoice(): si.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -138,7 +138,7 @@ def make_sales_invoice(): si1.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -159,7 +159,7 @@ def make_sales_invoice(): si2.append("taxes", { "charge_type": "On Net Total", - "account_head": "IGST - _GST", + "account_head": "Output Tax IGST - _GST", "cost_center": "Main - _GST", "description": "IGST @ 18.0", "rate": 18 @@ -195,7 +195,7 @@ def create_purchase_invoices(): pi.append("taxes", { "charge_type": "On Net Total", - "account_head": "CGST - _GST", + "account_head": "Input Tax CGST - _GST", "cost_center": "Main - _GST", "description": "CGST @ 9.0", "rate": 9 @@ -203,7 +203,7 @@ def create_purchase_invoices(): pi.append("taxes", { "charge_type": "On Net Total", - "account_head": "SGST - _GST", + "account_head": "Input Tax SGST - _GST", "cost_center": "Main - _GST", "description": "SGST @ 9.0", "rate": 9 @@ -410,10 +410,10 @@ def make_company(): company.country = "India" company.insert() - if not frappe.db.exists('Address', '_Test Address-Billing'): + if not frappe.db.exists('Address', '_Test Address GST-Billing'): address = frappe.get_doc({ + "address_title": "_Test Address GST", "address_line1": "_Test Address Line 1", - "address_title": "_Test Address", "address_type": "Billing", "city": "_Test City", "state": "Test State", @@ -444,9 +444,9 @@ def set_account_heads(): if not gst_account: gst_settings.append("gst_accounts", { "company": "_Test Company GST", - "cgst_account": "CGST - _GST", - "sgst_account": "SGST - _GST", - "igst_account": "IGST - _GST", + "cgst_account": "Output Tax CGST - _GST", + "sgst_account": "Output Tax SGST - _GST", + "igst_account": "Output Tax IGST - _GST" }) gst_settings.save() diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py index 3e0b9b733b..5f9d5ed0d6 100644 --- a/erpnext/regional/india/setup.py +++ b/erpnext/regional/india/setup.py @@ -25,6 +25,7 @@ def setup_company_independent_fixtures(patch=False): frappe.enqueue('erpnext.regional.india.setup.add_hsn_sac_codes', now=frappe.flags.in_test) create_gratuity_rule() add_print_formats() + update_accounts_settings_for_taxes() def add_hsn_sac_codes(): if frappe.flags.in_test and frappe.flags.created_hsn_codes: @@ -680,7 +681,7 @@ def make_custom_fields(update=True): def make_fixtures(company=None): docs = [] - company = company.name if company else frappe.db.get_value("Global Defaults", None, "default_company") + company = company or frappe.db.get_value("Global Defaults", None, "default_company") set_salary_components(docs) set_tds_account(docs, company) @@ -698,6 +699,53 @@ def make_fixtures(company=None): # create records for Tax Withholding Category set_tax_withholding_category(company) +def update_regional_tax_settings(country, company): + # Will only add default GST accounts if present + input_account_names = ['Input Tax CGST', 'Input Tax SGST', 'Input Tax IGST'] + output_account_names = ['Output Tax CGST', 'Output Tax SGST', 'Output Tax IGST'] + rcm_accounts = ['Input Tax CGST RCM', 'Input Tax SGST RCM', 'Input Tax IGST RCM'] + gst_settings = frappe.get_single('GST Settings') + existing_account_list = [] + + for account in gst_settings.get('gst_accounts'): + for key in ['cgst_account', 'sgst_account', 'igst_account']: + existing_account_list.append(account.get(key)) + + gst_accounts = frappe._dict(frappe.get_all("Account", + {'company': company, 'account_name': ('in', input_account_names + + output_account_names + rcm_accounts)}, ['account_name', 'name'], as_list=1)) + + add_accounts_in_gst_settings(company, input_account_names, gst_accounts, + existing_account_list, gst_settings) + add_accounts_in_gst_settings(company, output_account_names, gst_accounts, + existing_account_list, gst_settings) + add_accounts_in_gst_settings(company, rcm_accounts, gst_accounts, + existing_account_list, gst_settings, is_reverse_charge=1) + + gst_settings.save() + +def add_accounts_in_gst_settings(company, account_names, gst_accounts, + existing_account_list, gst_settings, is_reverse_charge=0): + accounts_not_added = 1 + + for account in account_names: + # Default Account Added does not exists + if not gst_accounts.get(account): + accounts_not_added = 0 + + # Check if already added in GST Settings + if gst_accounts.get(account) in existing_account_list: + accounts_not_added = 0 + + if accounts_not_added: + gst_settings.append('gst_accounts', { + 'company': company, + 'cgst_account': gst_accounts.get(account_names[0]), + 'sgst_account': gst_accounts.get(account_names[1]), + 'igst_account': gst_accounts.get(account_names[2]), + 'is_reverse_charge_account': is_reverse_charge + }) + def set_salary_components(docs): docs.extend([ {'doctype': 'Salary Component', 'salary_component': 'Professional Tax', @@ -731,12 +779,13 @@ def set_tax_withholding_category(company): docs = get_tds_details(accounts, fiscal_year) for d in docs: - try: + if not frappe.db.exists("Tax Withholding Category", d.get("name")): doc = frappe.get_doc(d) + doc.flags.ignore_validate = True doc.flags.ignore_permissions = True doc.flags.ignore_mandatory = True doc.insert() - except frappe.DuplicateEntryError: + else: doc = frappe.get_doc("Tax Withholding Category", d.get("name")) if accounts: @@ -749,11 +798,12 @@ def set_tax_withholding_category(company): doc.append("rates", d.get('rates')[0]) doc.flags.ignore_permissions = True + doc.flags.ignore_validate = True doc.flags.ignore_mandatory = True + doc.flags.ignore_links = True doc.save() def set_tds_account(docs, company): - abbr = frappe.get_value("Company", company, "abbr") parent_account = frappe.db.get_value("Account", filters = {"account_name": "Duties and Taxes", "company": company}) if parent_account: docs.extend([ @@ -912,7 +962,6 @@ def get_tds_details(accounts, fiscal_year): ] def create_gratuity_rule(): - # Standard Indain Gratuity Rule if not frappe.db.exists("Gratuity Rule", "Indian Standard Gratuity Rule"): rule = frappe.new_doc("Gratuity Rule") @@ -930,3 +979,7 @@ def create_gratuity_rule(): rule.flags.ignore_mandatory = True rule.save() + +def update_accounts_settings_for_taxes(): + if frappe.db.count('Company') == 1: + frappe.db.set_value('Accounts Settings', None, "add_taxes_from_item_tax_template", 0) \ No newline at end of file diff --git a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json index 4deb073a53..d0000ad50d 100644 --- a/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json +++ b/erpnext/regional/report/e_invoice_summary/e_invoice_summary.json @@ -11,7 +11,7 @@ "is_standard": "Yes", "json": "{}", "letter_head": "Logo", - "modified": "2021-03-12 12:36:48.689413", + "modified": "2021-03-13 12:36:48.689413", "modified_by": "Administrator", "module": "Regional", "name": "E-Invoice Summary", diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py index 80e2d725a2..10961593e1 100644 --- a/erpnext/regional/report/gstr_1/gstr_1.py +++ b/erpnext/regional/report/gstr_1/gstr_1.py @@ -201,7 +201,7 @@ class Gstr1Report(object): elif self.filters.get("type_of_business") == "EXPORT": conditions += """ AND is_return !=1 and gst_category = 'Overseas' """ - conditions += " AND billing_address_gstin NOT IN %(company_gstins)s" + conditions += " AND IFNULL(billing_address_gstin, '') NOT IN %(company_gstins)s" return conditions diff --git a/erpnext/selling/doctype/product_bundle/product_bundle.py b/erpnext/selling/doctype/product_bundle/product_bundle.py index d3281f733f..ae3482f402 100644 --- a/erpnext/selling/doctype/product_bundle/product_bundle.py +++ b/erpnext/selling/doctype/product_bundle/product_bundle.py @@ -4,6 +4,8 @@ from __future__ import unicode_literals import frappe +from frappe.utils import get_link_to_form + from frappe import _ from frappe.model.document import Document @@ -18,6 +20,27 @@ class ProductBundle(Document): from erpnext.utilities.transaction_base import validate_uom_is_integer validate_uom_is_integer(self, "uom", "qty") + def on_trash(self): + linked_doctypes = ["Delivery Note", "Sales Invoice", "POS Invoice", "Purchase Receipt", "Purchase Invoice", + "Stock Entry", "Stock Reconciliation", "Sales Order", "Purchase Order", "Material Request"] + + invoice_links = [] + for doctype in linked_doctypes: + item_doctype = doctype + " Item" + + if doctype == "Stock Entry": + item_doctype = doctype + " Detail" + + invoices = frappe.db.get_all(item_doctype, {"item_code": self.new_item_code, "docstatus": 1}, ["parent"]) + + for invoice in invoices: + invoice_links.append(get_link_to_form(doctype, invoice['parent'])) + + if len(invoice_links): + frappe.throw( + "This Product Bundle is linked with {0}. You will have to cancel these documents in order to delete this Product Bundle" + .format(", ".join(invoice_links)), title=_("Not Allowed")) + def validate_main_item(self): """Validates, main Item is not a stock item""" if frappe.db.get_value("Item", self.new_item_code, "is_stock_item"): diff --git a/erpnext/selling/doctype/sales_order/sales_order.py b/erpnext/selling/doctype/sales_order/sales_order.py index 551f715bd5..41f57a34d3 100755 --- a/erpnext/selling/doctype/sales_order/sales_order.py +++ b/erpnext/selling/doctype/sales_order/sales_order.py @@ -233,7 +233,7 @@ class SalesOrder(SellingController): # Checks Sales Invoice submit_rv = frappe.db.sql_list("""select t1.name from `tabSales Invoice` t1,`tabSales Invoice Item` t2 - where t1.name = t2.parent and t2.sales_order = %s and t1.docstatus = 1""", + where t1.name = t2.parent and t2.sales_order = %s and t1.docstatus < 2""", self.name) if submit_rv: diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py index 987371066a..974648d6d4 100644 --- a/erpnext/selling/doctype/sales_order/test_sales_order.py +++ b/erpnext/selling/doctype/sales_order/test_sales_order.py @@ -1217,6 +1217,19 @@ class TestSalesOrder(unittest.TestCase): # To test if the SO does NOT have a Blanket Order self.assertEqual(so_doc.items[0].blanket_order, None) + def test_so_cancellation_when_si_drafted(self): + """ + Test to check if Sales Order gets cancelled if Sales Invoice is in Draft state + Expected result: sales order should not get cancelled + """ + so = make_sales_order() + so.submit() + si = make_sales_invoice(so.name) + si.save() + + self.assertRaises(frappe.ValidationError, so.cancel) + + def make_sales_order(**args): so = frappe.new_doc("Sales Order") diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index ae3f9e3c9d..c827368dbf 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -241,8 +241,8 @@ erpnext.PointOfSale.Controller = class { events: { get_frm: () => this.frm, - cart_item_clicked: (item_code, batch_no, uom, rate) => { - const item_row = this.get_item_from_frm(item_code, batch_no, uom, rate); + cart_item_clicked: (item) => { + const item_row = this.get_item_from_frm(item); this.item_details.toggle_item_details_section(item_row); }, @@ -273,17 +273,15 @@ erpnext.PointOfSale.Controller = class { this.cart.toggle_numpad(minimize); }, - form_updated: (cdt, cdn, fieldname, value) => { - const item_row = frappe.model.get_doc(cdt, cdn); - if (item_row && item_row[fieldname] != value) { - - const { item_code, batch_no, uom, rate } = this.item_details.current_item; - const event = { - field: fieldname, + form_updated: (item, field, value) => { + const item_row = frappe.model.get_doc(item.doctype, item.name); + if (item_row && item_row[field] != value) { + const args = { + field, value, - item: { item_code, batch_no, uom, rate } - } - return this.on_cart_update(event) + item: this.item_details.current_item + }; + return this.on_cart_update(args); } return Promise.resolve(); @@ -300,19 +298,18 @@ erpnext.PointOfSale.Controller = class { set_value_in_current_cart_item: (selector, value) => { this.cart.update_selector_value_in_cart_item(selector, value, this.item_details.current_item); }, - clone_new_batch_item_in_frm: (batch_serial_map, current_item) => { + clone_new_batch_item_in_frm: (batch_serial_map, item) => { // called if serial nos are 'auto_selected' and if those serial nos belongs to multiple batches // for each unique batch new item row is added in the form & cart Object.keys(batch_serial_map).forEach(batch => { - const { item_code, batch_no } = current_item; - const item_to_clone = this.frm.doc.items.find(i => i.item_code === item_code && i.batch_no === batch_no); + const item_to_clone = this.frm.doc.items.find(i => i.name == item.name); const new_row = this.frm.add_child("items", { ...item_to_clone }); // update new serialno and batch new_row.batch_no = batch; new_row.serial_no = batch_serial_map[batch].join(`\n`); new_row.qty = batch_serial_map[batch].length; this.frm.doc.items.forEach(row => { - if (item_code === row.item_code) { + if (item.item_code === row.item_code) { this.update_cart_html(row); } }); @@ -321,8 +318,8 @@ erpnext.PointOfSale.Controller = class { remove_item_from_cart: () => this.remove_item_from_cart(), get_item_stock_map: () => this.item_stock_map, close_item_details: () => { - this.item_details.toggle_item_details_section(undefined); - this.cart.prev_action = undefined; + this.item_details.toggle_item_details_section(null); + this.cart.prev_action = null; this.cart.toggle_item_highlight(); }, get_available_stock: (item_code, warehouse) => this.get_available_stock(item_code, warehouse) @@ -506,50 +503,47 @@ erpnext.PointOfSale.Controller = class { let item_row = undefined; try { let { field, value, item } = args; - const { item_code, batch_no, serial_no, uom, rate } = item; - item_row = this.get_item_from_frm(item_code, batch_no, uom, rate); + item_row = this.get_item_from_frm(item); + const item_row_exists = !$.isEmptyObject(item_row); - const item_selected_from_selector = field === 'qty' && value === "+1" + const from_selector = field === 'qty' && value === "+1"; + if (from_selector) + value = flt(item_row.qty) + flt(value); - if (item_row) { - item_selected_from_selector && (value = item_row.qty + flt(value)) - - field === 'qty' && (value = flt(value)); + if (item_row_exists) { + if (field === 'qty') + value = flt(value); if (['qty', 'conversion_factor'].includes(field) && value > 0 && !this.allow_negative_stock) { const qty_needed = field === 'qty' ? value * item_row.conversion_factor : item_row.qty * value; await this.check_stock_availability(item_row, qty_needed, this.frm.doc.set_warehouse); } - if (this.is_current_item_being_edited(item_row) || item_selected_from_selector) { + if (this.is_current_item_being_edited(item_row) || from_selector) { await frappe.model.set_value(item_row.doctype, item_row.name, field, value); this.update_cart_html(item_row); } } else { - if (!this.frm.doc.customer) { - frappe.dom.unfreeze(); - frappe.show_alert({ - message: __('You must select a customer before adding an item.'), - indicator: 'orange' - }); - frappe.utils.play_sound("error"); + if (!this.frm.doc.customer) + return this.raise_customer_selection_alert(); + + const { item_code, batch_no, serial_no, rate } = item; + + if (!item_code) return; - } - if (!item_code) return; - item_selected_from_selector && (value = flt(value)) - - const args = { item_code, batch_no, rate, [field]: value }; + const new_item = { item_code, batch_no, rate, [field]: value }; if (serial_no) { await this.check_serial_no_availablilty(item_code, this.frm.doc.set_warehouse, serial_no); - args['serial_no'] = serial_no; + new_item['serial_no'] = serial_no; } - if (field === 'serial_no') args['qty'] = value.split(`\n`).length || 0; + if (field === 'serial_no') + new_item['qty'] = value.split(`\n`).length || 0; - item_row = this.frm.add_child('items', args); + item_row = this.frm.add_child('items', new_item); if (field === 'qty' && value !== 0 && !this.allow_negative_stock) await this.check_stock_availability(item_row, value, this.frm.doc.set_warehouse); @@ -558,8 +552,11 @@ erpnext.PointOfSale.Controller = class { this.update_cart_html(item_row); - this.item_details.$component.is(':visible') && this.edit_item_details_of(item_row); - this.check_serial_batch_selection_needed(item_row) && this.edit_item_details_of(item_row); + if (this.item_details.$component.is(':visible')) + this.edit_item_details_of(item_row); + + if (this.check_serial_batch_selection_needed(item_row)) + this.edit_item_details_of(item_row); } } catch (error) { @@ -570,14 +567,33 @@ erpnext.PointOfSale.Controller = class { } } - get_item_from_frm(item_code, batch_no, uom, rate) { - const has_batch_no = batch_no; - return this.frm.doc.items.find( - i => i.item_code === item_code - && (!has_batch_no || (has_batch_no && i.batch_no === batch_no)) - && (i.uom === uom) - && (i.rate == rate) - ); + raise_customer_selection_alert() { + frappe.dom.unfreeze(); + frappe.show_alert({ + message: __('You must select a customer before adding an item.'), + indicator: 'orange' + }); + frappe.utils.play_sound("error"); + } + + get_item_from_frm({ name, item_code, batch_no, uom, rate }) { + let item_row = null; + if (name) { + item_row = this.frm.doc.items.find(i => i.name == name); + } else { + // if item is clicked twice from item selector + // then "item_code, batch_no, uom, rate" will help in getting the exact item + // to increase the qty by one + const has_batch_no = batch_no; + item_row = this.frm.doc.items.find( + i => i.item_code === item_code + && (!has_batch_no || (has_batch_no && i.batch_no === batch_no)) + && (i.uom === uom) + && (i.rate == rate) + ); + } + + return item_row || {}; } edit_item_details_of(item_row) { @@ -585,9 +601,7 @@ erpnext.PointOfSale.Controller = class { } is_current_item_being_edited(item_row) { - const { item_code, batch_no } = this.item_details.current_item; - - return item_code !== item_row.item_code || batch_no != item_row.batch_no ? false : true; + return item_row.name == this.item_details.current_item.name; } update_cart_html(item_row, remove_item) { @@ -669,7 +683,7 @@ erpnext.PointOfSale.Controller = class { update_item_field(value, field_or_action) { if (field_or_action === 'checkout') { - this.item_details.toggle_item_details_section(undefined); + this.item_details.toggle_item_details_section(null); } else if (field_or_action === 'remove') { this.remove_item_from_cart(); } else { @@ -688,7 +702,7 @@ erpnext.PointOfSale.Controller = class { .then(() => { frappe.model.clear_doc(doctype, name); this.update_cart_html(current_item, true); - this.item_details.toggle_item_details_section(undefined); + this.item_details.toggle_item_details_section(null); frappe.dom.unfreeze(); }) .catch(e => console.log(e)); 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 f5019f5083..7cae0e4797 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -181,11 +181,8 @@ erpnext.PointOfSale.ItemCart = class { me.$totals_section.find(".edit-cart-btn").click(); } - const item_code = unescape($cart_item.attr('data-item-code')); - const batch_no = unescape($cart_item.attr('data-batch-no')); - const uom = unescape($cart_item.attr('data-uom')); - const rate = unescape($cart_item.attr('data-rate')); - me.events.cart_item_clicked(item_code, batch_no, uom, rate); + const item_row_name = unescape($cart_item.attr('data-row-name')); + me.events.cart_item_clicked({ name: item_row_name }); this.numpad_value = ''; }); @@ -521,25 +518,14 @@ erpnext.PointOfSale.ItemCart = class { } } - get_cart_item({ item_code, batch_no, uom, rate }) { - const batch_attr = `[data-batch-no="${escape(batch_no)}"]`; - const item_code_attr = `[data-item-code="${escape(item_code)}"]`; - const uom_attr = `[data-uom="${escape(uom)}"]`; - const rate_attr = `[data-rate="${escape(rate)}"]`; - - const item_selector = batch_no ? - `.cart-item-wrapper${batch_attr}${uom_attr}${rate_attr}` : `.cart-item-wrapper${item_code_attr}${uom_attr}${rate_attr}`; - + get_cart_item({ name }) { + const item_selector = `.cart-item-wrapper[data-row-name="${escape(name)}"]`; return this.$cart_items_wrapper.find(item_selector); } get_item_from_frm(item) { const doc = this.events.get_frm().doc; - const { item_code, batch_no, uom, rate } = item; - const search_field = batch_no ? 'batch_no' : 'item_code'; - const search_value = batch_no || item_code; - - return doc.items.find(i => i[search_field] === search_value && i.uom === uom && i.rate === rate); + return doc.items.find(i => i.name == item.name); } update_item_html(item, remove_item) { @@ -564,10 +550,7 @@ erpnext.PointOfSale.ItemCart = class { if (!$item_to_update.length) { this.$cart_items_wrapper.append( - `
-
+ `
` ) $item_to_update = this.get_cart_item(item_data); @@ -642,7 +625,7 @@ erpnext.PointOfSale.ItemCart = class { function get_item_image_html() { const { image, item_name } = item_data; - if (image) { + if (!me.hide_images && image) { return `
{ - const item_row = frappe.get_doc(me.doctype, me.name); - const doc = me.events.get_frm().doc; - me.$item_price.html(format_currency(item_row.rate, doc.currency)); - me.render_discount_dom(item_row); - }); - me.current_item.rate = this.value; - } - }; - } else { - this.rate_control.df.read_only = 1; - } + this.rate_control.df.onchange = function() { + if (this.value || flt(this.value) === 0) { + me.events.form_updated(me.current_item, 'rate', this.value).then(() => { + const item_row = frappe.get_doc(me.doctype, me.name); + const doc = me.events.get_frm().doc; + me.$item_price.html(format_currency(item_row.rate, doc.currency)); + me.render_discount_dom(item_row); + }); + } + }; + this.rate_control.df.read_only = !this.allow_rate_change; this.rate_control.refresh(); } @@ -246,7 +234,7 @@ erpnext.PointOfSale.ItemDetails = class { this.warehouse_control.df.reqd = 1; this.warehouse_control.df.onchange = function() { if (this.value) { - me.events.form_updated(me.doctype, me.name, 'warehouse', this.value).then(() => { + me.events.form_updated(me.current_item, 'warehouse', this.value).then(() => { me.item_stock_map = me.events.get_item_stock_map(); const available_qty = me.item_stock_map[me.item_row.item_code][this.value]; if (available_qty === undefined) { @@ -278,7 +266,7 @@ erpnext.PointOfSale.ItemDetails = class { this.serial_no_control.df.reqd = 1; this.serial_no_control.df.onchange = async function() { !me.current_item.batch_no && await me.auto_update_batch_no(); - me.events.form_updated(me.doctype, me.name, 'serial_no', this.value); + me.events.form_updated(me.current_item, 'serial_no', this.value); } this.serial_no_control.refresh(); } @@ -295,19 +283,12 @@ erpnext.PointOfSale.ItemDetails = class { } } }; - this.batch_no_control.df.onchange = function() { - me.events.set_value_in_current_cart_item('batch-no', this.value); - me.events.form_updated(me.doctype, me.name, 'batch_no', this.value); - me.current_item.batch_no = this.value; - } this.batch_no_control.refresh(); } if (this.uom_control) { this.uom_control.df.onchange = function() { - me.events.set_value_in_current_cart_item('uom', this.value); - me.events.form_updated(me.doctype, me.name, 'uom', this.value); - me.current_item.uom = this.value; + me.events.form_updated(me.current_item, 'uom', this.value); const item_row = frappe.get_doc(me.doctype, me.name); me.conversion_factor_control.df.read_only = (item_row.stock_uom == this.value); @@ -317,9 +298,9 @@ erpnext.PointOfSale.ItemDetails = class { frappe.model.on("POS Invoice Item", "*", (fieldname, value, item_row) => { const field_control = this[`${fieldname}_control`]; - const item_is_same = !this.has_item_has_changed(item_row); + const item_row_is_being_edited = this.compare_with_current_item(item_row); - if (item_is_same && field_control && field_control.get_value() !== value) { + if (item_row_is_being_edited && field_control && field_control.get_value() !== value) { field_control.set_value(value); cur_pos.update_cart_html(item_row); } @@ -337,7 +318,9 @@ erpnext.PointOfSale.ItemDetails = class { fields: ["batch_no", "name"] }); const batch_serial_map = serials_with_batch_no.reduce((acc, r) => { - acc[r.batch_no] || (acc[r.batch_no] = []); + if (!acc[r.batch_no]) { + acc[r.batch_no] = []; + } acc[r.batch_no] = [...acc[r.batch_no], r.name]; return acc; }, {}); @@ -353,12 +336,10 @@ erpnext.PointOfSale.ItemDetails = class { if (serial_nos_belongs_to_other_batch) { this.serial_no_control.set_value(batch_serial_nos); this.qty_control.set_value(batch_serial_map[batch_no].length); - } - delete batch_serial_map[batch_no]; - - if (serial_nos_belongs_to_other_batch) + delete batch_serial_map[batch_no]; this.events.clone_new_batch_item_in_frm(batch_serial_map, this.current_item); + } } } 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 64c529ee4a..dd7f143c4c 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -232,7 +232,11 @@ erpnext.PointOfSale.ItemSelector = class { uom = uom === "undefined" ? undefined : uom; rate = rate === "undefined" ? undefined : rate; - me.events.item_selected({ field: 'qty', value: "+1", item: { item_code, batch_no, serial_no, uom, rate }}); + me.events.item_selected({ + field: 'qty', + value: "+1", + item: { item_code, batch_no, serial_no, uom, rate } + }); me.set_search_value(''); }); 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 f5feb95f1a..8cb24460f7 100644 --- a/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py +++ b/erpnext/selling/report/sales_order_analysis/sales_order_analysis.py @@ -59,7 +59,7 @@ def get_data(conditions, filters): IF(so.status in ('Completed','To Bill'), 0, (SELECT delay_days)) as delay, soi.qty, soi.delivered_qty, (soi.qty - soi.delivered_qty) AS pending_qty, - IFNULL(sii.qty, 0) as billed_qty, + IFNULL(SUM(sii.qty), 0) as billed_qty, soi.base_amount as amount, (soi.delivered_qty * soi.base_rate) as delivered_qty_amount, (soi.billed_amt * IFNULL(so.conversion_rate, 1)) as billed_amount, diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py index 27e023c1e5..915e6a4f31 100644 --- a/erpnext/setup/doctype/company/company.py +++ b/erpnext/setup/doctype/company/company.py @@ -110,7 +110,7 @@ class Company(NestedSet): self.create_default_warehouses() if frappe.flags.country_change: - install_country_fixtures(self.name) + install_country_fixtures(self.name, self.country) self.create_default_tax_template() if not frappe.db.get_value("Department", {"company": self.name}): @@ -407,8 +407,6 @@ def replace_abbr(company, old, new): frappe.only_for("System Manager") - frappe.db.set_value("Company", company, "abbr", new) - def _rename_record(doc): parts = doc[0].rsplit(" - ", 1) if len(parts) == 1 or parts[1].lower() == old.lower(): @@ -419,11 +417,18 @@ def replace_abbr(company, old, new): doc = (d for d in frappe.db.sql("select name from `tab%s` where company=%s" % (dt, '%s'), company)) for d in doc: _rename_record(d) + try: + frappe.db.auto_commit_on_many_writes = 1 + frappe.db.set_value("Company", company, "abbr", new) + for dt in ["Warehouse", "Account", "Cost Center", "Department", + "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template"]: + _rename_records(dt) + frappe.db.commit() - for dt in ["Warehouse", "Account", "Cost Center", "Department", - "Sales Taxes and Charges Template", "Purchase Taxes and Charges Template"]: - _rename_records(dt) - frappe.db.commit() + except Exception: + frappe.log_error(title=_('Abbreviation Rename Error')) + finally: + frappe.db.auto_commit_on_many_writes = 0 def get_name_with_abbr(name, company): @@ -435,16 +440,15 @@ def get_name_with_abbr(name, company): return " - ".join(parts) -def install_country_fixtures(company): - company_doc = frappe.get_doc("Company", company) - path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(company_doc.country)) +def install_country_fixtures(company, country): + path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) if os.path.exists(path.encode("utf-8")): try: - module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(company_doc.country)) - frappe.get_attr(module_name)(company_doc, False) + module_name = "erpnext.regional.{0}.setup.setup".format(frappe.scrub(country)) + frappe.get_attr(module_name)(company, False) except Exception as e: frappe.log_error() - frappe.throw(_("Failed to setup defaults for country {0}. Please contact support@erpnext.com").format(frappe.bold(company_doc.country))) + frappe.throw(_("Failed to setup defaults for country {0}. Please contact support@erpnext.com").format(frappe.bold(country))) def update_company_current_month_sales(company): diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py index db31d6d699..1c72cebfa9 100644 --- a/erpnext/setup/doctype/item_group/item_group.py +++ b/erpnext/setup/doctype/item_group/item_group.py @@ -91,7 +91,7 @@ class ItemGroup(NestedSet, WebsiteGenerator): field_filters['item_group'] = self.name engine = ProductQuery() - context.items = engine.query(attribute_filters, field_filters, search, start) + context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name) filter_engine = ProductFiltersBuilder(self.name) diff --git a/erpnext/setup/setup_wizard/data/country_wise_tax.json b/erpnext/setup/setup_wizard/data/country_wise_tax.json index daaa626a81..34af093a23 100644 --- a/erpnext/setup/setup_wizard/data/country_wise_tax.json +++ b/erpnext/setup/setup_wizard/data/country_wise_tax.json @@ -1164,33 +1164,292 @@ }, "India": { + "tax_categories": [ + { + "title": "In-State", + "is_inter_state": 0, + "gst_state": "" + }, + { + "title": "Out-State", + "is_inter_state": 1, + "gst_state": "" + }, + { + "title": "Reverse Charge In-State", + "is_inter_state": 0, + "gst_state": "" + }, + { + "title": "Reverse Charge Out-State", + "is_inter_state": 1, + "gst_state": "" + }, + { + "title": "Registered Composition", + "is_inter_state": 0, + "gst_state": "" + } + ], "chart_of_accounts": { "*": { "item_tax_templates": [ { - "title": "In State GST", + "title": "GST 9%", "taxes": [ { "tax_type": { - "account_name": "SGST", + "account_name": "Output Tax SGST", "tax_rate": 9.00 } }, { "tax_type": { - "account_name": "CGST", + "account_name": "Output Tax CGST", "tax_rate": 9.00 } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 18.00 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 18.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 9.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 18.00, + "root_type": "Asset" + } } ] }, { - "title": "Out of State GST", + "title": "GST 5%", "taxes": [ { "tax_type": { - "account_name": "IGST", - "tax_rate": 18.00 + "account_name": "Output Tax SGST", + "tax_rate": 2.5 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 2.5 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 5.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 2.5, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 2.5, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 5.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 2.50, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 2.50, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 5.00, + "root_type": "Asset" + } + } + ] + }, + { + "title": "GST 12%", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 6.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 6.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 12.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 6.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 6.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 12.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 6.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 6.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 12.00, + "root_type": "Asset" + } + } + ] + }, + { + "title": "GST 28%", + "taxes": [ + { + "tax_type": { + "account_name": "Output Tax SGST", + "tax_rate": 14.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax CGST", + "tax_rate": 14.0 + } + }, + { + "tax_type": { + "account_name": "Output Tax IGST", + "tax_rate": 28.0 + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST", + "tax_rate": 14.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST", + "tax_rate": 14.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST", + "tax_rate": 28.0, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 14.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 14.00, + "root_type": "Asset" + } + }, + { + "tax_type": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 28.00, + "root_type": "Asset" } } ] @@ -1229,35 +1488,116 @@ ] } ], - "*": [ + "sales_tax_templates": [ { - "title": "In State GST", + "title": "Output GST In-state", "taxes": [ { "account_head": { - "account_name": "SGST", - "tax_rate": 9.00 + "account_name": "Output Tax SGST", + "tax_rate": 9.00, + "account_type": "Tax" } }, { "account_head": { - "account_name": "CGST", - "tax_rate": 9.00 + "account_name": "Output Tax CGST", + "tax_rate": 9.00, + "account_type": "Tax" } } - ] + ], + "tax_category": "In-State" }, { - "title": "Out of State GST", + "title": "Output GST Out-state", "taxes": [ { "account_head": { - "account_name": "IGST", - "tax_rate": 18.00 + "account_name": "Output Tax IGST", + "tax_rate": 18.00, + "account_type": "Tax" } } - ] + ], + "tax_category": "Out-State" + } + ], + "purchase_tax_templates": [ + { + "title": "Input GST In-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax SGST", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + }, + { + "account_head": { + "account_name": "Input Tax CGST", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "In-State" }, + { + "title": "Input GST Out-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax IGST", + "tax_rate": 18.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Out-State" + }, + { + "title": "Input GST RCM In-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax SGST RCM", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + }, + { + "account_head": { + "account_name": "Input Tax CGST RCM", + "tax_rate": 9.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Reverse Charge In-State" + }, + { + "title": "Input GST RCM Out-state", + "taxes": [ + { + "account_head": { + "account_name": "Input Tax IGST RCM", + "tax_rate": 18.00, + "root_type": "Asset", + "account_type": "Tax" + } + } + ], + "tax_category": "Reverse Charge Out-State" + } + ], + "*": [ { "title": "VAT 5%", "taxes": [ @@ -1349,7 +1689,7 @@ "Italy VAT 4%":{ "account_name": "IVA 4%", "tax_rate": 4.00 - } + } }, "Ivory Coast": { diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py index 3f0bb14649..4edf9485dc 100644 --- a/erpnext/setup/setup_wizard/operations/company_setup.py +++ b/erpnext/setup/setup_wizard/operations/company_setup.py @@ -42,29 +42,6 @@ def enable_shopping_cart(args): 'quotation_series': "QTN-", }).insert() -def create_bank_account(args): - if args.get("bank_account"): - company_name = args.get('company_name') - bank_account_group = frappe.db.get_value("Account", - {"account_type": "Bank", "is_group": 1, "root_type": "Asset", - "company": company_name}) - if bank_account_group: - bank_account = frappe.get_doc({ - "doctype": "Account", - 'account_name': args.get("bank_account"), - 'parent_account': bank_account_group, - 'is_group':0, - 'company': company_name, - "account_type": "Bank", - }) - try: - return bank_account.insert() - except RootNotEditable: - frappe.throw(_("Bank account cannot be named as {0}").format(args.get("bank_account"))) - except frappe.DuplicateEntryError: - # bank account same as a CoA entry - pass - def create_email_digest(): from frappe.utils.user import get_system_managers system_managers = get_system_managers(only_name=True) diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 5c725d332d..3dcb63867c 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -449,6 +449,8 @@ def install_defaults(args=None): set_active_domains(args) update_stock_settings() update_shopping_cart_settings(args) + + args.update({"set_default": 1}) create_bank_account(args) def set_global_defaults(args): @@ -480,17 +482,17 @@ def update_stock_settings(): stock_settings.save() def create_bank_account(args): - if not args.bank_account: + if not args.get('bank_account'): return - company_name = args.company_name + company_name = args.get('company_name') bank_account_group = frappe.db.get_value("Account", {"account_type": "Bank", "is_group": 1, "root_type": "Asset", "company": company_name}) if bank_account_group: bank_account = frappe.get_doc({ "doctype": "Account", - 'account_name': args.bank_account, + 'account_name': args.get('bank_account'), 'parent_account': bank_account_group, 'is_group':0, 'company': company_name, @@ -499,10 +501,13 @@ def create_bank_account(args): try: doc = bank_account.insert() - frappe.db.set_value("Company", args.company_name, "default_bank_account", bank_account.name, update_modified=False) + if args.get('set_default'): + frappe.db.set_value("Company", args.get('company_name'), "default_bank_account", bank_account.name, update_modified=False) + + return doc except RootNotEditable: - frappe.throw(_("Bank account cannot be named as {0}").format(args.bank_account)) + frappe.throw(_("Bank account cannot be named as {0}").format(args.get('bank_account'))) except frappe.DuplicateEntryError: # bank account same as a CoA entry pass diff --git a/erpnext/setup/setup_wizard/operations/taxes_setup.py b/erpnext/setup/setup_wizard/operations/taxes_setup.py index f4fe18e116..c7cc000518 100644 --- a/erpnext/setup/setup_wizard/operations/taxes_setup.py +++ b/erpnext/setup/setup_wizard/operations/taxes_setup.py @@ -27,6 +27,7 @@ def setup_taxes_and_charges(company_name: str, country: str): country_wise_tax = simple_to_detailed(country_wise_tax) from_detailed_data(company_name, country_wise_tax) + update_regional_tax_settings(country, company_name) def simple_to_detailed(templates): @@ -101,6 +102,17 @@ def from_detailed_data(company_name, data): make_item_tax_template(company_name, template) +def update_regional_tax_settings(country, company): + path = frappe.get_app_path('erpnext', 'regional', frappe.scrub(country)) + if os.path.exists(path.encode("utf-8")): + try: + module_name = "erpnext.regional.{0}.setup.update_regional_tax_settings".format(frappe.scrub(country)) + frappe.get_attr(module_name)(country, company) + except Exception as e: + # Log error and ignore if failed to setup regional tax settings + frappe.log_error() + pass + def make_taxes_and_charges_template(company_name, doctype, template): template['company'] = company_name template['doctype'] = doctype @@ -130,8 +142,14 @@ def make_taxes_and_charges_template(company_name, doctype, template): if fieldname not in tax_row: tax_row[fieldname] = default_value - return frappe.get_doc(template).insert(ignore_permissions=True) + doc = frappe.get_doc(template) + # Data in country wise json is already pre validated, hence validations can be ignored + # Ingone validations to make doctypes faster + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) + return doc def make_item_tax_template(company_name, template): """Create an Item Tax Template. @@ -156,8 +174,24 @@ def make_item_tax_template(company_name, template): if 'tax_rate' not in tax_row: tax_row['tax_rate'] = account_data.get('tax_rate') - return frappe.get_doc(template).insert(ignore_permissions=True) + doc = frappe.get_doc(template) + # Data in country wise json is already pre validated, hence validations can be ignored + # Ingone validations to make doctypes faster + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) + return doc + +def make_tax_category(tax_category): + """ Make tax category based on title if not already created """ + doctype = 'Tax Category' + if not frappe.db.exists(doctype, tax_category['title']): + tax_category['doctype'] = doctype + doc = frappe.get_doc(tax_category) + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True) def get_or_create_account(company_name, account): """ @@ -175,8 +209,7 @@ def get_or_create_account(company_name, account): or_filters={ 'account_name': account.get('account_name'), 'account_number': account.get('account_number') - } - ) + }) if existing_accounts: return frappe.get_doc('Account', existing_accounts[0].name) @@ -191,8 +224,11 @@ def get_or_create_account(company_name, account): account['root_type'] = root_type account['is_group'] = 0 - return frappe.get_doc(account).insert(ignore_permissions=True, ignore_mandatory=True) - + doc = frappe.get_doc(account) + doc.flags.ignore_links = True + doc.flags.ignore_validate = True + doc.insert(ignore_permissions=True, ignore_mandatory=True) + return doc def get_or_create_tax_group(company_name, root_type): # Look for a group account of type 'Tax' @@ -237,7 +273,11 @@ def get_or_create_tax_group(company_name, root_type): 'account_type': 'Tax', 'account_name': account_name, 'parent_account': root_account.name - }).insert(ignore_permissions=True) + }) + + tax_group_account.flags.ignore_links = True + tax_group_account.flags.ignore_validate = True + tax_group_account.insert(ignore_permissions=True) tax_group_name = tax_group_account.name diff --git a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json index 014f4095c1..6ca3d637da 100644 --- a/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json +++ b/erpnext/setup/workspace/erpnext_settings/erpnext_settings.json @@ -11,10 +11,11 @@ "hide_custom": 0, "icon": "settings", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "ERPNext Settings", "links": [], - "modified": "2020-12-01 13:38:37.759596", + "modified": "2021-06-12 01:58:11.399566", "modified_by": "Administrator", "module": "Setup", "name": "ERPNext Settings", @@ -109,6 +110,13 @@ "label": "Domain Settings", "link_to": "Domain Settings", "type": "DocType" + }, + { + "doc_view": "", + "icon": "retail", + "label": "Products Settings", + "link_to": "Products Settings", + "type": "DocType" } ] -} \ No newline at end of file +} diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py index 6c63d8759b..7dfa09e2d6 100644 --- a/erpnext/shopping_cart/filters.py +++ b/erpnext/shopping_cart/filters.py @@ -22,12 +22,15 @@ class ProductFiltersBuilder: filter_data = [] for df in fields: - filters = {} + filters, or_filters = {}, [] if df.fieldtype == "Link": if self.item_group: - filters['item_group'] = self.item_group + or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] + ]) - values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, distinct="True", pluck=df.fieldname) + values = frappe.get_all("Item", fields=[df.fieldname], filters=filters, or_filters=or_filters, distinct="True", pluck=df.fieldname) else: doctype = df.get_link_doctype() @@ -44,7 +47,9 @@ class ProductFiltersBuilder: values = [d.name for d in frappe.get_all(doctype, filters)] # Remove None - values = values.remove(None) if None in values else values + if None in values: + values.remove(None) + if values: filter_data.append([df, values]) @@ -61,14 +66,18 @@ class ProductFiltersBuilder: for attr_doc in attribute_docs: selected_attributes = [] for attr in attr_doc.item_attribute_values: + or_filters = [] filters= [ ["Item Variant Attribute", "attribute", "=", attr.parent], ["Item Variant Attribute", "attribute_value", "=", attr.attribute_value] ] if self.item_group: - filters.append(["item_group", "=", self.item_group]) + or_filters.extend([ + ["item_group", "=", self.item_group], + ["Website Item Group", "item_group", "=", self.item_group] + ]) - if frappe.db.get_all("Item", filters, limit=1): + if frappe.db.get_all("Item", filters, or_filters=or_filters, limit=1): selected_attributes.append(attr) if selected_attributes: diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py index 36d446ed0f..6c92d967d0 100644 --- a/erpnext/shopping_cart/product_query.py +++ b/erpnext/shopping_cart/product_query.py @@ -22,13 +22,14 @@ class ProductQuery: self.settings = frappe.get_doc("Products Settings") self.cart_settings = frappe.get_doc("Shopping Cart Settings") self.page_length = self.settings.products_per_page or 20 - self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', 'item_group', 'image', 'web_long_description', 'description', 'route'] + self.fields = ['name', 'item_name', 'item_code', 'website_image', 'variant_of', 'has_variants', + 'item_group', 'image', 'web_long_description', 'description', 'route', 'weightage'] self.filters = [] self.or_filters = [['show_in_website', '=', 1]] if not self.settings.get('hide_variants'): self.or_filters.append(['show_variant_in_website', '=', 1]) - def query(self, attributes=None, fields=None, search_term=None, start=0): + def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None): """Summary Args: @@ -44,6 +45,15 @@ class ProductQuery: if search_term: self.build_search_filters(search_term) result = [] + website_item_groups = [] + + # if from item group page consider website item group table + if item_group: + website_item_groups = frappe.db.get_all( + "Item", + fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"], + filters=[["Website Item Group", "item_group", "=", item_group]] + ) if attributes: all_items = [] @@ -61,22 +71,39 @@ class ProductQuery: ], or_filters=self.or_filters, start=start, - limit=self.page_length + limit=self.page_length, + order_by="weightage desc" ) items_dict = {item.name: item for item in items} - # TODO: Replace Variants by their parent templates all_items.append(set(items_dict.keys())) result = [items_dict.get(item) for item in list(set.intersection(*all_items))] else: - result = frappe.get_all("Item", fields=self.fields, filters=self.filters, or_filters=self.or_filters, start=start, limit=self.page_length) + result = frappe.get_all( + "Item", + fields=self.fields, + filters=self.filters, + or_filters=self.or_filters, + start=start, + limit=self.page_length, + order_by="weightage desc" + ) + + # Combine results having context of website item groups into item results + if item_group and website_item_groups: + items_list = {row.name for row in result} + for row in website_item_groups: + if row.wig_parent not in items_list: + result.append(row) + + result = sorted(result, key=lambda x: x.get("weightage"), reverse=True) for item in result: product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info') if product_info: - item.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None + item.formatted_price = (product_info.get('price') or {}).get('formatted_price') return result @@ -90,7 +117,16 @@ class ProductQuery: if not values: continue - if isinstance(values, list): + # handle multiselect fields in filter addition + meta = frappe.get_meta('Item', cached=True) + df = meta.get_field(field) + if df.fieldtype == 'Table MultiSelect': + child_doctype = df.options + child_meta = frappe.get_meta(child_doctype, cached=True) + fields = child_meta.get("fields") + if fields: + self.filters.append([child_doctype, fields[0].fieldname, 'IN', values]) + elif isinstance(values, list): # If value is a list use `IN` query self.filters.append([field, 'IN', values]) else: diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json index 943cb3401f..fc4cf1dbdb 100644 --- a/erpnext/stock/doctype/batch/batch.json +++ b/erpnext/stock/doctype/batch/batch.json @@ -1,4 +1,5 @@ { + "actions": [], "allow_import": 1, "autoname": "field:batch_id", "creation": "2013-03-05 14:50:38", @@ -25,7 +26,11 @@ "reference_doctype", "reference_name", "section_break_7", - "description" + "description", + "manufacturing_section", + "qty_to_produce", + "column_break_23", + "produced_qty" ], "fields": [ { @@ -160,13 +165,35 @@ "label": "Batch UOM", "options": "UOM", "read_only": 1 + }, + { + "fieldname": "manufacturing_section", + "fieldtype": "Section Break", + "label": "Manufacturing" + }, + { + "fieldname": "qty_to_produce", + "fieldtype": "Float", + "label": "Qty To Produce", + "read_only": 1 + }, + { + "fieldname": "column_break_23", + "fieldtype": "Column Break" + }, + { + "fieldname": "produced_qty", + "fieldtype": "Float", + "label": "Produced Qty", + "read_only": 1 } ], "icon": "fa fa-archive", "idx": 1, "image_field": "image", + "links": [], "max_attachments": 5, - "modified": "2020-09-18 17:26:09.703215", + "modified": "2021-07-08 16:22:01.343105", "modified_by": "Administrator", "module": "Stock", "name": "Batch", @@ -190,5 +217,6 @@ "quick_entry": 1, "sort_field": "modified", "sort_order": "DESC", - "title_field": "batch_id" + "title_field": "batch_id", + "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py index 508e17c340..b6eef6ca48 100644 --- a/erpnext/stock/doctype/batch/batch.py +++ b/erpnext/stock/doctype/batch/batch.py @@ -226,13 +226,12 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): return batch.name -def set_batch_nos(doc, warehouse_field, throw=False): +def set_batch_nos(doc, warehouse_field, throw=False, child_table="items"): """Automatically select `batch_no` for outgoing items in item table""" - for d in doc.items: + for d in doc.get(child_table): qty = d.get('stock_qty') or d.get('transfer_qty') or d.get('qty') or 0 - has_batch_no = frappe.db.get_value('Item', d.item_code, 'has_batch_no') warehouse = d.get(warehouse_field, None) - if has_batch_no and warehouse and qty > 0: + if warehouse and qty > 0 and frappe.db.get_value('Item', d.item_code, 'has_batch_no'): if not d.batch_no: d.batch_no = get_batch_no(d.item_code, warehouse, qty, throw, d.serial_no) else: @@ -308,4 +307,9 @@ def validate_serial_no_with_batch(serial_nos, item_code): message = "Serial Nos" if len(serial_nos) > 1 else "Serial No" frappe.throw(_("There is no batch found against the {0}: {1}") - .format(message, serial_no_link)) \ No newline at end of file + .format(message, serial_no_link)) + +def make_batch(args): + if frappe.db.get_value("Item", args.item, "has_batch_no"): + args.doctype = "Batch" + frappe.get_doc(args).insert().name diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.js b/erpnext/stock/doctype/delivery_note/delivery_note.js index 7875b9cd87..74cb3fcb1f 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note.js @@ -78,6 +78,9 @@ frappe.ui.form.on("Delivery Note", { }); erpnext.accounts.dimensions.setup_dimension_filters(frm, frm.doctype); + + frm.set_df_property('packed_items', 'cannot_add_rows', true); + frm.set_df_property('packed_items', 'cannot_delete_rows', true); }, print_without_amount: function(frm) { diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.json b/erpnext/stock/doctype/delivery_note/delivery_note.json index 280fde158f..f20e76f5bf 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.json +++ b/erpnext/stock/doctype/delivery_note/delivery_note.json @@ -554,8 +554,7 @@ "oldfieldname": "packing_details", "oldfieldtype": "Table", "options": "Packed Item", - "print_hide": 1, - "read_only": 1 + "print_hide": 1 }, { "fieldname": "product_bundle_help", @@ -1289,7 +1288,7 @@ "idx": 146, "is_submittable": 1, "links": [], - "modified": "2021-04-15 23:55:49.620641", + "modified": "2021-06-11 19:27:30.901112", "modified_by": "Administrator", "module": "Stock", "name": "Delivery Note", diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py index dd31965fac..4808e948fc 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/delivery_note.py @@ -129,12 +129,13 @@ class DeliveryNote(SellingController): self.validate_uom_is_integer("uom", "qty") self.validate_with_previous_doc() - if self._action != 'submit' and not self.is_return: - set_batch_nos(self, 'warehouse', True) - from erpnext.stock.doctype.packed_item.packed_item import make_packing_list make_packing_list(self) + if self._action != 'submit' and not self.is_return: + set_batch_nos(self, 'warehouse', throw=True) + set_batch_nos(self, 'warehouse', throw=True, child_table="packed_items") + self.update_current_stock() if not self.installation_status: self.installation_status = 'Not Installed' @@ -181,9 +182,8 @@ class DeliveryNote(SellingController): super(DeliveryNote, self).validate_warehouse() for d in self.get_item_list(): - if frappe.db.get_value("Item", d['item_code'], "is_stock_item") == 1: - if not d['warehouse']: - frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) + if not d['warehouse'] and frappe.db.get_value("Item", d['item_code'], "is_stock_item") == 1: + frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"])) def update_current_stock(self): diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js index f08125b199..0402898047 100644 --- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js +++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js @@ -6,8 +6,8 @@ frappe.listview_settings['Delivery Note'] = { return [__("Return"), "gray", "is_return,=,Yes"]; } else if (doc.status === "Closed") { return [__("Closed"), "green", "status,=,Closed"]; - } else if (flt(doc.per_returned, 2) === 100) { - return [__("Return Issued"), "grey", "per_returned,=,100"]; + } else if (doc.status === "Return Issued") { + return [__("Return Issued"), "grey", "status,=,Return Issued"]; } else if (flt(doc.per_billed, 2) < 100) { return [__("To Bill"), "orange", "per_billed,<,100"]; } else if (flt(doc.per_billed, 2) === 100) { diff --git a/erpnext/stock/doctype/delivery_note/test_delivery_note.py b/erpnext/stock/doctype/delivery_note/test_delivery_note.py index 0c63df0e22..f981aeb13b 100644 --- a/erpnext/stock/doctype/delivery_note/test_delivery_note.py +++ b/erpnext/stock/doctype/delivery_note/test_delivery_note.py @@ -7,7 +7,7 @@ import unittest import frappe import json import frappe.defaults -from frappe.utils import cint, nowdate, nowtime, cstr, add_days, flt, today +from frappe.utils import nowdate, nowtime, cstr, flt from erpnext.stock.stock_ledger import get_previous_sle from erpnext.accounts.utils import get_balance_on from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries @@ -18,9 +18,11 @@ from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos, SerialNoWa from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation \ import create_stock_reconciliation, set_valuation_method from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order, create_dn_against_so -from erpnext.accounts.doctype.account.test_account import get_inventory_account, create_account +from erpnext.accounts.doctype.account.test_account import get_inventory_account from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse -from erpnext.stock.doctype.item.test_item import create_item +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle + class TestDeliveryNote(unittest.TestCase): def test_over_billing_against_dn(self): @@ -277,8 +279,6 @@ class TestDeliveryNote(unittest.TestCase): dn.cancel() def test_sales_return_for_non_bundled_items_full(self): - from erpnext.stock.doctype.item.test_item import make_item - company = frappe.db.get_value('Warehouse', 'Stores - TCP1', 'company') make_item("Box", {'is_stock_item': 1}) @@ -741,6 +741,25 @@ class TestDeliveryNote(unittest.TestCase): self.assertEqual(si2.items[0].qty, 2) self.assertEqual(si2.items[1].qty, 1) + + def test_delivery_note_bundle_with_batched_item(self): + batched_bundle = make_item("_Test Batched bundle", {"is_stock_item": 0}) + batched_item = make_item("_Test Batched Item", + {"is_stock_item": 1, "has_batch_no": 1, "create_new_batch": 1, "batch_number_series": "TESTBATCH.#####"} + ) + make_product_bundle(parent=batched_bundle.name, items=[batched_item.name]) + make_stock_entry(item_code=batched_item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=42) + + try: + dn = create_delivery_note(item_code=batched_bundle.name, qty=1) + except frappe.ValidationError as e: + if "batch" in str(e).lower(): + self.fail("Batch numbers not getting added to bundled items in DN.") + raise e + + self.assertTrue("TESTBATCH" in dn.packed_items[0].batch_no, "Batch number not added in packed item") + + def create_delivery_note(**args): dn = frappe.new_doc("Delivery Note") args = frappe._dict(args) diff --git a/erpnext/stock/doctype/material_request/material_request.js b/erpnext/stock/doctype/material_request/material_request.js index 92c8d21387..6e66f9869c 100644 --- a/erpnext/stock/doctype/material_request/material_request.js +++ b/erpnext/stock/doctype/material_request/material_request.js @@ -101,7 +101,8 @@ frappe.ui.form.on('Material Request', { } if (frm.doc.docstatus == 1 && frm.doc.status != 'Stopped') { - if (flt(frm.doc.per_ordered, 2) < 100) { + let precision = frappe.defaults.get_default("float_precision"); + if (flt(frm.doc.per_ordered, precision) < 100) { let add_create_pick_list_button = () => { frm.add_custom_button(__('Pick List'), () => frm.events.create_pick_list(frm), __('Create')); diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py index 335175f21d..3ad9909ad0 100644 --- a/erpnext/stock/doctype/material_request/material_request.py +++ b/erpnext/stock/doctype/material_request/material_request.py @@ -189,7 +189,7 @@ class MaterialRequest(BuyingController): item_wh_list = [] for d in self.get("items"): if (not mr_item_rows or d.name in mr_item_rows) and [d.item_code, d.warehouse] not in item_wh_list \ - and frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1 and d.warehouse: + and d.warehouse and frappe.db.get_value("Item", d.item_code, "is_stock_item") == 1 : item_wh_list.append([d.item_code, d.warehouse]) for item_code, warehouse in item_wh_list: diff --git a/erpnext/stock/doctype/pick_list/pick_list.json b/erpnext/stock/doctype/pick_list/pick_list.json index c01388dcd2..2146793537 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.json +++ b/erpnext/stock/doctype/pick_list/pick_list.json @@ -184,4 +184,4 @@ "sort_field": "modified", "sort_order": "DESC", "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 6ab68e292a..e795742ea4 100644 --- a/erpnext/stock/doctype/pick_list/pick_list.py +++ b/erpnext/stock/doctype/pick_list/pick_list.py @@ -17,6 +17,9 @@ from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note a # TODO: Prioritize SO or WO group warehouse class PickList(Document): + def validate(self): + self.validate_for_qty() + def before_save(self): self.set_item_locations() @@ -35,6 +38,7 @@ class PickList(Document): @frappe.whitelist() def set_item_locations(self, save=False): + self.validate_for_qty() items = self.aggregate_item_qty() self.item_location_map = frappe._dict() @@ -107,6 +111,11 @@ class PickList(Document): return item_map.values() + def validate_for_qty(self): + if self.purpose == "Material Transfer for Manufacture" \ + and (self.for_qty is None or self.for_qty == 0): + frappe.throw(_("Qty of Finished Goods Item should be greater than 0.")) + def validate_item_locations(pick_list): if not pick_list.locations: diff --git a/erpnext/stock/doctype/pick_list/test_pick_list.py b/erpnext/stock/doctype/pick_list/test_pick_list.py index c4da05a6d4..84566b8d8c 100644 --- a/erpnext/stock/doctype/pick_list/test_pick_list.py +++ b/erpnext/stock/doctype/pick_list/test_pick_list.py @@ -37,6 +37,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 5, @@ -90,6 +91,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item Warehouse Group Wise Reorder', 'qty': 1000, @@ -135,6 +137,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Serialized Item', 'qty': 1000, @@ -264,6 +267,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 5, @@ -319,6 +323,7 @@ class TestPickList(unittest.TestCase): 'company': '_Test Company', 'customer': '_Test Customer', 'items_based_on': 'Sales Order', + 'purpose': 'Delivery', 'locations': [{ 'item_code': '_Test Item', 'qty': 1, diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py index b8580f95a3..82c87a83a5 100644 --- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py @@ -386,6 +386,7 @@ class PurchaseReceipt(BuyingController): against_account = ", ".join([d.account for d in gl_entries if flt(d.debit) > 0]) total_valuation_amount = sum(valuation_tax.values()) amount_including_divisional_loss = negative_expense_to_be_booked + stock_rbnb = self.get_company_default("stock_received_but_not_billed") i = 1 for tax in self.get("taxes"): if valuation_tax.get(tax.name): @@ -581,7 +582,6 @@ def update_billing_percentage(pr_doc, update_modified=True): @frappe.whitelist() def make_purchase_invoice(source_name, target_doc=None): - from frappe.model.mapper import get_mapped_doc from erpnext.accounts.party import get_payment_terms_template doc = frappe.get_doc('Purchase Receipt', source_name) @@ -601,11 +601,16 @@ def make_purchase_invoice(source_name, target_doc=None): def update_item(source_doc, target_doc, source_parent): target_doc.qty, returned_qty = get_pending_qty(source_doc) + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + target_doc.rejected_qty = 0 target_doc.stock_qty = flt(target_doc.qty) * flt(target_doc.conversion_factor, target_doc.precision("conversion_factor")) returned_qty_map[source_doc.name] = returned_qty def get_pending_qty(item_row): - pending_qty = item_row.qty - invoiced_qty_map.get(item_row.name, 0) + qty = item_row.qty + if frappe.db.get_single_value("Buying Settings", "bill_for_rejected_quantity_in_purchase_invoice"): + qty = item_row.received_qty + pending_qty = qty - invoiced_qty_map.get(item_row.name, 0) returned_qty = flt(returned_qty_map.get(item_row.name, 0)) if returned_qty: if returned_qty >= pending_qty: diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index 95096d77d7..2eb8bfd5d2 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -421,11 +421,18 @@ class TestPurchaseReceipt(unittest.TestCase): self.assertEqual(return_pr_2.items[0].qty, -3) # Make PI against unreturned amount + buying_settings = frappe.get_single("Buying Settings") + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 0 + buying_settings.save() + pi = make_purchase_invoice(pr.name) pi.submit() self.assertEqual(pi.items[0].qty, 3) + buying_settings.bill_for_rejected_quantity_in_purchase_invoice = 1 + buying_settings.save() + pr.load_from_db() # PR should be completed on billing all unreturned amount self.assertEqual(pr.items[0].billed_amt, 150) @@ -1004,6 +1011,47 @@ class TestPurchaseReceipt(unittest.TestCase): self.assertEqual(pr.status, "To Bill") self.assertAlmostEqual(pr.per_billed, 50.0, places=2) + def test_service_item_purchase_with_perpetual_inventory(self): + company = '_Test Company with perpetual inventory' + service_item = '_Test Non Stock Item' + + before_test_value = frappe.db.get_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items') + frappe.db.set_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items', 1) + srbnb_account = 'Stock Received But Not Billed - TCP1' + frappe.db.set_value('Company', company, 'service_received_but_not_billed', srbnb_account) + + pr = make_purchase_receipt( + company=company, item=service_item, + warehouse='Finished Goods - TCP1', do_not_save=1 + ) + item_row_with_diff_rate = frappe.copy_doc(pr.items[0]) + item_row_with_diff_rate.rate = 100 + pr.append('items', item_row_with_diff_rate) + + pr.save() + pr.submit() + + item_one_gl_entry = frappe.db.get_all("GL Entry", { + 'voucher_type': pr.doctype, + 'voucher_no': pr.name, + 'account': srbnb_account, + 'voucher_detail_no': pr.items[0].name + }, pluck="name") + + item_two_gl_entry = frappe.db.get_all("GL Entry", { + 'voucher_type': pr.doctype, + 'voucher_no': pr.name, + 'account': srbnb_account, + 'voucher_detail_no': pr.items[1].name + }, pluck="name") + + # check if the entries are not merged into one + # seperate entries should be made since voucher_detail_no is different + self.assertEqual(len(item_one_gl_entry), 1) + self.assertEqual(len(item_two_gl_entry), 1) + + frappe.db.set_value('Company', company, 'enable_perpetual_inventory_for_non_stock_items', before_test_value) + def get_sl_entries(voucher_type, voucher_no): return frappe.db.sql(""" select actual_qty, warehouse, stock_value_difference from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py index ea26caced0..0f50bcd6ea 100644 --- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py +++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py @@ -97,7 +97,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None): at_capacity, rules = get_ordered_putaway_rules(item_code, company, source_warehouse=source_warehouse) if not rules: - warehouse = source_warehouse or item.warehouse + warehouse = source_warehouse or item.get('warehouse') if at_capacity: # rules available, but no free space items_not_accomodated.append([item_code, pending_qty]) diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json index 3acf3a9316..a3d44af494 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.json +++ b/erpnext/stock/doctype/serial_no/serial_no.json @@ -57,7 +57,8 @@ "more_info", "serial_no_details", "company", - "status" + "status", + "work_order" ], "fields": [ { @@ -422,12 +423,18 @@ "label": "Status", "options": "\nActive\nInactive\nDelivered\nExpired", "read_only": 1 + }, + { + "fieldname": "work_order", + "fieldtype": "Link", + "label": "Work Order", + "options": "Work Order" } ], "icon": "fa fa-barcode", "idx": 1, "links": [], - "modified": "2020-07-20 20:50:16.660433", + "modified": "2021-01-08 14:31:15.375996", "modified_by": "Administrator", "module": "Stock", "name": "Serial No", diff --git a/erpnext/stock/doctype/serial_no/serial_no.py b/erpnext/stock/doctype/serial_no/serial_no.py index b236f6a999..bad7b608ac 100644 --- a/erpnext/stock/doctype/serial_no/serial_no.py +++ b/erpnext/stock/doctype/serial_no/serial_no.py @@ -473,16 +473,13 @@ def get_serial_nos(serial_no): if s.strip()] def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False): - serial_no_doc.update({ - "item_code": args.get("item_code"), - "company": args.get("company"), - "batch_no": args.get("batch_no"), - "via_stock_ledger": args.get("via_stock_ledger") or True, - "supplier": args.get("supplier"), - "location": args.get("location"), - "warehouse": (args.get("warehouse") - if args.get("actual_qty", 0) > 0 else None) - }) + for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]: + if args.get(field): + serial_no_doc.set(field, args.get(field)) + + serial_no_doc.via_stock_ledger = args.get("via_stock_ledger") or True + serial_no_doc.warehouse = (args.get("warehouse") + if args.get("actual_qty", 0) > 0 else None) if is_new: serial_no_doc.serial_no = serial_no diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py index 66f8b63cb9..8f27ef4356 100644 --- a/erpnext/stock/doctype/stock_entry/stock_entry.py +++ b/erpnext/stock/doctype/stock_entry/stock_entry.py @@ -498,6 +498,7 @@ class StockEntry(StockController): d.basic_amount = flt(flt(d.transfer_qty) * flt(d.basic_rate), d.precision("basic_amount")) if not d.t_warehouse: outgoing_items_cost += flt(d.basic_amount) + return outgoing_items_cost def get_args_for_incoming_rate(self, item): @@ -854,6 +855,7 @@ class StockEntry(StockController): pro_doc.run_method("update_work_order_qty") if self.purpose == "Manufacture": pro_doc.run_method("update_planned_qty") + pro_doc.update_batch_produced_qty(self) if not pro_doc.operations: pro_doc.set_actual_dates() @@ -1076,18 +1078,54 @@ class StockEntry(StockController): # in case of BOM to_warehouse = item.get("default_warehouse") + args = { + "to_warehouse": to_warehouse, + "from_warehouse": "", + "qty": self.fg_completed_qty, + "item_name": item.item_name, + "description": item.description, + "stock_uom": item.stock_uom, + "expense_account": item.get("expense_account"), + "cost_center": item.get("buying_cost_center"), + "is_finished_item": 1 + } + + if self.work_order and self.pro_doc.has_batch_no: + self.set_batchwise_finished_goods(args, item) + else: + self.add_finisged_goods(args, item) + + def set_batchwise_finished_goods(self, args, item): + qty = flt(self.fg_completed_qty) + filters = { + "reference_name": self.pro_doc.name, + "reference_doctype": self.pro_doc.doctype, + "qty_to_produce": (">", 0) + } + + fields = ["qty_to_produce as qty", "produced_qty", "name"] + + for row in frappe.get_all("Batch", filters = filters, fields = fields, order_by="creation asc"): + batch_qty = flt(row.qty) - flt(row.produced_qty) + if not batch_qty: + continue + + if qty <=0: + break + + fg_qty = batch_qty + if batch_qty >= qty: + fg_qty = qty + + qty -= batch_qty + args["qty"] = fg_qty + args["batch_no"] = row.name + + self.add_finisged_goods(args, item) + + def add_finisged_goods(self, args, item): self.add_to_stock_entry_detail({ - item.name: { - "to_warehouse": to_warehouse, - "from_warehouse": "", - "qty": self.fg_completed_qty, - "item_name": item.item_name, - "description": item.description, - "stock_uom": item.stock_uom, - "expense_account": item.get("expense_account"), - "cost_center": item.get("buying_cost_center"), - "is_finished_item": 1 - } + item.name: args }, bom_no = self.bom_no) def get_bom_raw_materials(self, qty): @@ -1524,6 +1562,36 @@ class StockEntry(StockController): material_requests.append(material_request) frappe.db.set_value('Material Request', material_request, 'transfer_status', status) + def set_serial_no_batch_for_finished_good(self): + args = {} + if self.pro_doc.serial_no: + self.get_serial_nos_for_fg(args) + + for row in self.items: + if row.is_finished_item and row.item_code == self.pro_doc.production_item: + if args.get("serial_no"): + row.serial_no = '\n'.join(args["serial_no"][0: cint(row.qty)]) + + def get_serial_nos_for_fg(self, args): + fields = ["`tabStock Entry`.`name`", "`tabStock Entry Detail`.`qty`", + "`tabStock Entry Detail`.`serial_no`", "`tabStock Entry Detail`.`batch_no`"] + + filters = [["Stock Entry","work_order","=",self.work_order], ["Stock Entry","purpose","=","Manufacture"], + ["Stock Entry","docstatus","=",1], ["Stock Entry Detail","item_code","=",self.pro_doc.production_item]] + + stock_entries = frappe.get_all("Stock Entry", fields=fields, filters=filters) + + if self.pro_doc.serial_no: + args["serial_no"] = self.get_available_serial_nos(stock_entries) + + def get_available_serial_nos(self, stock_entries): + used_serial_nos = [] + for row in stock_entries: + if row.serial_no: + used_serial_nos.extend(get_serial_nos(row.serial_no)) + + return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) + @frappe.whitelist() def move_sample_to_retention_warehouse(company, items): if isinstance(items, string_types): @@ -1635,6 +1703,10 @@ def get_operating_cost_per_unit(work_order=None, bom_no=None): if bom.quantity: operating_cost_per_unit = flt(bom.operating_cost) / flt(bom.quantity) + if work_order and work_order.produced_qty and cint(frappe.db.get_single_value('Manufacturing Settings', + 'add_corrective_operation_cost_in_finished_good_valuation')): + operating_cost_per_unit += flt(work_order.corrective_operation_cost) / flt(work_order.produced_qty) + return operating_cost_per_unit def get_used_alternative_items(purchase_order=None, work_order=None): diff --git a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json index a007389f7a..22f412a298 100644 --- a/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json +++ b/erpnext/stock/doctype/stock_entry_detail/stock_entry_detail.json @@ -18,6 +18,7 @@ "col_break2", "is_finished_item", "is_scrap_item", + "quality_inspection", "subcontracted_item", "section_break_8", "description", @@ -69,7 +70,6 @@ "putaway_rule", "column_break_51", "reference_purchase_receipt", - "quality_inspection", "job_card_item" ], "fields": [ diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py index ba31ad7b06..af2ada8c9a 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py @@ -54,7 +54,7 @@ class TestStockLedgerEntry(unittest.TestCase): ) # _Test Item for Reposting transferred from Stores to FG warehouse on 30-04-2020 - make_stock_entry( + se = make_stock_entry( item_code="_Test Item for Reposting", source="Stores - _TC", target="Finished Goods - _TC", @@ -64,29 +64,29 @@ class TestStockLedgerEntry(unittest.TestCase): posting_date='2020-04-30', posting_time='14:00' ) - target_wh_sle = get_previous_sle({ + target_wh_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-04-30', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": se.name + }, ["valuation_rate"], as_dict=1) self.assertEqual(target_wh_sle.get("valuation_rate"), 150) # Repack entry on 5-5-2020 repack = create_repack_entry(company=company, posting_date='2020-05-05', posting_time='14:00') - finished_item_sle = get_previous_sle({ + finished_item_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Finished Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-05-05', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": repack.name + }, ["incoming_rate", "valuation_rate"], as_dict=1) self.assertEqual(finished_item_sle.get("incoming_rate"), 540) self.assertEqual(finished_item_sle.get("valuation_rate"), 540) # Reconciliation for _Test Item for Reposting at Stores on 12-04-2020: Qty = 50, Rate = 150 - create_stock_reconciliation( + sr = create_stock_reconciliation( item_code="_Test Item for Reposting", warehouse="Stores - _TC", qty=50, @@ -109,12 +109,12 @@ class TestStockLedgerEntry(unittest.TestCase): self.assertEqual(target_wh_sle.get("valuation_rate"), 175) # Check valuation rate of repacked item after back-dated entry at Stores - finished_item_sle = get_previous_sle({ + finished_item_sle = frappe.db.get_value('Stock Ledger Entry', { "item_code": "_Test Finished Item for Reposting", "warehouse": "Finished Goods - _TC", - "posting_date": '2020-05-05', - "posting_time": '14:00' - }) + "voucher_type": "Stock Entry", + "voucher_no": repack.name + }, ["incoming_rate", "valuation_rate"], as_dict=1) self.assertEqual(finished_item_sle.get("incoming_rate"), 790) self.assertEqual(finished_item_sle.get("valuation_rate"), 790) diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js index ac4ed5e75d..a01db80da4 100644 --- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js +++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.js @@ -48,37 +48,54 @@ frappe.ui.form.on("Stock Reconciliation", { }, get_items: function(frm) { - frappe.prompt({label:"Warehouse", fieldname: "warehouse", fieldtype:"Link", options:"Warehouse", reqd: 1, + let fields = [{ + label: 'Warehouse', fieldname: 'warehouse', fieldtype: 'Link', options: 'Warehouse', reqd: 1, "get_query": function() { return { "filters": { "company": frm.doc.company, } - } - }}, - function(data) { - frappe.call({ - method:"erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items", - args: { - warehouse: data.warehouse, - posting_date: frm.doc.posting_date, - posting_time: frm.doc.posting_time, - company:frm.doc.company - }, - callback: function(r) { - var items = []; - frm.clear_table("items"); - for(var i=0; i< r.message.length; i++) { - var d = frm.add_child("items"); - $.extend(d, r.message[i]); - if(!d.qty) d.qty = null; - if(!d.valuation_rate) d.valuation_rate = null; - } - frm.refresh_field("items"); - } - }); + }; } - , __("Get Items"), __("Update")); + }, { + label: "Item Code", fieldname: "item_code", fieldtype: "Link", options: "Item", + "get_query": function() { + return { + "filters": { + "disabled": 0, + } + }; + } + }]; + + frappe.prompt(fields, function(data) { + frappe.call({ + method: "erpnext.stock.doctype.stock_reconciliation.stock_reconciliation.get_items", + args: { + warehouse: data.warehouse, + posting_date: frm.doc.posting_date, + posting_time: frm.doc.posting_time, + company: frm.doc.company, + item_code: data.item_code + }, + callback: function(r) { + frm.clear_table("items"); + for (var i=0; i= %s and rgt <= %s and name=bin.warehouse) - """, (lft, rgt)) + where i.name=bin.item_code and IFNULL(i.disabled, 0) = 0 and i.is_stock_item = 1 + and i.has_variants = 0 and exists( + select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=bin.warehouse + ) + """, (lft, rgt), as_dict=1) items += frappe.db.sql(""" - select i.name, i.item_name, id.default_warehouse, i.has_serial_no + select i.name as item_code, i.item_name, id.default_warehouse as warehouse, i.has_serial_no, i.has_batch_no from tabItem i, `tabItem Default` id where i.name = id.parent and exists(select name from `tabWarehouse` where lft >= %s and rgt <= %s and name=id.default_warehouse) - and i.is_stock_item = 1 and i.has_batch_no = 0 - and i.has_variants = 0 and i.disabled = 0 and id.company=%s + and i.is_stock_item = 1 and i.has_variants = 0 and IFNULL(i.disabled, 0) = 0 and id.company=%s group by i.name - """, (lft, rgt, company)) + """, (lft, rgt, company), as_dict=1) - res = [] - for d in set(items): - stock_bal = get_stock_balance(d[0], d[2], posting_date, posting_time, - with_valuation_rate=True , with_serial_no=cint(d[3])) + return items - if frappe.db.get_value("Item", d[0], "disabled") == 0: - res.append({ - "item_code": d[0], - "warehouse": d[2], - "qty": stock_bal[0], - "item_name": d[1], - "valuation_rate": stock_bal[1], - "current_qty": stock_bal[0], - "current_valuation_rate": stock_bal[1], - "current_serial_no": stock_bal[2] if cint(d[3]) else '', - "serial_no": stock_bal[2] if cint(d[3]) else '' - }) +def get_item_data(row, qty, valuation_rate, serial_no=None): + return { + 'item_code': row.item_code, + 'warehouse': row.warehouse, + 'qty': qty, + 'item_name': row.item_name, + 'valuation_rate': valuation_rate, + 'current_qty': qty, + 'current_valuation_rate': valuation_rate, + 'current_serial_no': serial_no, + 'serial_no': serial_no, + 'batch_no': row.get('batch_no') + } - return res +def get_itemwise_batch(warehouse, posting_date, company, item_code=None): + from erpnext.stock.report.batch_wise_balance_history.batch_wise_balance_history import execute + itemwise_batch_data = {} + + filters = frappe._dict({ + 'warehouse': warehouse, + 'from_date': posting_date, + 'to_date': posting_date, + 'company': company + }) + + if item_code: + filters.item_code = item_code + + columns, data = execute(filters) + + for row in data: + itemwise_batch_data.setdefault(row[0], []).append(frappe._dict({ + 'item_code': row[0], + 'warehouse': warehouse, + 'qty': row[8], + 'item_name': row[1], + 'batch_no': row[4] + })) + + return itemwise_batch_data @frappe.whitelist() def get_stock_balance_for(item_code, warehouse, diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py index 36380b838b..84cdc49128 100644 --- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py +++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py @@ -6,7 +6,7 @@ from __future__ import unicode_literals import frappe, unittest -from frappe.utils import flt, nowdate, nowtime +from frappe.utils import flt, nowdate, nowtime, random_string, add_days from erpnext.accounts.utils import get_stock_and_account_balance from erpnext.stock.stock_ledger import get_previous_sle, update_entries_after from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import EmptyStockReconciliationItemsError, get_items @@ -14,6 +14,7 @@ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.utils import get_incoming_rate, get_stock_value_on, get_valuation_method from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos +from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt class TestStockReconciliation(unittest.TestCase): @classmethod @@ -150,6 +151,42 @@ class TestStockReconciliation(unittest.TestCase): stock_doc = frappe.get_doc("Stock Reconciliation", d) stock_doc.cancel() + + def test_stock_reco_for_merge_serialized_item(self): + to_delete_records = [] + + # Add new serial nos + serial_item_code = "Stock-Reco-Serial-Item-2" + serial_warehouse = "_Test Warehouse for Stock Reco1 - _TC" + + sr = create_stock_reconciliation(item_code=serial_item_code, serial_no=random_string(6), + warehouse = serial_warehouse, qty=1, rate=100, do_not_submit=True, purpose='Opening Stock') + + for i in range(3): + sr.append('items', { + 'item_code': serial_item_code, + 'warehouse': serial_warehouse, + 'qty': 1, + 'valuation_rate': 100, + 'serial_no': random_string(6) + }) + + sr.save() + sr.submit() + + sle_entries = frappe.get_all('Stock Ledger Entry', filters= {'voucher_no': sr.name}, + fields = ['name', 'incoming_rate']) + + self.assertEqual(len(sle_entries), 1) + self.assertEqual(sle_entries[0].incoming_rate, 100) + + to_delete_records.append(sr.name) + to_delete_records.reverse() + + for d in to_delete_records: + stock_doc = frappe.get_doc("Stock Reconciliation", d) + stock_doc.cancel() + def test_stock_reco_for_batch_item(self): to_delete_records = [] to_delete_serial_nos = [] @@ -204,6 +241,117 @@ class TestStockReconciliation(unittest.TestCase): self.assertEqual(sr.get("items")[0].valuation_rate, 0) self.assertEqual(sr.get("items")[0].amount, 0) + def test_backdated_stock_reco_qty_reposting(self): + """ + Test if a backdated stock reco recalculates future qty until next reco. + ------------------------------------------- + Var | Doc | Qty | Balance + ------------------------------------------- + SR5 | Reco | 0 | 8 (posting date: today-4) [backdated] + PR1 | PR | 10 | 18 (posting date: today-3) + PR2 | PR | 1 | 19 (posting date: today-2) + SR4 | Reco | 0 | 6 (posting date: today-1) [backdated] + PR3 | PR | 1 | 7 (posting date: today) # can't post future PR + """ + item_code = "Backdated-Reco-Item" + warehouse = "_Test Warehouse - _TC" + create_item(item_code) + + pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100, + posting_date=add_days(nowdate(), -3)) + pr2 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=add_days(nowdate(), -2)) + pr3 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=nowdate()) + + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(pr3_balance, 12) + + # post backdated stock reco in between + sr4 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=6, rate=100, + posting_date=add_days(nowdate(), -1)) + pr3_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr3.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr3_balance, 7) + + # post backdated stock reco at the start + sr5 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=8, rate=100, + posting_date=add_days(nowdate(), -4)) + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, + "qty_after_transaction") + sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 18) + self.assertEqual(pr2_balance, 19) + self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected + + # cancel backdated stock reco and check future impact + sr5.cancel() + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + pr2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr2.name, "is_cancelled": 0}, + "qty_after_transaction") + sr4_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": sr4.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(pr2_balance, 11) + self.assertEqual(sr4_balance, 6) # check if future stock reco is unaffected + + # teardown + sr4.cancel() + pr3.cancel() + pr2.cancel() + pr1.cancel() + + def test_backdated_stock_reco_future_negative_stock(self): + """ + Test if a backdated stock reco causes future negative stock and is blocked. + ------------------------------------------- + Var | Doc | Qty | Balance + ------------------------------------------- + PR1 | PR | 10 | 10 (posting date: today-2) + SR3 | Reco | 0 | 1 (posting date: today-1) [backdated & blocked] + DN2 | DN | -2 | 8(-1) (posting date: today) + """ + from erpnext.stock.stock_ledger import NegativeStockError + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + + item_code = "Backdated-Reco-Item" + warehouse = "_Test Warehouse - _TC" + create_item(item_code) + + negative_stock_setting = frappe.db.get_single_value("Stock Settings", "allow_negative_stock") + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 0) + + pr1 = make_purchase_receipt(item_code=item_code, warehouse=warehouse, qty=10, rate=100, + posting_date=add_days(nowdate(), -2)) + dn2 = create_delivery_note(item_code=item_code, warehouse=warehouse, qty=2, rate=120, + posting_date=nowdate()) + + pr1_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": pr1.name, "is_cancelled": 0}, + "qty_after_transaction") + dn2_balance = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": dn2.name, "is_cancelled": 0}, + "qty_after_transaction") + self.assertEqual(pr1_balance, 10) + self.assertEqual(dn2_balance, 8) + + # check if stock reco is blocked + sr3 = create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=1, rate=100, + posting_date=add_days(nowdate(), -1), do_not_submit=True) + self.assertRaises(NegativeStockError, sr3.submit) + + # teardown + frappe.db.set_value("Stock Settings", None, "allow_negative_stock", negative_stock_setting) + sr3.cancel() + dn2.cancel() + pr1.cancel() + def insert_existing_sle(warehouse): from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry @@ -231,6 +379,12 @@ def create_batch_or_serial_no_items(): serial_item_doc.serial_no_series = "SRSI.####" serial_item_doc.save(ignore_permissions=True) + serial_item_doc = create_item("Stock-Reco-Serial-Item-2", is_stock_item=1) + if not serial_item_doc.has_serial_no: + serial_item_doc.has_serial_no = 1 + serial_item_doc.serial_no_series = "SRSII.####" + serial_item_doc.save(ignore_permissions=True) + batch_item_doc = create_item("Stock-Reco-batch-Item-1", is_stock_item=1) if not batch_item_doc.has_batch_no: batch_item_doc.has_batch_no = 1 diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py index 746cbbf601..ca174a3f63 100644 --- a/erpnext/stock/get_item_details.py +++ b/erpnext/stock/get_item_details.py @@ -436,20 +436,35 @@ def get_barcode_data(items_list): return itemwise_barcode @frappe.whitelist() -def get_item_tax_info(company, tax_category, item_codes, item_rates=None): +def get_item_tax_info(company, tax_category, item_codes, item_rates=None, item_tax_templates=None): out = {} - if isinstance(item_codes, string_types): + + if item_tax_templates is None: + item_tax_templates = {} + + if item_rates is None: + item_rates = {} + + if isinstance(item_codes, (str,)): item_codes = json.loads(item_codes) - if isinstance(item_rates, string_types): + if isinstance(item_rates, (str,)): item_rates = json.loads(item_rates) + if isinstance(item_tax_templates, (str,)): + item_tax_templates = json.loads(item_tax_templates) + for item_code in item_codes: - if not item_code or item_code[1] in out: + if not item_code or item_code[1] in out or not item_tax_templates.get(item_code[1]): continue + out[item_code[1]] = {} item = frappe.get_cached_doc("Item", item_code[0]) - args = {"company": company, "tax_category": tax_category, "net_rate": item_rates[item_code[1]]} + args = {"company": company, "tax_category": tax_category, "net_rate": item_rates.get(item_code[1])} + + if item_tax_templates: + args.update({"item_tax_template": item_tax_templates.get(item_code[1])}) + get_item_tax_template(args, item, out[item_code[1]]) out[item_code[1]]["item_tax_rate"] = get_item_tax_map(company, out[item_code[1]].get("item_tax_template"), as_json=True) @@ -463,9 +478,7 @@ def get_item_tax_template(args, item, out): } """ item_tax_template = args.get("item_tax_template") - - if not item_tax_template: - item_tax_template = _get_item_tax_template(args, item.taxes, out) + item_tax_template = _get_item_tax_template(args, item.taxes, out) if not item_tax_template: item_group = item.item_group @@ -508,7 +521,8 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False): return None # do not change if already a valid template - if args.get('item_tax_template') in taxes: + if args.get('item_tax_template') in {t.item_tax_template for t in taxes}: + out["item_tax_template"] = args.get('item_tax_template') return args.get('item_tax_template') for tax in taxes: diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/__init__.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js new file mode 100644 index 0000000000..bf11277d9c --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.js @@ -0,0 +1,27 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Balance Qty After Transaction"] = { + "filters": [ + { + label: __("Company"), + fieldtype: "Link", + fieldname: "company", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1 + }, + { + label: __('Item Code'), + fieldtype: 'Link', + fieldname: 'item_code', + options: 'Item' + }, + { + label: __('Warehouse'), + fieldtype: 'Link', + fieldname: 'warehouse' + } + ] +}; diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json new file mode 100644 index 0000000000..a5815bcca4 --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.json @@ -0,0 +1,32 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-05-12 16:47:58.717853", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-05-12 16:48:28.347575", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Balance Qty After Transaction", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Balance Qty After Transaction", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Stock Manager" + }, + { + "role": "Purchase User" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py new file mode 100644 index 0000000000..cf174c9368 --- /dev/null +++ b/erpnext/stock/report/incorrect_balance_qty_after_transaction/incorrect_balance_qty_after_transaction.py @@ -0,0 +1,111 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from six import iteritems +from frappe.utils import flt + +def execute(filters=None): + columns, data = [], [] + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_data(filters): + data = get_stock_ledger_entries(filters) + itewise_balance_qty = {} + + for row in data: + key = (row.item_code, row.warehouse) + itewise_balance_qty.setdefault(key, []).append(row) + + res = validate_data(itewise_balance_qty) + return res + +def validate_data(itewise_balance_qty): + res = [] + for key, data in iteritems(itewise_balance_qty): + row = get_incorrect_data(data) + if row: + res.append(row) + res.append({}) + + return res + +def get_incorrect_data(data): + balance_qty = 0.0 + for row in data: + balance_qty += row.actual_qty + if row.voucher_type == "Stock Reconciliation" and not row.batch_no: + balance_qty = flt(row.qty_after_transaction) + + row.expected_balance_qty = balance_qty + if abs(flt(row.expected_balance_qty) - flt(row.qty_after_transaction)) > 0.5: + row.differnce = abs(flt(row.expected_balance_qty) - flt(row.qty_after_transaction)) + return row + +def get_stock_ledger_entries(report_filters): + filters = {} + fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'actual_qty', + 'posting_date', 'posting_time', 'company', 'warehouse', 'qty_after_transaction', 'batch_no'] + + for field in ['warehouse', 'item_code', 'company']: + if report_filters.get(field): + filters[field] = report_filters.get(field) + + return frappe.get_all('Stock Ledger Entry', fields = fields, filters = filters, + order_by = 'timestamp(posting_date, posting_time) asc, creation asc') + +def get_columns(): + return [{ + 'label': _('Id'), + 'fieldtype': 'Link', + 'fieldname': 'name', + 'options': 'Stock Ledger Entry', + 'width': 120 + }, { + 'label': _('Posting Date'), + 'fieldtype': 'Date', + 'fieldname': 'posting_date', + 'width': 110 + }, { + 'label': _('Voucher Type'), + 'fieldtype': 'Link', + 'fieldname': 'voucher_type', + 'options': 'DocType', + 'width': 120 + }, { + 'label': _('Voucher No'), + 'fieldtype': 'Dynamic Link', + 'fieldname': 'voucher_no', + 'options': 'voucher_type', + 'width': 120 + }, { + 'label': _('Item Code'), + 'fieldtype': 'Link', + 'fieldname': 'item_code', + 'options': 'Item', + 'width': 120 + }, { + 'label': _('Warehouse'), + 'fieldtype': 'Link', + 'fieldname': 'warehouse', + 'options': 'Warehouse', + 'width': 120 + }, { + 'label': _('Expected Balance Qty'), + 'fieldtype': 'Float', + 'fieldname': 'expected_balance_qty', + 'width': 170 + }, { + 'label': _('Actual Balance Qty'), + 'fieldtype': 'Float', + 'fieldname': 'qty_after_transaction', + 'width': 150 + }, { + 'label': _('Difference'), + 'fieldtype': 'Float', + 'fieldname': 'differnce', + 'width': 110 + }] \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/__init__.py b/erpnext/stock/report/incorrect_serial_no_valuation/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js new file mode 100644 index 0000000000..c62d48081c --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.js @@ -0,0 +1,35 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Serial No Valuation"] = { + "filters": [ + { + label: __('Item Code'), + fieldtype: 'Link', + fieldname: 'item_code', + options: 'Item', + get_query: function() { + return { + filters: { + 'has_serial_no': 1 + } + } + } + }, + { + label: __('From Date'), + fieldtype: 'Date', + fieldname: 'from_date', + reqd: 1, + default: frappe.defaults.get_user_default("year_start_date") + }, + { + label: __('To Date'), + fieldtype: 'Date', + fieldname: 'to_date', + reqd: 1, + default: frappe.defaults.get_user_default("year_end_date") + } + ] +}; diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json new file mode 100644 index 0000000000..cc384a5bd0 --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.json @@ -0,0 +1,36 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-05-13 13:07:00.767845", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "json": "{}", + "modified": "2021-05-13 13:07:00.767845", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Serial No Valuation", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Serial No Valuation", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Accounts User" + }, + { + "role": "Stock Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py new file mode 100644 index 0000000000..e54cf4c66c --- /dev/null +++ b/erpnext/stock/report/incorrect_serial_no_valuation/incorrect_serial_no_valuation.py @@ -0,0 +1,148 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +import copy +from frappe import _ +from six import iteritems +from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos + +def execute(filters=None): + columns, data = [], [] + columns = get_columns() + data = get_data(filters) + return columns, data + +def get_data(filters): + data = get_stock_ledger_entries(filters) + serial_nos_data = prepare_serial_nos(data) + data = get_incorrect_serial_nos(serial_nos_data) + + return data + +def prepare_serial_nos(data): + serial_no_wise_data = {} + for row in data: + if not row.serial_nos: + continue + + for serial_no in get_serial_nos(row.serial_nos): + sle = copy.deepcopy(row) + sle.serial_no = serial_no + sle.qty = 1 if sle.actual_qty > 0 else -1 + sle.valuation_rate = sle.valuation_rate if sle.actual_qty > 0 else sle.valuation_rate * -1 + serial_no_wise_data.setdefault(serial_no, []).append(sle) + + return serial_no_wise_data + +def get_incorrect_serial_nos(serial_nos_data): + result = [] + + total_value = frappe._dict({'qty': 0, 'valuation_rate': 0, 'serial_no': frappe.bold(_('Balance'))}) + + for serial_no, data in iteritems(serial_nos_data): + total_dict = frappe._dict({'qty': 0, 'valuation_rate': 0, 'serial_no': frappe.bold(_('Total'))}) + + if check_incorrect_serial_data(data, total_dict): + result.extend(data) + + total_value.qty += total_dict.qty + total_value.valuation_rate += total_dict.valuation_rate + + result.append(total_dict) + result.append({}) + + result.append(total_value) + + return result + +def check_incorrect_serial_data(data, total_dict): + incorrect_data = False + for row in data: + total_dict.qty += row.qty + total_dict.valuation_rate += row.valuation_rate + + if ((total_dict.qty == 0 and abs(total_dict.valuation_rate) > 0) or total_dict.qty < 0): + incorrect_data = True + + return incorrect_data + +def get_stock_ledger_entries(report_filters): + fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'serial_no as serial_nos', 'actual_qty', + 'posting_date', 'posting_time', 'company', 'warehouse', '(stock_value_difference / actual_qty) as valuation_rate'] + + filters = {'serial_no': ("is", "set")} + + if report_filters.get('item_code'): + filters['item_code'] = report_filters.get('item_code') + + if report_filters.get('from_date') and report_filters.get('to_date'): + filters['posting_date'] = ('between', [report_filters.get('from_date'), report_filters.get('to_date')]) + + return frappe.get_all('Stock Ledger Entry', fields = fields, filters = filters, + order_by = 'timestamp(posting_date, posting_time) asc, creation asc') + +def get_columns(): + return [{ + 'label': _('Company'), + 'fieldtype': 'Link', + 'fieldname': 'company', + 'options': 'Company', + 'width': 120 + }, { + 'label': _('Id'), + 'fieldtype': 'Link', + 'fieldname': 'name', + 'options': 'Stock Ledger Entry', + 'width': 120 + }, { + 'label': _('Posting Date'), + 'fieldtype': 'Date', + 'fieldname': 'posting_date', + 'width': 90 + }, { + 'label': _('Posting Time'), + 'fieldtype': 'Time', + 'fieldname': 'posting_time', + 'width': 90 + }, { + 'label': _('Voucher Type'), + 'fieldtype': 'Link', + 'fieldname': 'voucher_type', + 'options': 'DocType', + 'width': 100 + }, { + 'label': _('Voucher No'), + 'fieldtype': 'Dynamic Link', + 'fieldname': 'voucher_no', + 'options': 'voucher_type', + 'width': 110 + }, { + 'label': _('Item Code'), + 'fieldtype': 'Link', + 'fieldname': 'item_code', + 'options': 'Item', + 'width': 120 + }, { + 'label': _('Warehouse'), + 'fieldtype': 'Link', + 'fieldname': 'warehouse', + 'options': 'Warehouse', + 'width': 120 + }, { + 'label': _('Serial No'), + 'fieldtype': 'Link', + 'fieldname': 'serial_no', + 'options': 'Serial No', + 'width': 100 + }, { + 'label': _('Qty'), + 'fieldtype': 'Float', + 'fieldname': 'qty', + 'width': 80 + }, { + 'label': _('Valuation Rate (In / Out)'), + 'fieldtype': 'Currency', + 'fieldname': 'valuation_rate', + 'width': 110 + }] \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_stock_value_report/__init__.py b/erpnext/stock/report/incorrect_stock_value_report/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js new file mode 100644 index 0000000000..ff424807e3 --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.js @@ -0,0 +1,36 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Incorrect Stock Value Report"] = { + "filters": [ + { + "label": __("Company"), + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + "reqd": 1, + "default": frappe.defaults.get_user_default("Company") + }, + { + "label": __("Account"), + "fieldname": "account", + "fieldtype": "Link", + "options": "Account", + get_query: function() { + var company = frappe.query_report.get_filter_value('company'); + return { + filters: { + "account_type": "Stock", + "company": company + } + } + } + }, + { + "label": __("From Date"), + "fieldname": "from_date", + "fieldtype": "Date" + } + ] +}; diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json new file mode 100644 index 0000000000..a7e9f203f7 --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.json @@ -0,0 +1,29 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-06-22 15:35:05.148177", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-06-22 15:35:05.148177", + "modified_by": "Administrator", + "module": "Stock", + "name": "Incorrect Stock Value Report", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Stock Ledger Entry", + "report_name": "Incorrect Stock Value Report", + "report_type": "Script Report", + "roles": [ + { + "role": "Stock User" + }, + { + "role": "Accounts Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py new file mode 100644 index 0000000000..a7243878eb --- /dev/null +++ b/erpnext/stock/report/incorrect_stock_value_report/incorrect_stock_value_report.py @@ -0,0 +1,141 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +import erpnext +from frappe import _ +from six import iteritems +from frappe.utils import add_days, today, getdate +from erpnext.stock.utils import get_stock_value_on +from erpnext.accounts.utils import get_stock_and_account_balance + +def execute(filters=None): + if not erpnext.is_perpetual_inventory_enabled(filters.company): + frappe.throw(_("Perpetual inventory required for the company {0} to view this report.") + .format(filters.company)) + + data = get_data(filters) + columns = get_columns(filters) + + return columns, data + +def get_unsync_date(filters): + date = filters.from_date + if not date: + date = frappe.db.sql(""" SELECT min(posting_date) from `tabStock Ledger Entry`""") + date = date[0][0] + + if not date: + return + + while getdate(date) < getdate(today()): + account_bal, stock_bal, warehouse_list = get_stock_and_account_balance(posting_date=date, + company=filters.company, account = filters.account) + + if abs(account_bal - stock_bal) > 0.1: + return date + + date = add_days(date, 1) + +def get_data(report_filters): + from_date = get_unsync_date(report_filters) + + if not from_date: + return [] + + result = [] + + voucher_wise_dict = {} + data = frappe.db.sql(''' + SELECT + name, posting_date, posting_time, voucher_type, voucher_no, + stock_value_difference, stock_value, warehouse, item_code + FROM + `tabStock Ledger Entry` + WHERE + posting_date + = %s and company = %s + and is_cancelled = 0 + ORDER BY timestamp(posting_date, posting_time) asc, creation asc + ''', (from_date, report_filters.company), as_dict=1) + + for d in data: + voucher_wise_dict.setdefault((d.item_code, d.warehouse), []).append(d) + + closing_date = add_days(from_date, -1) + for key, stock_data in iteritems(voucher_wise_dict): + prev_stock_value = get_stock_value_on(posting_date = closing_date, item_code=key[0], warehouse =key[1]) + for data in stock_data: + expected_stock_value = prev_stock_value + data.stock_value_difference + if abs(data.stock_value - expected_stock_value) > 0.1: + data.difference_value = abs(data.stock_value - expected_stock_value) + data.expected_stock_value = expected_stock_value + result.append(data) + + return result + +def get_columns(filters): + return [ + { + "label": _("Stock Ledger ID"), + "fieldname": "name", + "fieldtype": "Link", + "options": "Stock Ledger Entry", + "width": "80" + }, + { + "label": _("Posting Date"), + "fieldname": "posting_date", + "fieldtype": "Date" + }, + { + "label": _("Posting Time"), + "fieldname": "posting_time", + "fieldtype": "Time" + }, + { + "label": _("Voucher Type"), + "fieldname": "voucher_type", + "width": "110" + }, + { + "label": _("Voucher No"), + "fieldname": "voucher_no", + "fieldtype": "Dynamic Link", + "options": "voucher_type", + "width": "110" + }, + { + "label": _("Item Code"), + "fieldname": "item_code", + "fieldtype": "Link", + "options": "Item", + "width": "110" + }, + { + "label": _("Warehouse"), + "fieldname": "warehouse", + "fieldtype": "Link", + "options": "Warehouse", + "width": "110" + }, + { + "label": _("Expected Stock Value"), + "fieldname": "expected_stock_value", + "fieldtype": "Currency", + "width": "150" + }, + { + "label": _("Stock Value"), + "fieldname": "stock_value", + "fieldtype": "Currency", + "width": "120" + }, + { + "label": _("Difference Value"), + "fieldname": "difference_value", + "fieldtype": "Currency", + "width": "150" + } + ] \ No newline at end of file diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 9fe89c3fa5..4e9c7689ae 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -55,6 +55,11 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc sle_doc = make_entry(sle, allow_negative_stock, via_landed_cost_voucher) args = sle_doc.as_dict() + + if sle.get("voucher_type") == "Stock Reconciliation": + # preserve previous_qty_after_transaction for qty reposting + args.previous_qty_after_transaction = sle.get("previous_qty_after_transaction") + update_bin(args, allow_negative_stock, via_landed_cost_voucher) def get_args_for_future_sle(row): @@ -215,7 +220,7 @@ class update_entries_after(object): """ self.data.setdefault(args.warehouse, frappe._dict()) warehouse_dict = self.data[args.warehouse] - previous_sle = self.get_previous_sle_of_current_voucher(args) + previous_sle = get_previous_sle_of_current_voucher(args) warehouse_dict.previous_sle = previous_sle for key in ("qty_after_transaction", "valuation_rate", "stock_value"): @@ -227,29 +232,6 @@ class update_entries_after(object): "stock_value_difference": 0.0 }) - def get_previous_sle_of_current_voucher(self, args): - """get stock ledger entries filtered by specific posting datetime conditions""" - - args['time_format'] = '%H:%i:%s' - if not args.get("posting_date"): - args["posting_date"] = "1900-01-01" - if not args.get("posting_time"): - args["posting_time"] = "00:00" - - sle = frappe.db.sql(""" - select *, timestamp(posting_date, posting_time) as "timestamp" - from `tabStock Ledger Entry` - where item_code = %(item_code)s - and warehouse = %(warehouse)s - and is_cancelled = 0 - and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) - order by timestamp(posting_date, posting_time) desc, creation desc - limit 1 - for update""", args, as_dict=1) - - return sle[0] if sle else frappe._dict() - - def build(self): from erpnext.controllers.stock_controller import future_sle_exists @@ -734,6 +716,35 @@ class update_entries_after(object): bin_doc.flags.via_stock_ledger_entry = True bin_doc.save(ignore_permissions=True) + +def get_previous_sle_of_current_voucher(args, exclude_current_voucher=False): + """get stock ledger entries filtered by specific posting datetime conditions""" + + args['time_format'] = '%H:%i:%s' + if not args.get("posting_date"): + args["posting_date"] = "1900-01-01" + if not args.get("posting_time"): + args["posting_time"] = "00:00" + + voucher_condition = "" + if exclude_current_voucher: + voucher_no = args.get("voucher_no") + voucher_condition = f"and voucher_no != '{voucher_no}'" + + sle = frappe.db.sql(""" + select *, timestamp(posting_date, posting_time) as "timestamp" + from `tabStock Ledger Entry` + where item_code = %(item_code)s + and warehouse = %(warehouse)s + and is_cancelled = 0 + {voucher_condition} + and timestamp(posting_date, time_format(posting_time, %(time_format)s)) < timestamp(%(posting_date)s, time_format(%(posting_time)s, %(time_format)s)) + order by timestamp(posting_date, posting_time) desc, creation desc + limit 1 + for update""".format(voucher_condition=voucher_condition), args, as_dict=1) + + return sle[0] if sle else frappe._dict() + def get_previous_sle(args, for_update=False): """ get the last sle on or before the current time-bucket, @@ -862,9 +873,24 @@ def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no, return valuation_rate def update_qty_in_future_sle(args, allow_negative_stock=None): + """Recalculate Qty after Transaction in future SLEs based on current SLE.""" + datetime_limit_condition = "" + qty_shift = args.actual_qty + + # find difference/shift in qty caused by stock reconciliation + if args.voucher_type == "Stock Reconciliation": + qty_shift = get_stock_reco_qty_shift(args) + + # find the next nearest stock reco so that we only recalculate SLEs till that point + next_stock_reco_detail = get_next_stock_reco(args) + if next_stock_reco_detail: + detail = next_stock_reco_detail[0] + # add condition to update SLEs before this date & time + datetime_limit_condition = get_datetime_limit_condition(detail) + frappe.db.sql(""" update `tabStock Ledger Entry` - set qty_after_transaction = qty_after_transaction + {qty} + set qty_after_transaction = qty_after_transaction + {qty_shift} where item_code = %(item_code)s and warehouse = %(warehouse)s @@ -876,15 +902,70 @@ def update_qty_in_future_sle(args, allow_negative_stock=None): and creation > %(creation)s ) ) - """.format(qty=args.actual_qty), args) + {datetime_limit_condition} + """.format(qty_shift=qty_shift, datetime_limit_condition=datetime_limit_condition), args) validate_negative_qty_in_future_sle(args, allow_negative_stock) +def get_stock_reco_qty_shift(args): + stock_reco_qty_shift = 0 + if args.get("is_cancelled"): + if args.get("previous_qty_after_transaction"): + # get qty (balance) that was set at submission + last_balance = args.get("previous_qty_after_transaction") + stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance) + else: + stock_reco_qty_shift = flt(args.actual_qty) + else: + # reco is being submitted + last_balance = get_previous_sle_of_current_voucher(args, + exclude_current_voucher=True).get("qty_after_transaction") + + if last_balance is not None: + stock_reco_qty_shift = flt(args.qty_after_transaction) - flt(last_balance) + else: + stock_reco_qty_shift = args.qty_after_transaction + + return stock_reco_qty_shift + +def get_next_stock_reco(args): + """Returns next nearest stock reconciliaton's details.""" + + return frappe.db.sql(""" + select + name, posting_date, posting_time, creation, voucher_no + from + `tabStock Ledger Entry` + where + item_code = %(item_code)s + and warehouse = %(warehouse)s + and voucher_type = 'Stock Reconciliation' + and voucher_no != %(voucher_no)s + and is_cancelled = 0 + and (timestamp(posting_date, posting_time) > timestamp(%(posting_date)s, %(posting_time)s) + or ( + timestamp(posting_date, posting_time) = timestamp(%(posting_date)s, %(posting_time)s) + and creation > %(creation)s + ) + ) + limit 1 + """, args, as_dict=1) + +def get_datetime_limit_condition(detail): + return f""" + and + (timestamp(posting_date, posting_time) < timestamp('{detail.posting_date}', '{detail.posting_time}') + or ( + timestamp(posting_date, posting_time) = timestamp('{detail.posting_date}', '{detail.posting_time}') + and creation < '{detail.creation}' + ) + )""" + def validate_negative_qty_in_future_sle(args, allow_negative_stock=None): allow_negative_stock = allow_negative_stock \ or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock")) - if args.actual_qty < 0 and not allow_negative_stock: + if (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation") and not allow_negative_stock: sle = get_future_sle_with_negative_qty(args) if sle: message = _("{0} units of {1} needed in {2} on {3} {4} for {5} to complete this transaction.").format( diff --git a/erpnext/stock/workspace/stock/stock.json b/erpnext/stock/workspace/stock/stock.json index 3221dc4365..529ce8eb61 100644 --- a/erpnext/stock/workspace/stock/stock.json +++ b/erpnext/stock/workspace/stock/stock.json @@ -15,6 +15,7 @@ "hide_custom": 0, "icon": "stock", "idx": 0, + "is_default": 0, "is_standard": 1, "label": "Stock", "links": [ @@ -653,9 +654,44 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Data Report", + "link_type": "DocType", + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Serial No Qty and Valuation", + "link_to": "Incorrect Serial No Valuation", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Incorrect Balance Qty After Transaction", + "link_to": "Incorrect Balance Qty After Transaction", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Stock and Account Value Comparison", + "link_to": "Stock and Account Value Comparison", + "link_type": "Report", + "onboard": 0, + "type": "Link" } ], - "modified": "2020-12-01 13:38:36.282890", + "modified": "2021-05-13 13:10:24.914983", "modified_by": "Administrator", "module": "Stock", "name": "Stock", diff --git a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js index 576e0b76da..18691fe264 100644 --- a/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js +++ b/erpnext/support/report/first_response_time_for_issues/first_response_time_for_issues.js @@ -22,10 +22,10 @@ frappe.query_reports["First Response Time for Issues"] = { get_chart_data: function(_columns, result) { return { data: { - labels: result.map(d => d[0]), + labels: result.map(d => d.creation_date), datasets: [{ name: 'First Response Time', - values: result.map(d => d[1]) + values: result.map(d => d.first_response_time) }] }, type: "line", @@ -35,8 +35,7 @@ frappe.query_reports["First Response Time for Issues"] = { hide_days: 0, hide_seconds: 0 }; - value = frappe.utils.get_formatted_duration(d, duration_options); - return value; + return frappe.utils.get_formatted_duration(d, duration_options); } } } diff --git a/erpnext/templates/includes/cart/cart_address.html b/erpnext/templates/includes/cart/cart_address.html index 84a9430956..4482bc10cf 100644 --- a/erpnext/templates/includes/cart/cart_address.html +++ b/erpnext/templates/includes/cart/cart_address.html @@ -99,6 +99,7 @@ frappe.ready(() => { fieldname: 'country', fieldtype: 'Link', options: 'Country', + only_select: true, reqd: 1 }, { diff --git a/erpnext/templates/includes/projects/project_row.html b/erpnext/templates/includes/projects/project_row.html index 4c8c40db00..bfd63494c3 100644 --- a/erpnext/templates/includes/projects/project_row.html +++ b/erpnext/templates/includes/projects/project_row.html @@ -1,28 +1,54 @@ -{% if doc.status=="Open" %} - +{% if doc.status == "Open" %} +
+
+
+ Link + {{ doc.name }} +
+
+ {{ doc.project_name }} +
+
+ {% if doc.percent_complete %} + {% set pill_class = "green" if doc.percent_complete | round == 100 else + "orange" %} +
+ + {{ frappe.utils.cint(doc.percent_complete) }} + % + +
+ {% else %} + + {{ doc.status }} + {% endif %} +
+ {% if doc["_assign"] %} + {% set assigned_users = json.loads(doc["_assign"])%} +
+ {% for user in assigned_users %} + {% set user_details = frappe + .db + .get_value("User", user, [ + "full_name", "user_image" + ], as_dict = True) %} + {% if user_details.user_image %} + + + + {% else %} + +
+ {{ frappe.utils.get_abbr(user_details.full_name) }} +
+
+ {% endif %} + {% endfor %} +
+ {% endif %} +
+ {{ frappe.utils.pretty_date(doc.modified) }} +
+
+
{% endif %} diff --git a/erpnext/templates/includes/projects/project_tasks.html b/erpnext/templates/includes/projects/project_tasks.html index 50b9f4b259..2b07a5f0d0 100644 --- a/erpnext/templates/includes/projects/project_tasks.html +++ b/erpnext/templates/includes/projects/project_tasks.html @@ -1,32 +1,5 @@ {% for task in doc.tasks %} - +
+ {{ task_row(task, 0) }} +
{% endfor %} diff --git a/erpnext/templates/includes/projects/project_timesheets.html b/erpnext/templates/includes/projects/project_timesheets.html index 05a07c12e8..850c5e9863 100644 --- a/erpnext/templates/includes/projects/project_timesheets.html +++ b/erpnext/templates/includes/projects/project_timesheets.html @@ -1,23 +1,33 @@ {% for timesheet in doc.timesheets %} - -{% endfor %} \ No newline at end of file +
+
+
{{ timesheet.name }}
+ Link +
{{ timesheet.status }}
+
{{ frappe.utils.format_date(timesheet.from_time, "medium") }}
+
{{ frappe.utils.format_date(timesheet.to_time, "medium") }}
+
+ {% set user_details = frappe + .db + .get_value("User", timesheet.modified_by, [ + "full_name", "user_image" + ], as_dict = True) + %} + {% if user_details.user_image %} + + + + {% else %} + +
+ {{ frappe.utils.get_abbr(user_details.full_name) }} +
+
+ {% endif %} +
+
+ {{ frappe.utils.pretty_date(timesheet.modified) }} +
+
+
+{% endfor %} diff --git a/erpnext/templates/pages/projects.html b/erpnext/templates/pages/projects.html index 7e294e076b..76eaf75cf3 100644 --- a/erpnext/templates/pages/projects.html +++ b/erpnext/templates/pages/projects.html @@ -1,90 +1,173 @@ {% extends "templates/web.html" %} -{% block title %}{{ doc.project_name }}{% endblock %} +{% block title %} + {{ doc.project_name }} +{% endblock %} + +{% block head_include %} + +{% endblock %} {% block header %} -

{{ doc.project_name }}

+

{{ doc.project_name }}

{% endblock %} {% block style %} - + {% endblock %} - {% block page_content %} -{% if doc.percent_complete %} -
-
-
-
-{% endif %} -
-

{{ _("Tasks") }}

- {{ _("New task") }} -
+ {{ progress_bar(doc.percent_complete) }} -

- -

+
+

Status:

+

Progress: + {{ doc.percent_complete }} + % +

+

Hours Spent: + {{ doc.actual_time }} +

+
-{% if doc.tasks %} -
-
- {% include "erpnext/templates/includes/projects/project_tasks.html" %} -
-

-

-{% else %} -

{{ _("No tasks") }}

-{% endif %} + {{ progress_bar(doc.percent_complete) }} + {% if doc.tasks %} +
+
+
+
+

Tasks

+

Status

+

End Date

+

Assigned To

+ +
+
+ {% include "erpnext/templates/includes/projects/project_tasks.html" %} +
+
+ {% else %} +

{{ _("No Tasks") }}

+ {% endif %} -
+ {% if doc.timesheets %} +
+
+
+
+

Timesheets

+

Status

+

From

+

To

+

Modified By

+

Modified On

+
+
+ {% include "erpnext/templates/includes/projects/project_timesheets.html" %} +
+
+ {% else %} +

{{ _("No Timesheets") }}

+ {% endif %} -

{{ _("Timesheets") }}

+ {% if doc.attachments %} +
-{% if doc.timesheets %} -
- {% include "erpnext/templates/includes/projects/project_timesheets.html" %} -
- {% if doc.timesheets|length > 9 %} -

{{ _("More") }}

- {% endif %} -{% else %} -

{{ _("No time sheets") }}

-{% endif %} - -{% if doc.attachments %} -
- -

{{ _("Attachments") }}

-
- {% for attachment in doc.attachments %} - - {% endfor %} -
-{% endif %} +

{{ _("Attachments") }}

+
+ {% for attachment in doc.attachments %} + + {% endfor %} +
+ {% endif %}
{% endblock %} + +{% macro progress_bar(percent_complete) %} +{% if percent_complete %} +
+
+
+{% else %} +
+{% endif %} +{% endmacro %} + +{% macro task_row(task, indent) %} +
+ +
{{ task.status }}
+
+ {% if task.exp_end_date %} + {{ task.exp_end_date }} + {% else %} + -- + {% endif %} +
+
+ {% if task["_assign"] %} + {% set assigned_users = json.loads(task["_assign"])%} + {% for user in assigned_users %} + {% set user_details = frappe.db.get_value("User", user, + ["full_name", "user_image"], + as_dict = True)%} + {% if user_details.user_image %} + + + + {% else %} + +
+ {{ frappe.utils.get_abbr(user_details.full_name) }} +
+
+ {% endif %} + {% endfor %} + {% endif %} +
+
+ {{ frappe.utils.pretty_date(task.modified) }} +
+
+{% if task.children %} + {% for child in task.children %} + {{ task_row(child, indent + 30) }} + {% endfor %} +{% endif %} +{% endmacro %} diff --git a/erpnext/templates/pages/projects.py b/erpnext/templates/pages/projects.py index d23fed9e7d..b369cb6a99 100644 --- a/erpnext/templates/pages/projects.py +++ b/erpnext/templates/pages/projects.py @@ -35,26 +35,16 @@ def get_tasks(project, start=0, search=None, item_status=None): # if item_status: # filters["status"] = item_status tasks = frappe.get_all("Task", filters=filters, - fields=["name", "subject", "status", "_seen", "_comments", "modified", "description"], + fields=["name", "subject", "status", "modified", "_assign", "exp_end_date", "is_group", "parent_task"], limit_start=start, limit_page_length=10) - + task_nest = [] for task in tasks: - task.todo = frappe.get_all('ToDo',filters={'reference_name':task.name, 'reference_type':'Task'}, - fields=["assigned_by", "owner", "modified", "modified_by"]) - - if task.todo: - task.todo=task.todo[0] - task.todo.user_image = frappe.db.get_value('User', task.todo.owner, 'user_image') - - - task.comment_count = len(json.loads(task._comments or "[]")) - - task.css_seen = '' - if task._seen: - if frappe.session.user in json.loads(task._seen): - task.css_seen = 'seen' - - return tasks + if task.is_group: + child_tasks = list(filter(lambda x: x.parent_task == task.name, tasks)) + if len(child_tasks): + task.children = child_tasks + task_nest.append(task) + return list(filter(lambda x: not x.parent_task, tasks)) @frappe.whitelist() def get_task_html(project, start=0, item_status=None): @@ -74,19 +64,12 @@ def get_timesheets(project, start=0, search=None): fields=['project','activity_type','from_time','to_time','parent'], limit_start=start, limit_page_length=10) for timesheet in timesheets: - timesheet.infos = frappe.get_all('Timesheet', filters={"name": timesheet.parent}, - fields=['name','_comments','_seen','status','modified','modified_by'], + info = frappe.get_all('Timesheet', filters={"name": timesheet.parent}, + fields=['name','status','modified','modified_by'], limit_start=start, limit_page_length=10) - for timesheet.info in timesheet.infos: - timesheet.info.user_image = frappe.db.get_value('User', timesheet.info.modified_by, 'user_image') - - timesheet.info.comment_count = len(json.loads(timesheet.info._comments or "[]")) - - timesheet.info.css_seen = '' - if timesheet.info._seen: - if frappe.session.user in json.loads(timesheet.info._seen): - timesheet.info.css_seen = 'seen' + if len(info): + timesheet.update(info[0]) return timesheets @frappe.whitelist() diff --git a/erpnext/www/book_appointment/index.py b/erpnext/www/book_appointment/index.py index 7bfac89f30..ccfa97bc62 100644 --- a/erpnext/www/book_appointment/index.py +++ b/erpnext/www/book_appointment/index.py @@ -2,6 +2,7 @@ import frappe import datetime import json import pytz +from frappe import _ WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] @@ -14,7 +15,8 @@ def get_context(context): if is_enabled: return context else: - frappe.local.flags.redirect_location = '/404' + frappe.redirect_to_message(_("Appointment Scheduling Disabled"), _("Appointment Scheduling has been disabled for this site"), + http_status_code=302, indicator_color="red") raise frappe.Redirect @frappe.whitelist(allow_guest=True)