diff --git a/erpnext/accounts/doctype/party_account/party_account.json b/erpnext/accounts/doctype/party_account/party_account.json index c9f15a6a470..69330577ab3 100644 --- a/erpnext/accounts/doctype/party_account/party_account.json +++ b/erpnext/accounts/doctype/party_account/party_account.json @@ -3,6 +3,7 @@ "creation": "2014-08-29 16:02:39.740505", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "company", "account" @@ -11,6 +12,7 @@ { "fieldname": "company", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Company", "options": "Company", @@ -27,7 +29,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-04-07 18:13:08.833822", + "modified": "2022-04-04 12:31:02.994197", "modified_by": "Administrator", "module": "Accounts", "name": "Party Account", @@ -35,5 +37,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file diff --git a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py index 35c2f8494fc..a519d8be736 100644 --- a/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py +++ b/erpnext/accounts/doctype/tax_withholding_category/tax_withholding_category.py @@ -34,7 +34,9 @@ class TaxWithholdingCategory(Document): def validate_thresholds(self): for d in self.get("rates"): - if d.cumulative_threshold and d.cumulative_threshold < d.single_threshold: + if ( + d.cumulative_threshold and d.single_threshold and d.cumulative_threshold < d.single_threshold + ): frappe.throw( _("Row #{0}: Cumulative threshold cannot be less than Single Transaction threshold").format( d.idx diff --git a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html index e6580493095..605ce8383e4 100644 --- a/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html +++ b/erpnext/accounts/print_format/gst_e_invoice/gst_e_invoice.html @@ -1,7 +1,8 @@ {%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%} -{%- set einvoice = json.loads(doc.signed_einvoice) -%}
+ {% if doc.signed_einvoice %} + {%- set einvoice = json.loads(doc.signed_einvoice) -%}
{% if letter_head and not no_letterhead %}
{{ letter_head }}
@@ -170,4 +171,10 @@
+ {% else %} +
+ You must generate IRN before you can preview GST E-Invoice. +
+ {% endif %}
+ diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index 3a69e717bd9..eda36868b9f 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -463,7 +463,10 @@ class BuyingController(StockController, Subcontracting): stock_items = self.get_stock_items() for d in self.get("items"): - if d.item_code in stock_items and d.warehouse: + if d.item_code not in stock_items: + continue + + if d.warehouse: pr_qty = flt(d.qty) * flt(d.conversion_factor) if pr_qty: @@ -488,6 +491,7 @@ class BuyingController(StockController, Subcontracting): sle = self.get_sl_entries( d, {"actual_qty": flt(pr_qty), "serial_no": cstr(d.serial_no).strip()} ) + if self.is_return: outgoing_rate = get_rate_for_return( self.doctype, self.name, d.item_code, self.return_against, item_row=d @@ -517,18 +521,18 @@ class BuyingController(StockController, Subcontracting): sl_entries.append(from_warehouse_sle) - if flt(d.rejected_qty) != 0: - sl_entries.append( - self.get_sl_entries( - d, - { - "warehouse": d.rejected_warehouse, - "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor), - "serial_no": cstr(d.rejected_serial_no).strip(), - "incoming_rate": 0.0, - }, - ) + if flt(d.rejected_qty) != 0: + sl_entries.append( + self.get_sl_entries( + d, + { + "warehouse": d.rejected_warehouse, + "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor), + "serial_no": cstr(d.rejected_serial_no).strip(), + "incoming_rate": 0.0, + }, ) + ) self.make_sl_entries_for_supplier_warehouse(sl_entries) self.make_sl_entries( diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json index d5fb9697f89..e6f08f708a8 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json @@ -47,7 +47,7 @@ "item_search_settings_section", "redisearch_warning", "search_index_fields", - "show_categories_in_search_autocomplete", + "is_redisearch_enabled", "is_redisearch_loaded", "shop_by_category_section", "slideshow", @@ -293,6 +293,7 @@ "fieldname": "search_index_fields", "fieldtype": "Small Text", "label": "Search Index Fields", + "mandatory_depends_on": "is_redisearch_enabled", "read_only_depends_on": "eval:!doc.is_redisearch_loaded" }, { @@ -301,13 +302,6 @@ "fieldtype": "Section Break", "label": "Item Search Settings" }, - { - "default": "1", - "fieldname": "show_categories_in_search_autocomplete", - "fieldtype": "Check", - "label": "Show Categories in Search Autocomplete", - "read_only_depends_on": "eval:!doc.is_redisearch_loaded" - }, { "default": "0", "fieldname": "is_redisearch_loaded", @@ -365,12 +359,19 @@ "fieldname": "show_price_in_quotation", "fieldtype": "Check", "label": "Show Price in Quotation" + }, + { + "default": "0", + "fieldname": "is_redisearch_enabled", + "fieldtype": "Check", + "label": "Enable Redisearch", + "read_only_depends_on": "eval:!doc.is_redisearch_loaded" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2021-09-02 14:02:44.785824", + "modified": "2022-04-01 18:35:56.106756", "modified_by": "Administrator", "module": "E-commerce", "name": "E Commerce Settings", @@ -389,5 +390,6 @@ ], "sort_field": "modified", "sort_order": "DESC", + "states": [], "track_changes": 1 } \ No newline at end of file diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py index 881d833eb01..f85667e1b20 100644 --- a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py +++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py @@ -9,6 +9,7 @@ from frappe.utils import comma_and, flt, unique from erpnext.e_commerce.redisearch_utils import ( create_website_items_index, + define_autocomplete_dictionary, get_indexable_web_fields, is_search_module_loaded, ) @@ -21,6 +22,8 @@ class ShoppingCartSetupError(frappe.ValidationError): class ECommerceSettings(Document): def onload(self): self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series") + + # flag >> if redisearch is installed and loaded self.is_redisearch_loaded = is_search_module_loaded() def validate(self): @@ -34,6 +37,20 @@ class ECommerceSettings(Document): frappe.clear_document_cache("E Commerce Settings", "E Commerce Settings") + self.is_redisearch_enabled_pre_save = frappe.db.get_single_value( + "E Commerce Settings", "is_redisearch_enabled" + ) + + def after_save(self): + self.create_redisearch_indexes() + + def create_redisearch_indexes(self): + # if redisearch is enabled (value changed) create indexes and dictionary + value_changed = self.is_redisearch_enabled != self.is_redisearch_enabled_pre_save + if self.is_redisearch_loaded and self.is_redisearch_enabled and value_changed: + define_autocomplete_dictionary() + create_website_items_index() + def validate_field_filters(self): if not (self.enable_field_filters and self.filter_fields): return diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js index 7108cabfb3f..7295e4b56a0 100644 --- a/erpnext/e_commerce/doctype/website_item/website_item.js +++ b/erpnext/e_commerce/doctype/website_item/website_item.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on('Website Item', { - onload: function(frm) { + onload: (frm) => { // should never check Private frm.fields_dict["website_image"].df.is_private = 0; @@ -13,18 +13,35 @@ frappe.ui.form.on('Website Item', { }); }, - image: function() { + refresh: (frm) => { + frm.add_custom_button(__("Prices"), function() { + frappe.set_route("List", "Item Price", {"item_code": frm.doc.item_code}); + }, __("View")); + + frm.add_custom_button(__("Stock"), function() { + frappe.route_options = { + "item_code": frm.doc.item_code + }; + frappe.set_route("query-report", "Stock Balance"); + }, __("View")); + + frm.add_custom_button(__("E Commerce Settings"), function() { + frappe.set_route("Form", "E Commerce Settings"); + }, __("View")); + }, + + image: () => { refresh_field("image_view"); }, - copy_from_item_group: function(frm) { + copy_from_item_group: (frm) => { return frm.call({ doc: frm.doc, method: "copy_specification_from_item_group" }); }, - set_meta_tags(frm) { + set_meta_tags: (frm) => { frappe.utils.set_meta_tag(frm.doc.route); } }); diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py index 82829bf1eff..f2dd796f2c5 100644 --- a/erpnext/e_commerce/redisearch_utils.py +++ b/erpnext/e_commerce/redisearch_utils.py @@ -1,8 +1,12 @@ -# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe +from frappe import _ from frappe.utils.redis_wrapper import RedisWrapper +from redis import ResponseError from redisearch import AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField WEBSITE_ITEM_INDEX = "website_items_index" @@ -22,6 +26,12 @@ def get_indexable_web_fields(): return [df.fieldname for df in valid_fields] +def is_redisearch_enabled(): + "Return True only if redisearch is loaded and enabled." + is_redisearch_enabled = frappe.db.get_single_value("E Commerce Settings", "is_redisearch_enabled") + return is_search_module_loaded() and is_redisearch_enabled + + def is_search_module_loaded(): try: cache = frappe.cache() @@ -32,14 +42,14 @@ def is_search_module_loaded(): ) return "search" in parsed_output except Exception: - return False + return False # handling older redis versions -def if_redisearch_loaded(function): - "Decorator to check if Redisearch is loaded." +def if_redisearch_enabled(function): + "Decorator to check if Redisearch is enabled." def wrapper(*args, **kwargs): - if is_search_module_loaded(): + if is_redisearch_enabled(): func = function(*args, **kwargs) return func return @@ -51,22 +61,25 @@ def make_key(key): return "{0}|{1}".format(frappe.conf.db_name, key).encode("utf-8") -@if_redisearch_loaded +@if_redisearch_enabled def create_website_items_index(): "Creates Index Definition." # CREATE index client = Client(make_key(WEBSITE_ITEM_INDEX), conn=frappe.cache()) - # DROP if already exists try: - client.drop_index() - except Exception: + client.drop_index() # drop if already exists + except ResponseError: + # will most likely raise a ResponseError if index does not exist + # ignore and create index pass + except Exception: + raise_redisearch_error() idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)]) - # Based on e-commerce settings + # Index fields mentioned in e-commerce settings idx_fields = frappe.db.get_single_value("E Commerce Settings", "search_index_fields") idx_fields = idx_fields.split(",") if idx_fields else [] @@ -91,20 +104,20 @@ def to_search_field(field): return TextField(field) -@if_redisearch_loaded +@if_redisearch_enabled def insert_item_to_index(website_item_doc): # Insert item to index key = get_cache_key(website_item_doc.name) cache = frappe.cache() web_item = create_web_item_map(website_item_doc) - for k, v in web_item.items(): - super(RedisWrapper, cache).hset(make_key(key), k, v) + for field, value in web_item.items(): + super(RedisWrapper, cache).hset(make_key(key), field, value) insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name) -@if_redisearch_loaded +@if_redisearch_enabled def insert_to_name_ac(web_name, doc_name): ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=frappe.cache()) ac.add_suggestions(Suggestion(web_name, payload=doc_name)) @@ -114,20 +127,20 @@ def create_web_item_map(website_item_doc): fields_to_index = get_fields_indexed() web_item = {} - for f in fields_to_index: - web_item[f] = website_item_doc.get(f) or "" + for field in fields_to_index: + web_item[field] = website_item_doc.get(field) or "" return web_item -@if_redisearch_loaded +@if_redisearch_enabled def update_index_for_item(website_item_doc): # Reinsert to Cache insert_item_to_index(website_item_doc) define_autocomplete_dictionary() -@if_redisearch_loaded +@if_redisearch_enabled def delete_item_from_index(website_item_doc): cache = frappe.cache() key = get_cache_key(website_item_doc.name) @@ -135,13 +148,13 @@ def delete_item_from_index(website_item_doc): try: cache.delete(key) except Exception: - return False + raise_redisearch_error() delete_from_ac_dict(website_item_doc) return True -@if_redisearch_loaded +@if_redisearch_enabled def delete_from_ac_dict(website_item_doc): """Removes this items's name from autocomplete dictionary""" cache = frappe.cache() @@ -149,40 +162,60 @@ def delete_from_ac_dict(website_item_doc): name_ac.delete(website_item_doc.web_item_name) -@if_redisearch_loaded +@if_redisearch_enabled def define_autocomplete_dictionary(): - """Creates an autocomplete search dictionary for `name`. - Also creats autocomplete dictionary for `categories` if - checked in E Commerce Settings""" + """ + Defines/Redefines an autocomplete search dictionary for Website Item Name. + Also creats autocomplete dictionary for Published Item Groups. + """ cache = frappe.cache() - name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) - cat_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache) - - ac_categories = frappe.db.get_single_value( - "E Commerce Settings", "show_categories_in_search_autocomplete" - ) + item_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache) + item_group_ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=cache) # Delete both autocomplete dicts try: cache.delete(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE)) cache.delete(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE)) except Exception: - return False + raise_redisearch_error() + create_items_autocomplete_dict(autocompleter=item_ac) + create_item_groups_autocomplete_dict(autocompleter=item_group_ac) + + +@if_redisearch_enabled +def create_items_autocomplete_dict(autocompleter): + "Add items as suggestions in Autocompleter." items = frappe.get_all( "Website Item", fields=["web_item_name", "item_group"], filters={"published": 1} ) for item in items: - name_ac.add_suggestions(Suggestion(item.web_item_name)) - if ac_categories and item.item_group: - cat_ac.add_suggestions(Suggestion(item.item_group)) - - return True + autocompleter.add_suggestions(Suggestion(item.web_item_name)) -@if_redisearch_loaded +@if_redisearch_enabled +def create_item_groups_autocomplete_dict(autocompleter): + "Add item groups with weightage as suggestions in Autocompleter." + published_item_groups = frappe.get_all( + "Item Group", fields=["name", "route", "weightage"], filters={"show_in_website": 1} + ) + if not published_item_groups: + return + + for item_group in published_item_groups: + payload = json.dumps({"name": item_group.name, "route": item_group.route}) + autocompleter.add_suggestions( + Suggestion( + string=item_group.name, + score=frappe.utils.flt(item_group.weightage) or 1.0, + payload=payload, # additional info that can be retrieved later + ) + ) + + +@if_redisearch_enabled def reindex_all_web_items(): items = frappe.get_all("Website Item", fields=get_fields_indexed(), filters={"published": True}) @@ -191,8 +224,8 @@ def reindex_all_web_items(): web_item = create_web_item_map(item) key = make_key(get_cache_key(item.name)) - for k, v in web_item.items(): - super(RedisWrapper, cache).hset(key, k, v) + for field, value in web_item.items(): + super(RedisWrapper, cache).hset(key, field, value) def get_cache_key(name): @@ -210,7 +243,12 @@ def get_fields_indexed(): return fields_to_index -# TODO: Remove later -# # Figure out a way to run this at startup -define_autocomplete_dictionary() -create_website_items_index() +def raise_redisearch_error(): + "Create an Error Log and raise error." + traceback = frappe.get_traceback() + log = frappe.log_error(traceback, frappe._("Redisearch Error")) + log_link = frappe.utils.get_link_to_form("Error Log", log.name) + + frappe.throw( + msg=_("Something went wrong. Check {0}").format(log_link), title=_("Redisearch Error") + ) diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py index 98408afab64..27479a5e81f 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py @@ -39,11 +39,15 @@ class LeaveAllocation(Document): def validate(self): self.validate_period() self.validate_allocation_overlap() - self.validate_back_dated_allocation() - self.set_total_leaves_allocated() - self.validate_total_leaves_allocated() self.validate_lwp() set_employee_name(self) + self.set_total_leaves_allocated() + self.validate_leave_days_and_dates() + + def validate_leave_days_and_dates(self): + # all validations that should run on save as well as on update after submit + self.validate_back_dated_allocation() + self.validate_total_leaves_allocated() self.validate_leave_allocation_days() def validate_leave_allocation_days(self): @@ -56,14 +60,19 @@ class LeaveAllocation(Document): leave_allocated = 0 if leave_period: leave_allocated = get_leave_allocation_for_period( - self.employee, self.leave_type, leave_period[0].from_date, leave_period[0].to_date + self.employee, + self.leave_type, + leave_period[0].from_date, + leave_period[0].to_date, + exclude_allocation=self.name, ) leave_allocated += flt(self.new_leaves_allocated) if leave_allocated > max_leaves_allowed: frappe.throw( _( - "Total allocated leaves are more days than maximum allocation of {0} leave type for employee {1} in the period" - ).format(self.leave_type, self.employee) + "Total allocated leaves are more than maximum allocation allowed for {0} leave type for employee {1} in the period" + ).format(self.leave_type, self.employee), + OverAllocationError, ) def on_submit(self): @@ -84,6 +93,12 @@ class LeaveAllocation(Document): def on_update_after_submit(self): if self.has_value_changed("new_leaves_allocated"): self.validate_against_leave_applications() + + # recalculate total leaves allocated + self.total_leaves_allocated = flt(self.unused_leaves) + flt(self.new_leaves_allocated) + # run required validations again since total leaves are being updated + self.validate_leave_days_and_dates() + leaves_to_be_added = self.new_leaves_allocated - self.get_existing_leave_count() args = { "leaves": leaves_to_be_added, @@ -92,6 +107,7 @@ class LeaveAllocation(Document): "is_carry_forward": 0, } create_leave_ledger_entry(self, args, True) + self.db_update() def get_existing_leave_count(self): ledger_entries = frappe.get_all( @@ -279,27 +295,27 @@ def get_previous_allocation(from_date, leave_type, employee): ) -def get_leave_allocation_for_period(employee, leave_type, from_date, to_date): - leave_allocated = 0 - leave_allocations = frappe.db.sql( - """ - select employee, leave_type, from_date, to_date, total_leaves_allocated - from `tabLeave Allocation` - where employee=%(employee)s and leave_type=%(leave_type)s - 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)) - """, - {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type}, - as_dict=1, - ) +def get_leave_allocation_for_period( + employee, leave_type, from_date, to_date, exclude_allocation=None +): + from frappe.query_builder.functions import Sum - if leave_allocations: - for leave_alloc in leave_allocations: - leave_allocated += leave_alloc.total_leaves_allocated - - return leave_allocated + Allocation = frappe.qb.DocType("Leave Allocation") + return ( + frappe.qb.from_(Allocation) + .select(Sum(Allocation.total_leaves_allocated).as_("total_allocated_leaves")) + .where( + (Allocation.employee == employee) + & (Allocation.leave_type == leave_type) + & (Allocation.docstatus == 1) + & (Allocation.name != exclude_allocation) + & ( + (Allocation.from_date.between(from_date, to_date)) + | (Allocation.to_date.between(from_date, to_date)) + | ((Allocation.from_date < from_date) & (Allocation.to_date > to_date)) + ) + ) + ).run()[0][0] or 0.0 @frappe.whitelist() diff --git a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py index a53d4a82ba6..dde52d7ad8e 100644 --- a/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py +++ b/erpnext/hr/doctype/leave_allocation/test_leave_allocation.py @@ -1,24 +1,26 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, add_months, getdate, nowdate import erpnext from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.leave_allocation.leave_allocation import ( + BackDatedAllocationError, + OverAllocationError, +) from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type -class TestLeaveAllocation(unittest.TestCase): - @classmethod - def setUpClass(cls): - frappe.db.sql("delete from `tabLeave Period`") +class TestLeaveAllocation(FrappeTestCase): + def setUp(self): + frappe.db.delete("Leave Period") + frappe.db.delete("Leave Allocation") - emp_id = make_employee("test_emp_leave_allocation@salary.com") - cls.employee = frappe.get_doc("Employee", emp_id) - - def tearDown(self): - frappe.db.rollback() + emp_id = make_employee("test_emp_leave_allocation@salary.com", company="_Test Company") + self.employee = frappe.get_doc("Employee", emp_id) def test_overlapping_allocation(self): leaves = [ @@ -65,7 +67,7 @@ class TestLeaveAllocation(unittest.TestCase): # invalid period self.assertRaises(frappe.ValidationError, doc.save) - def test_allocated_leave_days_over_period(self): + def test_validation_for_over_allocation(self): doc = frappe.get_doc( { "doctype": "Leave Allocation", @@ -80,7 +82,135 @@ class TestLeaveAllocation(unittest.TestCase): ) # allocated leave more than period - self.assertRaises(frappe.ValidationError, doc.save) + self.assertRaises(OverAllocationError, doc.save) + + def test_validation_for_over_allocation_post_submission(self): + allocation = frappe.get_doc( + { + "doctype": "Leave Allocation", + "__islocal": 1, + "employee": self.employee.name, + "employee_name": self.employee.employee_name, + "leave_type": "_Test Leave Type", + "from_date": getdate("2015-09-1"), + "to_date": getdate("2015-09-30"), + "new_leaves_allocated": 15, + } + ).submit() + allocation.reload() + # allocated leaves more than period after submission + allocation.new_leaves_allocated = 35 + self.assertRaises(OverAllocationError, allocation.save) + + def test_validation_for_over_allocation_based_on_leave_setup(self): + frappe.delete_doc_if_exists("Leave Period", "Test Allocation Period") + leave_period = frappe.get_doc( + dict( + name="Test Allocation Period", + doctype="Leave Period", + from_date=add_months(nowdate(), -6), + to_date=add_months(nowdate(), 6), + company="_Test Company", + is_active=1, + ) + ).insert() + + leave_type = create_leave_type(leave_type_name="_Test Allocation Validation", is_carry_forward=1) + leave_type.max_leaves_allowed = 25 + leave_type.save() + + # 15 leaves allocated in this period + allocation = create_leave_allocation( + leave_type=leave_type.name, + employee=self.employee.name, + employee_name=self.employee.employee_name, + from_date=leave_period.from_date, + to_date=nowdate(), + ) + allocation.submit() + + # trying to allocate additional 15 leaves + allocation = create_leave_allocation( + leave_type=leave_type.name, + employee=self.employee.name, + employee_name=self.employee.employee_name, + from_date=add_days(nowdate(), 1), + to_date=leave_period.to_date, + ) + self.assertRaises(OverAllocationError, allocation.save) + + def test_validation_for_over_allocation_based_on_leave_setup_post_submission(self): + frappe.delete_doc_if_exists("Leave Period", "Test Allocation Period") + leave_period = frappe.get_doc( + dict( + name="Test Allocation Period", + doctype="Leave Period", + from_date=add_months(nowdate(), -6), + to_date=add_months(nowdate(), 6), + company="_Test Company", + is_active=1, + ) + ).insert() + + leave_type = create_leave_type(leave_type_name="_Test Allocation Validation", is_carry_forward=1) + leave_type.max_leaves_allowed = 30 + leave_type.save() + + # 15 leaves allocated + allocation = create_leave_allocation( + leave_type=leave_type.name, + employee=self.employee.name, + employee_name=self.employee.employee_name, + from_date=leave_period.from_date, + to_date=nowdate(), + ) + allocation.submit() + allocation.reload() + + # allocate additional 15 leaves + allocation = create_leave_allocation( + leave_type=leave_type.name, + employee=self.employee.name, + employee_name=self.employee.employee_name, + from_date=add_days(nowdate(), 1), + to_date=leave_period.to_date, + ) + allocation.submit() + allocation.reload() + + # trying to allocate 25 leaves in 2nd alloc within leave period + # total leaves = 40 which is more than `max_leaves_allowed` setting i.e. 30 + allocation.new_leaves_allocated = 25 + self.assertRaises(OverAllocationError, allocation.save) + + def test_validate_back_dated_allocation_update(self): + leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1) + leave_type.save() + + # initial leave allocation = 15 + leave_allocation = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, + leave_type="_Test_CF_leave", + from_date=add_months(nowdate(), -12), + to_date=add_months(nowdate(), -1), + carry_forward=0, + ) + leave_allocation.submit() + + # new_leaves = 15, carry_forwarded = 10 + leave_allocation_1 = create_leave_allocation( + employee=self.employee.name, + employee_name=self.employee.employee_name, + leave_type="_Test_CF_leave", + carry_forward=1, + ) + leave_allocation_1.submit() + + # try updating initial leave allocation + leave_allocation.reload() + leave_allocation.new_leaves_allocated = 20 + self.assertRaises(BackDatedAllocationError, leave_allocation.save) def test_carry_forward_calculation(self): leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1) @@ -108,8 +238,10 @@ class TestLeaveAllocation(unittest.TestCase): carry_forward=1, ) leave_allocation_1.submit() + leave_allocation_1.reload() self.assertEqual(leave_allocation_1.unused_leaves, 10) + self.assertEqual(leave_allocation_1.total_leaves_allocated, 25) leave_allocation_1.cancel() @@ -197,9 +329,12 @@ class TestLeaveAllocation(unittest.TestCase): employee=self.employee.name, employee_name=self.employee.employee_name ) leave_allocation.submit() + leave_allocation.reload() self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 40 leave_allocation.submit() + leave_allocation.reload() self.assertTrue(leave_allocation.total_leaves_allocated, 40) def test_leave_subtraction_after_submit(self): @@ -207,9 +342,12 @@ class TestLeaveAllocation(unittest.TestCase): employee=self.employee.name, employee_name=self.employee.employee_name ) leave_allocation.submit() + leave_allocation.reload() self.assertTrue(leave_allocation.total_leaves_allocated, 15) + leave_allocation.new_leaves_allocated = 10 leave_allocation.submit() + leave_allocation.reload() self.assertTrue(leave_allocation.total_leaves_allocated, 10) def test_validation_against_leave_application_after_submit(self): diff --git a/erpnext/regional/india/e_invoice/einvoice.js b/erpnext/regional/india/e_invoice/einvoice.js index 348f0c6feed..17b018c65b4 100644 --- a/erpnext/regional/india/e_invoice/einvoice.js +++ b/erpnext/regional/india/e_invoice/einvoice.js @@ -105,6 +105,30 @@ erpnext.setup_einvoice_actions = (doctype) => { }, primary_action_label: __('Submit') }); + d.fields_dict.transporter.df.onchange = function () { + const transporter = d.fields_dict.transporter.value; + if (transporter) { + frappe.db.get_value('Supplier', transporter, ['gst_transporter_id', 'supplier_name']) + .then(({ message }) => { + d.set_value('gst_transporter_id', message.gst_transporter_id); + d.set_value('transporter_name', message.supplier_name); + }); + } else { + d.set_value('gst_transporter_id', ''); + d.set_value('transporter_name', ''); + } + }; + d.fields_dict.driver.df.onchange = function () { + const driver = d.fields_dict.driver.value; + if (driver) { + frappe.db.get_value('Driver', driver, ['full_name']) + .then(({ message }) => { + d.set_value('driver_name', message.full_name); + }); + } else { + d.set_value('driver_name', ''); + } + }; d.show(); }; @@ -153,7 +177,6 @@ const get_ewaybill_fields = (frm) => { 'fieldname': 'gst_transporter_id', 'label': 'GST Transporter ID', 'fieldtype': 'Data', - 'fetch_from': 'transporter.gst_transporter_id', 'default': frm.doc.gst_transporter_id }, { @@ -189,9 +212,9 @@ const get_ewaybill_fields = (frm) => { 'fieldname': 'transporter_name', 'label': 'Transporter Name', 'fieldtype': 'Data', - 'fetch_from': 'transporter.name', 'read_only': 1, - 'default': frm.doc.transporter_name + 'default': frm.doc.transporter_name, + 'depends_on': 'transporter' }, { 'fieldname': 'mode_of_transport', @@ -206,7 +229,8 @@ const get_ewaybill_fields = (frm) => { 'fieldtype': 'Data', 'fetch_from': 'driver.full_name', 'read_only': 1, - 'default': frm.doc.driver_name + 'default': frm.doc.driver_name, + 'depends_on': 'driver' }, { 'fieldname': 'lr_date', diff --git a/erpnext/regional/india/e_invoice/utils.py b/erpnext/regional/india/e_invoice/utils.py index cbdec564ba9..8fd9c1c43d2 100644 --- a/erpnext/regional/india/e_invoice/utils.py +++ b/erpnext/regional/india/e_invoice/utils.py @@ -387,7 +387,7 @@ def update_other_charges( def get_payment_details(invoice): payee_name = invoice.company - mode_of_payment = ", ".join([d.mode_of_payment for d in invoice.payments]) + mode_of_payment = "" paid_amount = invoice.base_paid_amount outstanding_amount = invoice.outstanding_amount diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json index 89be607d047..19b6ef27ac3 100644 --- a/erpnext/setup/doctype/company/test_records.json +++ b/erpnext/setup/doctype/company/test_records.json @@ -8,7 +8,8 @@ "domain": "Manufacturing", "chart_of_accounts": "Standard", "default_holiday_list": "_Test Holiday List", - "enable_perpetual_inventory": 0 + "enable_perpetual_inventory": 0, + "allow_account_creation_against_child_company": 1 }, { "abbr": "_TC1", diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js index 9e8b3bd4637..23301a6a78e 100644 --- a/erpnext/stock/doctype/item/item.js +++ b/erpnext/stock/doctype/item/item.js @@ -55,10 +55,15 @@ frappe.ui.form.on("Item", { if (frm.doc.has_variants) { frm.set_intro(__("This Item is a Template and cannot be used in transactions. Item attributes will be copied over into the variants unless 'No Copy' is set"), true); + frm.add_custom_button(__("Show Variants"), function() { frappe.set_route("List", "Item", {"variant_of": frm.doc.name}); }, __("View")); + frm.add_custom_button(__("Item Variant Settings"), function() { + frappe.set_route("Form", "Item Variant Settings"); + }, __("View")); + frm.add_custom_button(__("Variant Details Report"), function() { frappe.set_route("query-report", "Item Variant Details", {"item": frm.doc.name}); }, __("View")); @@ -110,6 +115,13 @@ frappe.ui.form.on("Item", { } }); }, __('Actions')); + } else { + frm.add_custom_button(__("Website Item"), function() { + frappe.db.get_value("Website Item", {item_code: frm.doc.name}, "name", (d) => { + if (!d.name) frappe.throw(__("Website Item not found")); + frappe.set_route("Form", "Website Item", d.name); + }); + }, __("View")); } erpnext.item.edit_prices_button(frm); @@ -131,12 +143,6 @@ frappe.ui.form.on("Item", { frappe.set_route('Form', 'Item', new_item.name); }); - if(frm.doc.has_variants) { - frm.add_custom_button(__("Item Variant Settings"), function() { - frappe.set_route("Form", "Item Variant Settings"); - }, __("View")); - } - const stock_exists = (frm.doc.__onload && frm.doc.__onload.stock_exists) ? 1 : 0; diff --git a/erpnext/stock/doctype/item/item_dashboard.py b/erpnext/stock/doctype/item/item_dashboard.py index 33acf4bfd8a..3caed02d69b 100644 --- a/erpnext/stock/doctype/item/item_dashboard.py +++ b/erpnext/stock/doctype/item/item_dashboard.py @@ -32,5 +32,6 @@ def get_data(): {"label": _("Manufacture"), "items": ["Production Plan", "Work Order", "Item Manufacturer"]}, {"label": _("Traceability"), "items": ["Serial No", "Batch"]}, {"label": _("Move"), "items": ["Stock Entry"]}, + {"label": _("E-commerce"), "items": ["Website Item"]}, ], } diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py index bfbdd562921..f3faba4f8d5 100644 --- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py +++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py @@ -647,6 +647,45 @@ class TestPurchaseReceipt(FrappeTestCase): return_pr.cancel() pr.cancel() + def test_purchase_receipt_for_rejected_gle_without_accepted_warehouse(self): + from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse + + rejected_warehouse = "_Test Rejected Warehouse - TCP1" + if not frappe.db.exists("Warehouse", rejected_warehouse): + get_warehouse( + company="_Test Company with perpetual inventory", + abbr=" - TCP1", + warehouse_name="_Test Rejected Warehouse", + ).name + + pr = make_purchase_receipt( + company="_Test Company with perpetual inventory", + warehouse="Stores - TCP1", + received_qty=2, + rejected_qty=2, + rejected_warehouse=rejected_warehouse, + do_not_save=True, + ) + + pr.items[0].qty = 0.0 + pr.items[0].warehouse = "" + pr.submit() + + actual_qty = frappe.db.get_value( + "Stock Ledger Entry", + { + "voucher_type": "Purchase Receipt", + "voucher_no": pr.name, + "warehouse": pr.items[0].rejected_warehouse, + "is_cancelled": 0, + }, + "actual_qty", + ) + + self.assertEqual(actual_qty, 2) + self.assertFalse(pr.items[0].warehouse) + pr.cancel() + def test_purchase_return_for_serialized_items(self): def _check_serial_no_values(serial_no, field_values): serial_no = frappe.get_doc("Serial No", serial_no) diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py index 77a749ef9eb..3ed056f55e7 100644 --- a/erpnext/templates/pages/product_search.py +++ b/erpnext/templates/pages/product_search.py @@ -1,6 +1,8 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +import json + import frappe from frappe.utils import cint, cstr from redisearch import AutoCompleter, Client, Query @@ -9,7 +11,7 @@ from erpnext.e_commerce.redisearch_utils import ( WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE, WEBSITE_ITEM_INDEX, WEBSITE_ITEM_NAME_AUTOCOMPLETE, - is_search_module_loaded, + is_redisearch_enabled, make_key, ) from erpnext.e_commerce.shopping_cart.product_info import set_product_info_for_website @@ -74,8 +76,8 @@ def search(query): def product_search(query, limit=10, fuzzy_search=True): search_results = {"from_redisearch": True, "results": []} - if not is_search_module_loaded(): - # Redisearch module not loaded + if not is_redisearch_enabled(): + # Redisearch module not enabled search_results["from_redisearch"] = False search_results["results"] = get_product_data(query, 0, limit) return search_results @@ -86,6 +88,8 @@ def product_search(query, limit=10, fuzzy_search=True): red = frappe.cache() query = clean_up_query(query) + # TODO: Check perf/correctness with Suggestions & Query vs only Query + # TODO: Use Levenshtein Distance in Query (max=3) ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=red) client = Client(make_key(WEBSITE_ITEM_INDEX), conn=red) suggestions = ac.get_suggestions( @@ -121,8 +125,8 @@ def convert_to_dict(redis_search_doc): def get_category_suggestions(query): search_results = {"results": []} - if not is_search_module_loaded(): - # Redisearch module not loaded, query db + if not is_redisearch_enabled(): + # Redisearch module not enabled, query db categories = frappe.db.get_all( "Item Group", filters={"name": ["like", "%{0}%".format(query)], "show_in_website": 1}, @@ -135,8 +139,10 @@ def get_category_suggestions(query): return search_results ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=frappe.cache()) - suggestions = ac.get_suggestions(query, num=10) + suggestions = ac.get_suggestions(query, num=10, with_payloads=True) - search_results["results"] = [s.string for s in suggestions] + results = [json.loads(s.payload) for s in suggestions] + + search_results["results"] = results return search_results