+ `;
+
+ this.products_section.append(error ? error_section : no_results_section);
+ }
+
+ render_item_sub_categories(categories) {
+ if (categories && categories.length) {
+ let sub_group_html = `
+
`;
+
+ $("#product-listing").prepend(sub_group_html);
+ }
+ }
+
+ get_query_string(object) {
+ const url = new URLSearchParams();
+ for (let key in object) {
+ const value = object[key];
+ if (value) {
+ url.append(key, value);
+ }
+ }
+ return url.toString();
+ }
+
+ if_key_exists(obj) {
+ let exists = false;
+ for (let key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key) && obj[key]) {
+ exists = true;
+ break;
+ }
+ }
+ return exists ? obj : undefined;
+ }
+};
\ No newline at end of file
diff --git a/erpnext/e_commerce/redisearch_utils.py b/erpnext/e_commerce/redisearch_utils.py
new file mode 100644
index 0000000000..59c7f32fd4
--- /dev/null
+++ b/erpnext/e_commerce/redisearch_utils.py
@@ -0,0 +1,210 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe.utils.redis_wrapper import RedisWrapper
+from redisearch import AutoCompleter, Client, IndexDefinition, Suggestion, TagField, TextField
+
+WEBSITE_ITEM_INDEX = 'website_items_index'
+WEBSITE_ITEM_KEY_PREFIX = 'website_item:'
+WEBSITE_ITEM_NAME_AUTOCOMPLETE = 'website_items_name_dict'
+WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE = 'website_items_category_dict'
+
+def get_indexable_web_fields():
+ "Return valid fields from Website Item that can be searched for."
+ web_item_meta = frappe.get_meta("Website Item", cached=True)
+ valid_fields = filter(
+ lambda df: df.fieldtype in ("Link", "Table MultiSelect", "Data", "Small Text", "Text Editor"),
+ web_item_meta.fields)
+
+ return [df.fieldname for df in valid_fields]
+
+def is_search_module_loaded():
+ try:
+ cache = frappe.cache()
+ out = cache.execute_command('MODULE LIST')
+
+ parsed_output = " ".join(
+ (" ".join([s.decode() for s in o if not isinstance(s, int)]) for o in out)
+ )
+ return "search" in parsed_output
+ except Exception:
+ return False
+
+def if_redisearch_loaded(function):
+ "Decorator to check if Redisearch is loaded."
+ def wrapper(*args, **kwargs):
+ if is_search_module_loaded():
+ func = function(*args, **kwargs)
+ return func
+ return
+
+ return wrapper
+
+def make_key(key):
+ return "{0}|{1}".format(frappe.conf.db_name, key).encode('utf-8')
+
+@if_redisearch_loaded
+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:
+ pass
+
+ idx_def = IndexDefinition([make_key(WEBSITE_ITEM_KEY_PREFIX)])
+
+ # Based on 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 []
+
+ if 'web_item_name' in idx_fields:
+ idx_fields.remove('web_item_name')
+
+ idx_fields = list(map(to_search_field, idx_fields))
+
+ client.create_index(
+ [TextField("web_item_name", sortable=True)] + idx_fields,
+ definition=idx_def,
+ )
+
+ reindex_all_web_items()
+ define_autocomplete_dictionary()
+
+def to_search_field(field):
+ if field == "tags":
+ return TagField("tags", separator=",")
+
+ return TextField(field)
+
+@if_redisearch_loaded
+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)
+
+ insert_to_name_ac(website_item_doc.web_item_name, website_item_doc.name)
+
+@if_redisearch_loaded
+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))
+
+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 ''
+
+ return web_item
+
+@if_redisearch_loaded
+def update_index_for_item(website_item_doc):
+ # Reinsert to Cache
+ insert_item_to_index(website_item_doc)
+ define_autocomplete_dictionary()
+
+@if_redisearch_loaded
+def delete_item_from_index(website_item_doc):
+ cache = frappe.cache()
+ key = get_cache_key(website_item_doc.name)
+
+ try:
+ cache.delete(key)
+ except Exception:
+ return False
+
+ delete_from_ac_dict(website_item_doc)
+ return True
+
+@if_redisearch_loaded
+def delete_from_ac_dict(website_item_doc):
+ '''Removes this items's name from autocomplete dictionary'''
+ cache = frappe.cache()
+ name_ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=cache)
+ name_ac.delete(website_item_doc.web_item_name)
+
+@if_redisearch_loaded
+def define_autocomplete_dictionary():
+ """Creates an autocomplete search dictionary for `name`.
+ Also creats autocomplete dictionary for `categories` if
+ checked in E Commerce Settings"""
+
+ 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'
+ )
+
+ # 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
+
+ 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
+
+@if_redisearch_loaded
+def reindex_all_web_items():
+ items = frappe.get_all(
+ 'Website Item',
+ fields=get_fields_indexed(),
+ filters={"published": True}
+ )
+
+ cache = frappe.cache()
+ for item in 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)
+
+def get_cache_key(name):
+ name = frappe.scrub(name)
+ return f"{WEBSITE_ITEM_KEY_PREFIX}{name}"
+
+def get_fields_indexed():
+ fields_to_index = frappe.db.get_single_value(
+ 'E Commerce Settings',
+ 'search_index_fields'
+ )
+ fields_to_index = fields_to_index.split(',') if fields_to_index else []
+
+ mandatory_fields = ['name', 'web_item_name', 'route', 'thumbnail', 'ranking']
+ fields_to_index = fields_to_index + mandatory_fields
+
+ return fields_to_index
+
+# TODO: Remove later
+# # Figure out a way to run this at startup
+define_autocomplete_dictionary()
+create_website_items_index()
diff --git a/erpnext/e_commerce/shopping_cart/__init__.py b/erpnext/e_commerce/shopping_cart/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py
similarity index 88%
rename from erpnext/shopping_cart/cart.py
rename to erpnext/e_commerce/shopping_cart/cart.py
index ebbe233ca3..458cf69af7 100644
--- a/erpnext/shopping_cart/cart.py
+++ b/erpnext/e_commerce/shopping_cart/cart.py
@@ -1,7 +1,6 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
import frappe
import frappe.defaults
from frappe import _, throw
@@ -11,20 +10,20 @@ from frappe.utils import cint, cstr, flt, get_fullname
from frappe.utils.nestedset import get_root_of
from erpnext.accounts.utils import get_account_name
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
get_shopping_cart_settings,
)
-from erpnext.utilities.product import get_qty_in_stock
+from erpnext.utilities.product import get_web_item_qty_in_stock
class WebsitePriceListMissingError(frappe.ValidationError):
pass
def set_cart_count(quotation=None):
- if cint(frappe.db.get_singles_value("Shopping Cart Settings", "enabled")):
+ if cint(frappe.db.get_singles_value("E Commerce Settings", "enabled")):
if not quotation:
quotation = _get_cart_quotation()
- cart_count = cstr(len(quotation.get("items")))
+ cart_count = cstr(cint(quotation.get("total_qty")))
if hasattr(frappe.local, "cookie_manager"):
frappe.local.cookie_manager.set_cookie("cart_count", cart_count)
@@ -48,7 +47,7 @@ def get_cart_quotation(doc=None):
"shipping_addresses": get_shipping_addresses(party),
"billing_addresses": get_billing_addresses(party),
"shipping_rules": get_applicable_shipping_rules(party),
- "cart_settings": frappe.get_cached_doc("Shopping Cart Settings")
+ "cart_settings": frappe.get_cached_doc("E Commerce Settings")
}
@frappe.whitelist()
@@ -72,7 +71,7 @@ def get_billing_addresses(party=None):
@frappe.whitelist()
def place_order():
quotation = _get_cart_quotation()
- cart_settings = frappe.db.get_value("Shopping Cart Settings", None,
+ cart_settings = frappe.db.get_value("E Commerce Settings", None,
["company", "allow_items_not_in_stock"], as_dict=1)
quotation.company = cart_settings.company
@@ -92,13 +91,19 @@ def place_order():
if not cint(cart_settings.allow_items_not_in_stock):
for item in sales_order.get("items"):
- item.reserved_warehouse, is_stock_item = frappe.db.get_value("Item",
- item.item_code, ["website_warehouse", "is_stock_item"])
+ item.warehouse = frappe.db.get_value(
+ "Website Item",
+ {
+ "item_code": item.item_code
+ },
+ "website_warehouse"
+ )
+ is_stock_item = frappe.db.get_value("Item", item.item_code, "is_stock_item")
if is_stock_item:
- item_stock = get_qty_in_stock(item.item_code, "website_warehouse")
+ item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse")
if not cint(item_stock.in_stock):
- throw(_("{1} Not in Stock").format(item.item_code))
+ throw(_("{0} Not in Stock").format(item.item_code))
if item.qty > item_stock.stock_qty[0][0]:
throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code))
@@ -156,19 +161,19 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False):
set_cart_count(quotation)
- context = get_cart_quotation(quotation)
-
if cint(with_items):
+ context = get_cart_quotation(quotation)
return {
"items": frappe.render_template("templates/includes/cart/cart_items.html",
context),
- "taxes": frappe.render_template("templates/includes/order/order_taxes.html",
+ "total": frappe.render_template("templates/includes/cart/cart_items_total.html",
context),
+ "taxes_and_totals": frappe.render_template("templates/includes/cart/cart_payment_summary.html",
+ context)
}
else:
return {
- 'name': quotation.name,
- 'shopping_cart_menu': get_shopping_cart_menu(context)
+ 'name': quotation.name
}
@frappe.whitelist()
@@ -265,13 +270,36 @@ def guess_territory():
territory = frappe.db.get_value("Territory", geoip_country)
return territory or \
- frappe.db.get_value("Shopping Cart Settings", None, "territory") or \
+ frappe.db.get_value("E Commerce Settings", None, "territory") or \
get_root_of("Territory")
def decorate_quotation_doc(doc):
for d in doc.get("items", []):
- d.update(frappe.db.get_value("Item", d.item_code,
- ["thumbnail", "website_image", "description", "route"], as_dict=True))
+ item_code = d.item_code
+ fields = ["web_item_name", "thumbnail", "website_image", "description", "route"]
+
+ # Variant Item
+ if not frappe.db.exists("Website Item", {"item_code": item_code}):
+ variant_data = frappe.db.get_values(
+ "Item",
+ filters={"item_code": item_code},
+ fieldname=["variant_of", "item_name", "image"],
+ as_dict=True
+ )[0]
+ item_code = variant_data.variant_of
+ fields = fields[1:]
+ d.web_item_name = variant_data.item_name
+
+ if variant_data.image: # get image from variant or template web item
+ d.thumbnail = variant_data.image
+ fields = fields[2:]
+
+ d.update(frappe.db.get_value(
+ "Website Item",
+ {"item_code": item_code},
+ fields,
+ as_dict=True)
+ )
return doc
@@ -288,7 +316,7 @@ def _get_cart_quotation(party=None):
if quotation:
qdoc = frappe.get_doc("Quotation", quotation[0].name)
else:
- company = frappe.db.get_value("Shopping Cart Settings", None, ["company"])
+ company = frappe.db.get_value("E Commerce Settings", None, ["company"])
qdoc = frappe.get_doc({
"doctype": "Quotation",
"naming_series": get_shopping_cart_settings().quotation_series or "QTN-CART-",
@@ -343,7 +371,7 @@ def apply_cart_settings(party=None, quotation=None):
if not quotation:
quotation = _get_cart_quotation(party)
- cart_settings = frappe.get_doc("Shopping Cart Settings")
+ cart_settings = frappe.get_doc("E Commerce Settings")
set_price_list_and_rate(quotation, cart_settings)
@@ -420,7 +448,7 @@ def get_party(user=None):
party_doctype = contact.links[0].link_doctype
party = contact.links[0].link_name
- cart_settings = frappe.get_doc("Shopping Cart Settings")
+ cart_settings = frappe.get_doc("E Commerce Settings")
debtors_account = ''
@@ -557,10 +585,20 @@ def get_shipping_rules(quotation=None, cart_settings=None):
if quotation.shipping_address_name:
country = frappe.db.get_value("Address", quotation.shipping_address_name, "country")
if country:
- shipping_rules = frappe.db.sql_list("""select distinct sr.name
- from `tabShipping Rule Country` src, `tabShipping Rule` sr
- where src.country = %s and
- sr.disabled != 1 and sr.name = src.parent""", country)
+ sr_country = frappe.qb.DocType("Shipping Rule Country")
+ sr = frappe.qb.DocType("Shipping Rule")
+ query = (
+ frappe.qb.from_(sr_country)
+ .join(sr).on(sr.name == sr_country.parent)
+ .select(sr.name)
+ .distinct()
+ .where(
+ (sr_country.country == country)
+ & (sr.disabled != 1)
+ )
+ )
+ result = query.run(as_list=True)
+ shipping_rules = [x[0] for x in result]
return shipping_rules
diff --git a/erpnext/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py
similarity index 52%
rename from erpnext/shopping_cart/product_info.py
rename to erpnext/e_commerce/shopping_cart/product_info.py
index 977f12fb9e..595fed01d2 100644
--- a/erpnext/shopping_cart/product_info.py
+++ b/erpnext/e_commerce/shopping_cart/product_info.py
@@ -1,15 +1,18 @@
-# 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
-
import frappe
-from erpnext.shopping_cart.cart import _get_cart_quotation, _set_price_list
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
get_shopping_cart_settings,
show_quantity_in_website,
)
-from erpnext.utilities.product import get_non_stock_item_status, get_price, get_qty_in_stock
+from erpnext.e_commerce.shopping_cart.cart import _get_cart_quotation, _set_price_list
+from erpnext.utilities.product import (
+ get_non_stock_item_status,
+ get_price,
+ get_web_item_qty_in_stock,
+)
@frappe.whitelist(allow_guest=True)
@@ -18,7 +21,11 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False):
cart_settings = get_shopping_cart_settings()
if not cart_settings.enabled:
- return frappe._dict()
+ # return settings even if cart is disabled
+ return frappe._dict({
+ "product_info": {},
+ "cart_settings": cart_settings
+ })
cart_quotation = frappe._dict()
if not skip_quotation_creation:
@@ -26,25 +33,43 @@ def get_product_info_for_website(item_code, skip_quotation_creation=False):
selling_price_list = cart_quotation.get("selling_price_list") if cart_quotation else _set_price_list(cart_settings, None)
- price = get_price(
- item_code,
- selling_price_list,
- cart_settings.default_customer_group,
- cart_settings.company
- )
+ price = {}
+ if cart_settings.show_price:
+ is_guest = frappe.session.user == "Guest"
+ # Show Price if logged in.
+ # If not logged in, check if price is hidden for guest.
+ if not is_guest or not cart_settings.hide_price_for_guest:
+ price = get_price(
+ item_code,
+ selling_price_list,
+ cart_settings.default_customer_group,
+ cart_settings.company
+ )
- stock_status = get_qty_in_stock(item_code, "website_warehouse")
+ stock_status = None
+
+ if cart_settings.show_stock_availability:
+ on_backorder = frappe.get_cached_value("Website Item", {"item_code": item_code}, "on_backorder")
+ if on_backorder:
+ stock_status = frappe._dict({"on_backorder": True})
+ else:
+ stock_status = get_web_item_qty_in_stock(item_code, "website_warehouse")
product_info = {
"price": price,
- "stock_qty": stock_status.stock_qty,
- "in_stock": stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse"),
"qty": 0,
"uom": frappe.db.get_value("Item", item_code, "stock_uom"),
- "show_stock_qty": show_quantity_in_website(),
"sales_uom": frappe.db.get_value("Item", item_code, "sales_uom")
}
+ if stock_status:
+ if stock_status.on_backorder:
+ product_info["on_backorder"] = True
+ else:
+ product_info["stock_qty"] = stock_status.stock_qty
+ product_info["in_stock"] = stock_status.in_stock if stock_status.is_stock_item else get_non_stock_item_status(item_code, "website_warehouse")
+ product_info["show_stock_qty"] = show_quantity_in_website()
+
if product_info["price"]:
if frappe.session.user != "Guest":
item = cart_quotation.get({"item_code": item_code}) if cart_quotation else None
diff --git a/erpnext/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
similarity index 77%
rename from erpnext/shopping_cart/test_shopping_cart.py
rename to erpnext/e_commerce/shopping_cart/test_shopping_cart.py
index 60c220a087..8519e68d09 100644
--- a/erpnext/shopping_cart/test_shopping_cart.py
+++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
@@ -8,8 +8,14 @@ import frappe
from frappe.utils import add_months, nowdate
from erpnext.accounts.doctype.tax_rule.tax_rule import ConflictingTaxRule
-from erpnext.shopping_cart.cart import _get_cart_quotation, get_party, update_cart
-from erpnext.tests.utils import create_test_contact_and_address
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+from erpnext.e_commerce.shopping_cart.cart import (
+ _get_cart_quotation,
+ get_cart_quotation,
+ get_party,
+ update_cart,
+)
+from erpnext.tests.utils import change_settings, create_test_contact_and_address
# test_dependencies = ['Payment Terms Template']
@@ -27,8 +33,14 @@ class TestShoppingCart(unittest.TestCase):
frappe.set_user("Administrator")
create_test_contact_and_address()
self.enable_shopping_cart()
+ if not frappe.db.exists("Website Item", {"item_code": "_Test Item"}):
+ make_website_item(frappe.get_cached_doc("Item", "_Test Item"))
+
+ if not frappe.db.exists("Website Item", {"item_code": "_Test Item 2"}):
+ make_website_item(frappe.get_cached_doc("Item", "_Test Item 2"))
def tearDown(self):
+ frappe.db.rollback()
frappe.set_user("Administrator")
self.disable_shopping_cart()
@@ -123,6 +135,43 @@ class TestShoppingCart(unittest.TestCase):
self.remove_test_quotation(quotation)
+ @change_settings("E Commerce Settings",{
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India",
+ "show_price": 1
+ })
+ def test_add_item_variant_without_web_item_to_cart(self):
+ "Test adding Variants having no Website Items in cart via Template Web Item."
+ from erpnext.controllers.item_variant import create_variant
+ from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+ from erpnext.stock.doctype.item.test_item import make_item
+
+ template_item = make_item("Test-Tshirt-Temp", {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [
+ {"attribute": "Test Size"},
+ {"attribute": "Test Colour"}
+ ]
+ })
+ variant = create_variant("Test-Tshirt-Temp", {
+ "Test Size": "Small", "Test Colour": "Red"
+ })
+ variant.save()
+ make_website_item(template_item) # publish template not variant
+
+ update_cart("Test-Tshirt-Temp-S-R", 1)
+
+ cart = get_cart_quotation() # test if cart page gets data without errors
+ doc = cart.get("doc")
+
+ self.assertEqual(doc.get("items")[0].item_name, "Test-Tshirt-Temp-S-R")
+
+ # test if items are rendered without error
+ frappe.render_template("templates/includes/cart/cart_items.html", cart)
+
def create_tax_rule(self):
tax_rule = frappe.get_test_records("Tax Rule")[0]
try:
@@ -166,7 +215,7 @@ class TestShoppingCart(unittest.TestCase):
# helper functions
def enable_shopping_cart(self):
- settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings")
+ settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings")
settings.update({
"enabled": 1,
@@ -196,7 +245,7 @@ class TestShoppingCart(unittest.TestCase):
frappe.local.shopping_cart_settings = None
def disable_shopping_cart(self):
- settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings")
+ settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings")
settings.enabled = 0
settings.save()
frappe.local.shopping_cart_settings = None
diff --git a/erpnext/shopping_cart/utils.py b/erpnext/e_commerce/shopping_cart/utils.py
similarity index 84%
rename from erpnext/shopping_cart/utils.py
rename to erpnext/e_commerce/shopping_cart/utils.py
index 5f0c792381..e9745a44d7 100644
--- a/erpnext/shopping_cart/utils.py
+++ b/erpnext/e_commerce/shopping_cart/utils.py
@@ -1,10 +1,8 @@
-# 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
import frappe
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
- is_cart_enabled,
-)
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import is_cart_enabled
def show_cart_count():
@@ -23,7 +21,7 @@ def set_cart_count(login_manager):
return
if show_cart_count():
- from erpnext.shopping_cart.cart import set_cart_count
+ from erpnext.e_commerce.shopping_cart.cart import set_cart_count
# set_cart_count will try to fetch existing cart quotation
# or create one if non existent (and create a customer too)
diff --git a/erpnext/e_commerce/variant_selector/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/portal/product_configurator/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py
similarity index 82%
rename from erpnext/portal/product_configurator/item_variants_cache.py
rename to erpnext/e_commerce/variant_selector/item_variants_cache.py
index 636ae8d491..bb6b3ef37f 100644
--- a/erpnext/portal/product_configurator/item_variants_cache.py
+++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py
@@ -44,7 +44,7 @@ class ItemVariantsCacheManager:
val = frappe.cache().get_value('ordered_attribute_values_map')
if val: return val
- all_attribute_values = frappe.db.get_all('Item Attribute Value',
+ all_attribute_values = frappe.get_all('Item Attribute Value',
['attribute_value', 'idx', 'parent'], order_by='idx asc')
ordered_attribute_values_map = frappe._dict({})
@@ -57,22 +57,35 @@ class ItemVariantsCacheManager:
def build_cache(self):
parent_item_code = self.item_code
- attributes = [a.attribute for a in frappe.db.get_all('Item Variant Attribute',
- {'parent': parent_item_code}, ['attribute'], order_by='idx asc')
+ attributes = [
+ a.attribute for a in frappe.get_all(
+ 'Item Variant Attribute',
+ {'parent': parent_item_code},
+ ['attribute'],
+ order_by='idx asc'
+ )
]
- item_variants_data = frappe.db.get_all('Item Variant Attribute',
- {'variant_of': parent_item_code}, ['parent', 'attribute', 'attribute_value'],
+ # join with Website Item
+ item_variants_data = frappe.get_all(
+ 'Item Variant Attribute',
+ {'variant_of': parent_item_code},
+ ['parent', 'attribute', 'attribute_value'],
order_by='name',
as_list=1
)
- disabled_items = set([i.name for i in frappe.db.get_all('Item', {'disabled': 1})])
+ disabled_items = set(
+ [i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
+ )
- attribute_value_item_map = frappe._dict({})
- item_attribute_value_map = frappe._dict({})
+ attribute_value_item_map = frappe._dict()
+ item_attribute_value_map = frappe._dict()
+ # dont consider variants that are disabled
+ # pull all other variants
item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
+
for row in item_variants_data:
item_code, attribute, attribute_value = row
# (attr, value) => [item1, item2]
diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py
new file mode 100644
index 0000000000..b83961e6e1
--- /dev/null
+++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py
@@ -0,0 +1,117 @@
+import frappe
+
+from erpnext.controllers.item_variant import create_variant
+from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
+ setup_e_commerce_settings,
+)
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+from erpnext.e_commerce.variant_selector.utils import get_next_attribute_and_values
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.tests.utils import ERPNextTestCase
+
+test_dependencies = ["Item"]
+
+class TestVariantSelector(ERPNextTestCase):
+
+ @classmethod
+ def setUpClass(cls):
+ template_item = make_item("Test-Tshirt-Temp", {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [
+ {"attribute": "Test Size"},
+ {"attribute": "Test Colour"}
+ ]
+ })
+
+ # create L-R, L-G, M-R, M-G and S-R
+ for size in ("Large", "Medium",):
+ for colour in ("Red", "Green",):
+ variant = create_variant("Test-Tshirt-Temp", {
+ "Test Size": size, "Test Colour": colour
+ })
+ variant.save()
+
+ variant = create_variant("Test-Tshirt-Temp", {
+ "Test Size": "Small", "Test Colour": "Red"
+ })
+ variant.save()
+
+ make_website_item(template_item) # publish template not variants
+
+ def test_item_attributes(self):
+ """
+ Test if the right attributes are fetched in the popup.
+ (Attributes must only come from active items)
+
+ Attribute selection must not be linked to Website Items.
+ """
+ from erpnext.e_commerce.variant_selector.utils import get_attributes_and_values
+
+ attr_data = get_attributes_and_values("Test-Tshirt-Temp")
+
+ self.assertEqual(attr_data[0]["attribute"], "Test Size")
+ self.assertEqual(attr_data[1]["attribute"], "Test Colour")
+ self.assertEqual(len(attr_data[0]["values"]), 3) # ['Small', 'Medium', 'Large']
+ self.assertEqual(len(attr_data[1]["values"]), 2) # ['Red', 'Green']
+
+ # disable small red tshirt, now there are no small tshirts.
+ # but there are some red tshirts
+ small_variant = frappe.get_doc("Item", "Test-Tshirt-Temp-S-R")
+ small_variant.disabled = 1
+ small_variant.save() # trigger cache rebuild
+
+ attr_data = get_attributes_and_values("Test-Tshirt-Temp")
+
+ # Only L and M attribute values must be fetched since S is disabled
+ self.assertEqual(len(attr_data[0]["values"]), 2) # ['Medium', 'Large']
+
+ # teardown
+ small_variant.disabled = 0
+ small_variant.save()
+
+ def test_next_item_variant_values(self):
+ """
+ Test if on selecting an attribute value, the next possible values
+ are filtered accordingly.
+ Values that dont apply should not be fetched.
+ E.g.
+ There is a ** Small-Red ** Tshirt. No other colour in this size.
+ On selecting ** Small **, only ** Red ** should be selectable next.
+ """
+ next_values = get_next_attribute_and_values("Test-Tshirt-Temp", selected_attributes={"Test Size": "Small"})
+ next_colours = next_values["valid_options_for_attributes"]["Test Colour"]
+ filtered_items = next_values["filtered_items"]
+
+ self.assertEqual(len(next_colours), 1)
+ self.assertEqual(next_colours.pop(), "Red")
+ self.assertEqual(len(filtered_items), 1)
+ self.assertEqual(filtered_items.pop(), "Test-Tshirt-Temp-S-R")
+
+ def test_exact_match_with_price(self):
+ """
+ Test price fetching and matching of variant without Website Item
+ """
+ from erpnext.e_commerce.doctype.website_item.test_website_item import make_web_item_price
+
+ frappe.set_user("Administrator")
+ setup_e_commerce_settings({
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India",
+ "show_price": 1
+ })
+
+ make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
+ next_values = get_next_attribute_and_values(
+ "Test-Tshirt-Temp",
+ selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
+ )
+ print(">>>>", next_values)
+ price_info = next_values["product_info"]["price"]
+
+ self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
+ self.assertEqual(next_values["exact_match"][0],"Test-Tshirt-Temp-S-R")
+ self.assertEqual(price_info["price_list_rate"], 100.0)
+ self.assertEqual(price_info["formatted_price_sales_uom"], "₹ 100.00")
\ No newline at end of file
diff --git a/erpnext/e_commerce/variant_selector/utils.py b/erpnext/e_commerce/variant_selector/utils.py
new file mode 100644
index 0000000000..33802737ef
--- /dev/null
+++ b/erpnext/e_commerce/variant_selector/utils.py
@@ -0,0 +1,218 @@
+import frappe
+from frappe.utils import cint
+
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
+ get_shopping_cart_settings,
+)
+from erpnext.e_commerce.shopping_cart.cart import _set_price_list
+from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager
+from erpnext.utilities.product import get_price
+
+
+def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
+ items = []
+
+ for attribute, values in attribute_filters.items():
+ attribute_values = values
+
+ if not isinstance(attribute_values, list):
+ attribute_values = [attribute_values]
+
+ if not attribute_values:
+ continue
+
+ wheres = []
+ query_values = []
+ for attribute_value in attribute_values:
+ wheres.append('( attribute = %s and attribute_value = %s )')
+ query_values += [attribute, attribute_value]
+
+ attribute_query = ' or '.join(wheres)
+
+ if template_item_code:
+ variant_of_query = 'AND t2.variant_of = %s'
+ query_values.append(template_item_code)
+ else:
+ variant_of_query = ''
+
+ query = '''
+ SELECT
+ t1.parent
+ FROM
+ `tabItem Variant Attribute` t1
+ WHERE
+ 1 = 1
+ AND (
+ {attribute_query}
+ )
+ AND EXISTS (
+ SELECT
+ 1
+ FROM
+ `tabItem` t2
+ WHERE
+ t2.name = t1.parent
+ {variant_of_query}
+ )
+ GROUP BY
+ t1.parent
+ ORDER BY
+ NULL
+ '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query)
+
+ item_codes = set([r[0] for r in frappe.db.sql(query, query_values)]) # nosemgrep
+ items.append(item_codes)
+
+ res = list(set.intersection(*items))
+
+ return res
+
+@frappe.whitelist(allow_guest=True)
+def get_attributes_and_values(item_code):
+ '''Build a list of attributes and their possible values.
+ This will ignore the values upon selection of which there cannot exist one item.
+ '''
+ item_cache = ItemVariantsCacheManager(item_code)
+ item_variants_data = item_cache.get_item_variants_data()
+
+ attributes = get_item_attributes(item_code)
+ attribute_list = [a.attribute for a in attributes]
+
+ valid_options = {}
+ for item_code, attribute, attribute_value in item_variants_data:
+ if attribute in attribute_list:
+ valid_options.setdefault(attribute, set()).add(attribute_value)
+
+ item_attribute_values = frappe.db.get_all('Item Attribute Value',
+ ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc')
+ ordered_attribute_value_map = frappe._dict()
+ for iv in item_attribute_values:
+ ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value)
+
+ # build attribute values in idx order
+ for attr in attributes:
+ valid_attribute_values = valid_options.get(attr.attribute, [])
+ ordered_values = ordered_attribute_value_map.get(attr.attribute, [])
+ attr['values'] = [v for v in ordered_values if v in valid_attribute_values]
+
+ return attributes
+
+
+@frappe.whitelist(allow_guest=True)
+def get_next_attribute_and_values(item_code, selected_attributes):
+ '''Find the count of Items that match the selected attributes.
+ Also, find the attribute values that are not applicable for further searching.
+ If less than equal to 10 items are found, return item_codes of those items.
+ If one item is matched exactly, return item_code of that item.
+ '''
+ selected_attributes = frappe.parse_json(selected_attributes)
+
+ item_cache = ItemVariantsCacheManager(item_code)
+ item_variants_data = item_cache.get_item_variants_data()
+
+ attributes = get_item_attributes(item_code)
+ attribute_list = [a.attribute for a in attributes]
+ filtered_items = get_items_with_selected_attributes(item_code, selected_attributes)
+
+ next_attribute = None
+
+ for attribute in attribute_list:
+ if attribute not in selected_attributes:
+ next_attribute = attribute
+ break
+
+ valid_options_for_attributes = frappe._dict()
+
+ for a in attribute_list:
+ valid_options_for_attributes[a] = set()
+
+ selected_attribute = selected_attributes.get(a, None)
+ if selected_attribute:
+ # already selected attribute values are valid options
+ valid_options_for_attributes[a].add(selected_attribute)
+
+ for row in item_variants_data:
+ item_code, attribute, attribute_value = row
+ if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list:
+ valid_options_for_attributes[attribute].add(attribute_value)
+
+ optional_attributes = item_cache.get_optional_attributes()
+ exact_match = []
+ # search for exact match if all selected attributes are required attributes
+ if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)):
+ item_attribute_value_map = item_cache.get_item_attribute_value_map()
+ for item_code, attr_dict in item_attribute_value_map.items():
+ if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()):
+ exact_match.append(item_code)
+
+ filtered_items_count = len(filtered_items)
+
+ # get product info if exact match
+ # from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website
+ if exact_match:
+ cart_settings = get_shopping_cart_settings()
+ product_info = get_item_variant_price_dict(exact_match[0], cart_settings)
+
+ if product_info:
+ product_info["allow_items_not_in_stock"] = cint(cart_settings.allow_items_not_in_stock)
+ else:
+ product_info = None
+
+ return {
+ 'next_attribute': next_attribute,
+ 'valid_options_for_attributes': valid_options_for_attributes,
+ 'filtered_items_count': filtered_items_count,
+ 'filtered_items': filtered_items if filtered_items_count < 10 else [],
+ 'exact_match': exact_match,
+ 'product_info': product_info
+ }
+
+
+def get_items_with_selected_attributes(item_code, selected_attributes):
+ item_cache = ItemVariantsCacheManager(item_code)
+ attribute_value_item_map = item_cache.get_attribute_value_item_map()
+
+ items = []
+ for attribute, value in selected_attributes.items():
+ filtered_items = attribute_value_item_map.get((attribute, value), [])
+ items.append(set(filtered_items))
+
+ return set.intersection(*items)
+
+# utilities
+
+def get_item_attributes(item_code):
+ attributes = frappe.db.get_all('Item Variant Attribute',
+ fields=['attribute'],
+ filters={
+ 'parenttype': 'Item',
+ 'parent': item_code
+ },
+ order_by='idx asc'
+ )
+
+ optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes()
+
+ for a in attributes:
+ if a.attribute in optional_attributes:
+ a.optional = True
+
+ return attributes
+
+def get_item_variant_price_dict(item_code, cart_settings):
+ if cart_settings.enabled and cart_settings.show_price:
+ is_guest = frappe.session.user == "Guest"
+ # Show Price if logged in.
+ # If not logged in, check if price is hidden for guest.
+ if not is_guest or not cart_settings.hide_price_for_guest:
+ price_list = _set_price_list(cart_settings, None)
+ price = get_price(
+ item_code,
+ price_list,
+ cart_settings.default_customer_group,
+ cart_settings.company
+ )
+ return {"price": price}
+
+ return None
+
diff --git a/erpnext/e_commerce/web_template/__init__.py b/erpnext/e_commerce/web_template/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/e_commerce/web_template/hero_slider/__init__.py b/erpnext/e_commerce/web_template/hero_slider/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.html b/erpnext/e_commerce/web_template/hero_slider/hero_slider.html
similarity index 100%
rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.html
rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.html
diff --git a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json
similarity index 98%
rename from erpnext/shopping_cart/web_template/hero_slider/hero_slider.json
rename to erpnext/e_commerce/web_template/hero_slider/hero_slider.json
index 04fb1d2705..2b1807c965 100644
--- a/erpnext/shopping_cart/web_template/hero_slider/hero_slider.json
+++ b/erpnext/e_commerce/web_template/hero_slider/hero_slider.json
@@ -1,4 +1,5 @@
{
+ "__unsaved": 1,
"creation": "2020-11-17 15:21:51.207221",
"docstatus": 0,
"doctype": "Web Template",
@@ -273,9 +274,9 @@
}
],
"idx": 2,
- "modified": "2020-12-29 12:30:02.794994",
+ "modified": "2021-02-24 15:57:05.889709",
"modified_by": "Administrator",
- "module": "Shopping Cart",
+ "module": "E-commerce",
"name": "Hero Slider",
"owner": "Administrator",
"standard": 1,
diff --git a/erpnext/e_commerce/web_template/item_card_group/__init__.py b/erpnext/e_commerce/web_template/item_card_group/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html
similarity index 81%
rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.html
rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.html
index fe061d5f5f..07952f056a 100644
--- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.html
+++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.html
@@ -23,11 +23,10 @@
{%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%}
{%- set item = values['card_' + index + '_item'] -%}
{%- if item -%}
- {%- set item = frappe.get_doc("Item", item) -%}
+ {%- set web_item = frappe.get_doc("Website Item", item) -%}
{{ item_card(
- item.item_name, item.image, item.route, item.description,
- None, item.item_group, values['card_' + index + '_featured'],
- True, "Center"
+ web_item, is_featured=values['card_' + index + '_featured'],
+ is_full_width=True, align="Center"
) }}
{%- endif -%}
{%- endfor -%}
diff --git a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json
similarity index 84%
rename from erpnext/shopping_cart/web_template/item_card_group/item_card_group.json
rename to erpnext/e_commerce/web_template/item_card_group/item_card_group.json
index ad087b0470..ad9e2a7b24 100644
--- a/erpnext/shopping_cart/web_template/item_card_group/item_card_group.json
+++ b/erpnext/e_commerce/web_template/item_card_group/item_card_group.json
@@ -17,15 +17,12 @@
"reqd": 0
},
{
- "__unsaved": 1,
"fieldname": "primary_action_label",
"fieldtype": "Data",
"label": "Primary Action Label",
"reqd": 0
},
{
- "__islocal": 1,
- "__unsaved": 1,
"fieldname": "primary_action",
"fieldtype": "Data",
"label": "Primary Action",
@@ -40,8 +37,8 @@
{
"fieldname": "card_1_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -59,8 +56,8 @@
{
"fieldname": "card_2_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -79,8 +76,8 @@
{
"fieldname": "card_3_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -98,8 +95,8 @@
{
"fieldname": "card_4_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -117,8 +114,8 @@
{
"fieldname": "card_5_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -136,8 +133,8 @@
{
"fieldname": "card_6_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -155,8 +152,8 @@
{
"fieldname": "card_7_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -174,8 +171,8 @@
{
"fieldname": "card_8_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -193,8 +190,8 @@
{
"fieldname": "card_9_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -212,8 +209,8 @@
{
"fieldname": "card_10_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -231,8 +228,8 @@
{
"fieldname": "card_11_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -250,8 +247,8 @@
{
"fieldname": "card_12_item",
"fieldtype": "Link",
- "label": "Item",
- "options": "Item",
+ "label": "Website Item",
+ "options": "Website Item",
"reqd": 0
},
{
@@ -262,9 +259,9 @@
}
],
"idx": 0,
- "modified": "2020-11-19 18:48:52.633045",
+ "modified": "2021-12-21 14:44:59.821335",
"modified_by": "Administrator",
- "module": "Shopping Cart",
+ "module": "E-commerce",
"name": "Item Card Group",
"owner": "Administrator",
"standard": 1,
diff --git a/erpnext/e_commerce/web_template/product_card/__init__.py b/erpnext/e_commerce/web_template/product_card/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/e_commerce/web_template/product_card/product_card.html b/erpnext/e_commerce/web_template/product_card/product_card.html
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.json b/erpnext/e_commerce/web_template/product_card/product_card.json
similarity index 82%
rename from erpnext/shopping_cart/web_template/product_card/product_card.json
rename to erpnext/e_commerce/web_template/product_card/product_card.json
index 1059c1b251..2eb73741ef 100644
--- a/erpnext/shopping_cart/web_template/product_card/product_card.json
+++ b/erpnext/e_commerce/web_template/product_card/product_card.json
@@ -5,7 +5,6 @@
"doctype": "Web Template",
"fields": [
{
- "__unsaved": 1,
"fieldname": "item",
"fieldtype": "Link",
"label": "Item",
@@ -13,7 +12,6 @@
"reqd": 0
},
{
- "__unsaved": 1,
"fieldname": "featured",
"fieldtype": "Check",
"label": "Featured",
@@ -22,9 +20,9 @@
}
],
"idx": 0,
- "modified": "2020-11-17 15:33:34.982515",
+ "modified": "2021-02-24 16:05:17.926610",
"modified_by": "Administrator",
- "module": "Shopping Cart",
+ "module": "E-commerce",
"name": "Product Card",
"owner": "Administrator",
"standard": 1,
diff --git a/erpnext/e_commerce/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/web_template/product_category_cards/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html
similarity index 81%
rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html
rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html
index 06b76af901..6d75a8b1d5 100644
--- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.html
+++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.html
@@ -6,8 +6,15 @@
}) -%}
{% if image %}
-
+
+ {% else %}
+
+
+ {{ frappe.utils.get_abbr(title or '') }}
+
+
{% endif %}
+
{{ title or '' }}
diff --git a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json
similarity index 95%
rename from erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json
rename to erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json
index ba5f63b48b..0202165d08 100644
--- a/erpnext/shopping_cart/web_template/product_category_cards/product_category_cards.json
+++ b/erpnext/e_commerce/web_template/product_category_cards/product_category_cards.json
@@ -74,9 +74,9 @@
}
],
"idx": 0,
- "modified": "2020-11-18 17:26:28.726260",
+ "modified": "2021-02-24 16:03:33.835635",
"modified_by": "Administrator",
- "module": "Shopping Cart",
+ "module": "E-commerce",
"name": "Product Category Cards",
"owner": "Administrator",
"standard": 1,
diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py
index a23d49267e..4d0f3a9801 100644
--- a/erpnext/education/doctype/program_enrollment/program_enrollment.py
+++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py
@@ -6,6 +6,7 @@ import frappe
from frappe import _, msgprint
from frappe.desk.reportview import get_match_cond
from frappe.model.document import Document
+from frappe.query_builder.functions import Min
from frappe.utils import comma_and, get_link_to_form, getdate
@@ -60,8 +61,15 @@ class ProgramEnrollment(Document):
frappe.throw(_("Student is already enrolled."))
def update_student_joining_date(self):
- date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student)
- frappe.db.set_value("Student", self.student, "joining_date", date)
+ table = frappe.qb.DocType('Program Enrollment')
+ date = (
+ frappe.qb.from_(table)
+ .select(Min(table.enrollment_date).as_('enrollment_date'))
+ .where(table.student == self.student)
+ ).run(as_dict=True)
+
+ if date:
+ frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date)
def make_fee_records(self):
from erpnext.education.api import get_fee_components
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
index 03c1a1a0b5..29bc36f384 100644
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
+++ b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
@@ -149,7 +149,6 @@ def create_item_code(amazon_item_json, sku):
item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title
item.brand = new_brand
item.manufacturer = new_manufacturer
- item.web_long_description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title
item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 04f5793836..d99f23ed64 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -51,15 +51,15 @@ additional_print_settings = "erpnext.controllers.print_settings.get_print_settin
on_session_creation = [
"erpnext.portal.utils.create_customer_or_supplier",
- "erpnext.shopping_cart.utils.set_cart_count"
+ "erpnext.e_commerce.shopping_cart.utils.set_cart_count"
]
-on_logout = "erpnext.shopping_cart.utils.clear_cart_count"
+on_logout = "erpnext.e_commerce.shopping_cart.utils.clear_cart_count"
treeviews = ['Account', 'Cost Center', 'Warehouse', 'Item Group', 'Customer Group', 'Sales Person', 'Territory', 'Assessment Group', 'Department']
# website
-update_website_context = ["erpnext.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"]
-my_account_context = "erpnext.shopping_cart.utils.update_my_account_context"
+update_website_context = ["erpnext.e_commerce.shopping_cart.utils.update_website_context", "erpnext.education.doctype.education_settings.education_settings.update_website_context"]
+my_account_context = "erpnext.e_commerce.shopping_cart.utils.update_my_account_context"
webform_list_context = "erpnext.controllers.website_list_for_contact.get_webform_list_context"
calendars = ["Task", "Work Order", "Leave Application", "Sales Order", "Holiday List", "Course Schedule"]
@@ -73,7 +73,7 @@ domains = {
'Services': 'erpnext.domains.services',
}
-website_generators = ["Item Group", "Item", "BOM", "Sales Partner",
+website_generators = ["Item Group", "Website Item", "BOM", "Sales Partner",
"Job Opening", "Student Admission"]
website_context = {
@@ -237,10 +237,7 @@ doc_events = {
]
},
"Sales Taxes and Charges Template": {
- "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings"
- },
- "Website Settings": {
- "validate": "erpnext.portal.doctype.products_settings.products_settings.home_page_is_products"
+ "on_update": "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.validate_cart_settings"
},
"Tax Category": {
"validate": "erpnext.regional.india.utils.validate_tax_category"
@@ -344,7 +341,8 @@ scheduler_events = {
"erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts"
],
"hourly_long": [
- "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries"
+ "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
+ "erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction"
],
"daily": [
"erpnext.stock.reorder_item.reorder_item",
diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 559bd393e6..0bb66374d1 100644
--- a/erpnext/hr/doctype/employee/employee_reminders.py
+++ b/erpnext/hr/doctype/employee/employee_reminders.py
@@ -20,6 +20,7 @@ def send_reminders_in_advance_weekly():
send_advance_holiday_reminders("Weekly")
+
def send_reminders_in_advance_monthly():
to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders"))
frequency = frappe.db.get_single_value("HR Settings", "frequency")
@@ -28,6 +29,7 @@ def send_reminders_in_advance_monthly():
send_advance_holiday_reminders("Monthly")
+
def send_advance_holiday_reminders(frequency):
"""Send Holiday Reminders in Advance to Employees
`frequency` (str): 'Weekly' or 'Monthly'
@@ -42,7 +44,7 @@ def send_advance_holiday_reminders(frequency):
else:
return
- employees = frappe.db.get_all('Employee', pluck='name')
+ employees = frappe.db.get_all('Employee', filters={'status': 'Active'}, pluck='name')
for employee in employees:
holidays = get_holidays_for_employee(
employee,
@@ -51,10 +53,13 @@ def send_advance_holiday_reminders(frequency):
raise_exception=False
)
- if not (holidays is None):
- send_holidays_reminder_in_advance(employee, holidays)
+ send_holidays_reminder_in_advance(employee, holidays)
+
def send_holidays_reminder_in_advance(employee, holidays):
+ if not holidays:
+ return
+
employee_doc = frappe.get_doc('Employee', employee)
employee_email = get_employee_email(employee_doc)
frequency = frappe.db.get_single_value("HR Settings", "frequency")
@@ -101,6 +106,7 @@ def send_birthday_reminders():
reminder_text, message = get_birthday_reminder_text_and_message(others)
send_birthday_reminder(person_email, reminder_text, others, message)
+
def get_birthday_reminder_text_and_message(birthday_persons):
if len(birthday_persons) == 1:
birthday_person_text = birthday_persons[0]['name']
@@ -116,6 +122,7 @@ def get_birthday_reminder_text_and_message(birthday_persons):
return reminder_text, message
+
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -129,10 +136,12 @@ def send_birthday_reminder(recipients, reminder_text, birthday_persons, message)
header=_("Birthday Reminder 🎂")
)
+
def get_employees_who_are_born_today():
"""Get all employee born today & group them based on their company"""
return get_employees_having_an_event_today("birthday")
+
def get_employees_having_an_event_today(event_type):
"""Get all employee who have `event_type` today
& group them based on their company. `event_type`
@@ -210,13 +219,14 @@ def send_work_anniversary_reminders():
reminder_text, message = get_work_anniversary_reminder_text_and_message(others)
send_work_anniversary_reminder(person_email, reminder_text, others, message)
+
def get_work_anniversary_reminder_text_and_message(anniversary_persons):
if len(anniversary_persons) == 1:
anniversary_person = anniversary_persons[0]['name']
persons_name = anniversary_person
# Number of years completed at the company
completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year
- anniversary_person += f" completed {completed_years} years"
+ anniversary_person += f" completed {completed_years} year(s)"
else:
person_names_with_years = []
names = []
@@ -225,7 +235,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
names.append(person_text)
# Number of years completed at the company
completed_years = getdate().year - person['date_of_joining'].year
- person_text += f" completed {completed_years} years"
+ person_text += f" completed {completed_years} year(s)"
person_names_with_years.append(person_text)
# converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim
@@ -239,6 +249,7 @@ def get_work_anniversary_reminder_text_and_message(anniversary_persons):
return reminder_text, message
+
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -249,5 +260,5 @@ def send_work_anniversary_reminder(recipients, reminder_text, anniversary_person
anniversary_persons=anniversary_persons,
message=message,
),
- header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️")
+ header=_("Work Anniversary Reminder")
)
diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py
index 8a2da0866e..67cbea67e1 100644
--- a/erpnext/hr/doctype/employee/test_employee.py
+++ b/erpnext/hr/doctype/employee/test_employee.py
@@ -36,7 +36,7 @@ class TestEmployee(unittest.TestCase):
employee_doc.reload()
make_holiday_list()
- frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List")
+ frappe.db.set_value("Company", employee_doc.company, "default_holiday_list", "Salary Slip Test Holiday List")
frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""")
salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly",
diff --git a/erpnext/hr/doctype/employee/test_employee_reminders.py b/erpnext/hr/doctype/employee/test_employee_reminders.py
index 52c0098244..a4097ab9d1 100644
--- a/erpnext/hr/doctype/employee/test_employee_reminders.py
+++ b/erpnext/hr/doctype/employee/test_employee_reminders.py
@@ -5,10 +5,12 @@ import unittest
from datetime import timedelta
import frappe
-from frappe.utils import getdate
+from frappe.utils import add_months, getdate
+from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change
+from erpnext.hr.utils import get_holidays_for_employee
class TestEmployeeReminders(unittest.TestCase):
@@ -46,6 +48,24 @@ class TestEmployeeReminders(unittest.TestCase):
cls.test_employee = test_employee
cls.test_holiday_dates = test_holiday_dates
+ # Employee without holidays in this month/week
+ test_employee_2 = make_employee('test@empwithoutholiday.io', company="_Test Company")
+ test_employee_2 = frappe.get_doc('Employee', test_employee_2)
+
+ test_holiday_list = make_holiday_list(
+ 'TestHolidayRemindersList2',
+ holiday_dates=[
+ {'holiday_date': add_months(getdate(), 1), 'description': 'test holiday1'},
+ ],
+ from_date=add_months(getdate(), -2),
+ to_date=add_months(getdate(), 2)
+ )
+ test_employee_2.holiday_list = test_holiday_list.name
+ test_employee_2.save()
+
+ cls.test_employee_2 = test_employee_2
+ cls.holiday_list_2 = test_holiday_list
+
@classmethod
def get_test_holiday_dates(cls):
today_date = getdate()
@@ -61,6 +81,7 @@ class TestEmployeeReminders(unittest.TestCase):
def setUp(self):
# Clear Email Queue
frappe.db.sql("delete from `tabEmail Queue`")
+ frappe.db.sql("delete from `tabEmail Queue Recipient`")
def test_is_holiday(self):
from erpnext.hr.doctype.employee.employee import is_holiday
@@ -103,11 +124,10 @@ class TestEmployeeReminders(unittest.TestCase):
self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message)
def test_work_anniversary_reminders(self):
- employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
- employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:]
- employee.company_email = "test@example.com"
- employee.company = "_Test Company"
- employee.save()
+ make_employee("test_work_anniversary@gmail.com",
+ date_of_joining="1998" + frappe.utils.nowdate()[4:],
+ company="_Test Company",
+ )
from erpnext.hr.doctype.employee.employee_reminders import (
get_employees_having_an_event_today,
@@ -115,7 +135,12 @@ class TestEmployeeReminders(unittest.TestCase):
)
employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
- self.assertTrue(employees_having_work_anniversary.get("_Test Company"))
+ employees = employees_having_work_anniversary.get("_Test Company") or []
+ user_ids = []
+ for entry in employees:
+ user_ids.append(entry.user_id)
+
+ self.assertTrue("test_work_anniversary@gmail.com" in user_ids)
hr_settings = frappe.get_doc("HR Settings", "HR Settings")
hr_settings.send_work_anniversary_reminders = 1
@@ -126,16 +151,24 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message)
- def test_send_holidays_reminder_in_advance(self):
- from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance
- from erpnext.hr.utils import get_holidays_for_employee
+ def test_work_anniversary_reminder_not_sent_for_0_years(self):
+ make_employee("test_work_anniversary_2@gmail.com",
+ date_of_joining=getdate(),
+ company="_Test Company",
+ )
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- set_proceed_with_frequency_change()
- hr_settings.frequency = 'Weekly'
- hr_settings.save()
+ from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today
+
+ employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary')
+ employees = employees_having_work_anniversary.get("_Test Company") or []
+ user_ids = []
+ for entry in employees:
+ user_ids.append(entry.user_id)
+
+ self.assertTrue("test_work_anniversary_2@gmail.com" not in user_ids)
+
+ def test_send_holidays_reminder_in_advance(self):
+ setup_hr_settings('Weekly')
holidays = get_holidays_for_employee(
self.test_employee.get('name'),
@@ -151,32 +184,80 @@ class TestEmployeeReminders(unittest.TestCase):
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertEqual(len(email_queue), 1)
+ self.assertTrue("Holidays this Week." in email_queue[0].message)
def test_advance_holiday_reminders_monthly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- set_proceed_with_frequency_change()
- hr_settings.frequency = 'Monthly'
- hr_settings.save()
+ setup_hr_settings('Monthly')
+
+ # disable emp 2, set same holiday list
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Left',
+ 'holiday_list': self.test_employee.holiday_list
+ })
send_reminders_in_advance_monthly()
-
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
+ # even though emp 2 has holiday, non-active employees should not be recipients
+ recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
+ self.assertTrue(self.test_employee_2.user_id not in recipients)
+
+ # teardown: enable emp 2
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Active',
+ 'holiday_list': self.holiday_list_2.name
+ })
+
def test_advance_holiday_reminders_weekly(self):
from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly
- # Get HR settings and enable advance holiday reminders
- hr_settings = frappe.get_doc("HR Settings", "HR Settings")
- hr_settings.send_holiday_reminders = 1
- hr_settings.frequency = 'Weekly'
- hr_settings.save()
+ setup_hr_settings('Weekly')
+
+ # disable emp 2, set same holiday list
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Left',
+ 'holiday_list': self.test_employee.holiday_list
+ })
send_reminders_in_advance_weekly()
-
email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
self.assertTrue(len(email_queue) > 0)
+
+ # even though emp 2 has holiday, non-active employees should not be recipients
+ recipients = frappe.db.get_all('Email Queue Recipient', pluck='recipient')
+ self.assertTrue(self.test_employee_2.user_id not in recipients)
+
+ # teardown: enable emp 2
+ frappe.db.set_value('Employee', self.test_employee_2.name, {
+ 'status': 'Active',
+ 'holiday_list': self.holiday_list_2.name
+ })
+
+ def test_reminder_not_sent_if_no_holdays(self):
+ setup_hr_settings('Monthly')
+
+ # reminder not sent if there are no holidays
+ holidays = get_holidays_for_employee(
+ self.test_employee_2.get('name'),
+ getdate(), getdate() + timedelta(days=3),
+ only_non_weekly=True,
+ raise_exception=False
+ )
+ send_holidays_reminder_in_advance(
+ self.test_employee_2.get('name'),
+ holidays
+ )
+ email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True)
+ self.assertEqual(len(email_queue), 0)
+
+
+def setup_hr_settings(frequency=None):
+ # Get HR settings and enable advance holiday reminders
+ hr_settings = frappe.get_doc("HR Settings", "HR Settings")
+ hr_settings.send_holiday_reminders = 1
+ set_proceed_with_frequency_change()
+ hr_settings.frequency = frequency or 'Weekly'
+ hr_settings.save()
\ No newline at end of file
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index 75e99f8991..6d27f4abef 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -75,10 +75,8 @@ class TestLeaveApplication(unittest.TestCase):
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
frappe.set_user("Administrator")
-
- @classmethod
- def setUpClass(cls):
set_leave_approver()
+
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
def tearDown(self):
@@ -134,10 +132,11 @@ class TestLeaveApplication(unittest.TestCase):
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ employee = get_employee()
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
- leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
self.assertEqual(leave_application.total_leave_days, 4)
self.assertEqual(frappe.db.count('Attendance', {'leave_application': leave_application.name}), 4)
@@ -157,25 +156,28 @@ class TestLeaveApplication(unittest.TestCase):
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", holiday_list)
+ employee = get_employee()
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
# already marked attendance on a holiday should be deleted in this case
config = {
"doctype": "Attendance",
- "employee": "_T-Employee-00001",
+ "employee": employee.name,
"status": "Present"
}
attendance_on_holiday = frappe.get_doc(config)
attendance_on_holiday.attendance_date = first_sunday
+ attendance_on_holiday.flags.ignore_validate = True
attendance_on_holiday.save()
# already marked attendance on a non-holiday should be updated
attendance = frappe.get_doc(config)
attendance.attendance_date = add_days(first_sunday, 3)
+ attendance.flags.ignore_validate = True
attendance.save()
- leave_application = make_leave_application("_T-Employee-00001", first_sunday, add_days(first_sunday, 3), leave_type.name)
+ leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
# holiday should be excluded while marking attendance
self.assertEqual(leave_application.total_leave_days, 3)
@@ -325,7 +327,7 @@ class TestLeaveApplication(unittest.TestCase):
employee = get_employee()
default_holiday_list = make_holiday_list()
- frappe.db.set_value("Company", "_Test Company", "default_holiday_list", default_holiday_list)
+ frappe.db.set_value("Company", employee.company, "default_holiday_list", default_holiday_list)
first_sunday = get_first_sunday(default_holiday_list)
optional_leave_date = add_days(first_sunday, 1)
@@ -544,7 +546,7 @@ class TestLeaveApplication(unittest.TestCase):
from erpnext.hr.utils import allocate_earned_leaves
i = 0
while(i<14):
- allocate_earned_leaves()
+ allocate_earned_leaves(ignore_duplicates=True)
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 6)
@@ -552,7 +554,7 @@ class TestLeaveApplication(unittest.TestCase):
frappe.db.set_value('Leave Type', leave_type, 'max_leaves_allowed', 0)
i = 0
while(i<6):
- allocate_earned_leaves()
+ allocate_earned_leaves(ignore_duplicates=True)
i += 1
self.assertEqual(get_leave_balance_on(employee.name, leave_type, nowdate()), 9)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
index 355370f3a4..41a9558deb 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
@@ -8,7 +8,7 @@ from math import ceil
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.utils import date_diff, flt, formatdate, get_datetime, getdate
+from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate
class LeavePolicyAssignment(Document):
@@ -108,8 +108,8 @@ class LeavePolicyAssignment(Document):
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
from erpnext.hr.utils import get_monthly_earned_leave
- current_month = get_datetime().month
- current_year = get_datetime().year
+ current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month
+ current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year
from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
if getdate(date_of_joining) > getdate(from_date):
@@ -119,10 +119,14 @@ class LeavePolicyAssignment(Document):
from_date_year = get_datetime(from_date).year
months_passed = 0
+
if current_year == from_date_year and current_month > from_date_month:
months_passed = current_month - from_date_month
+ months_passed = add_current_month_if_applicable(months_passed)
+
elif current_year > from_date_year:
months_passed = (12 - from_date_month) + current_month
+ months_passed = add_current_month_if_applicable(months_passed)
if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -134,6 +138,17 @@ class LeavePolicyAssignment(Document):
return new_leaves_allocated
+def add_current_month_if_applicable(months_passed):
+ date = getdate(frappe.flags.current_date) or getdate()
+ last_day_of_month = get_last_day(date)
+
+ # if its the last day of the month, then that month should also be considered
+ if last_day_of_month == date:
+ months_passed += 1
+
+ return months_passed
+
+
@frappe.whitelist()
def create_assignment_for_multiple_employees(employees, data):
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index 3b7f8ec822..8c76ca1cc3 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import add_months, get_first_day, getdate
+from frappe.utils import add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@@ -125,6 +125,121 @@ class TestLeavePolicyAssignment(unittest.TestCase):
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
+ def test_earned_leave_allocation_for_passed_months(self):
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -1)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # Case 1: assignment created one month after the leave period, should allocate 1 leave
+ frappe.flags.current_date = get_first_day(getdate())
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 1)
+
+ def test_earned_leave_allocation_for_passed_months_on_month_end(self):
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -2)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # Case 2: assignment created on the last day of the leave period's latter month
+ # should allocate 1 leave for current month even though the month has not ended
+ # since the daily job might have already executed
+ frappe.flags.current_date = get_last_day(getdate())
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self):
+ from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
+
+ employee = get_employee()
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=get_first_day(add_months(getdate(), -2)))
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ # initial leave allocation = 5
+ leave_allocation = create_leave_allocation(
+ employee=employee.name,
+ employee_name=employee.employee_name,
+ leave_type=leave_type.name,
+ from_date=add_months(getdate(), -12),
+ to_date=add_months(getdate(), -3),
+ new_leaves_allocated=5,
+ carry_forward=0)
+ leave_allocation.submit()
+
+ # Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
+ frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name,
+ "carry_forward": 1
+ }
+ # carry forwarded leaves = 5, 3 leaves allocated for passed months
+ leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+
+ details = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, ["total_leaves_allocated", "new_leaves_allocated", "unused_leaves", "name"], as_dict=True)
+ self.assertEqual(details.new_leaves_allocated, 2)
+ self.assertEqual(details.unused_leaves, 5)
+ self.assertEqual(details.total_leaves_allocated, 7)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import is_earned_leave_already_allocated
+ frappe.flags.current_date = get_last_day(getdate())
+
+ allocation = frappe.get_doc('Leave Allocation', details.name)
+ # 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
+ self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
+
def tearDown(self):
frappe.db.rollback()
@@ -138,13 +253,14 @@ def create_earned_leave_type(leave_type):
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
- max_leaves_allowed=6
+ is_carry_forward=1
)).insert()
-def create_leave_period(name):
+def create_leave_period(name, start_date=None):
frappe.delete_doc_if_exists("Leave Period", name, force=1)
- start_date = get_first_day(getdate())
+ if not start_date:
+ start_date = get_first_day(getdate())
return frappe.get_doc(dict(
name=name,
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 0febce1610..7fd3a98e2d 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -237,7 +237,7 @@ def generate_leave_encashment():
create_leave_encashment(leave_allocation=leave_allocation)
-def allocate_earned_leaves():
+def allocate_earned_leaves(ignore_duplicates=False):
'''Allocate earned leaves to Employees'''
e_leave_types = get_earned_leaves()
today = getdate()
@@ -265,9 +265,9 @@ def allocate_earned_leaves():
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
- update_previous_leave_allocation(allocation, annual_allocation, e_leave_type)
+ update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
-def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type):
+def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding)
allocation = frappe.get_doc('Leave Allocation', allocation.name)
@@ -277,9 +277,12 @@ def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type
new_allocation = e_leave_type.max_leaves_allowed
if new_allocation != allocation.total_leaves_allocated:
- allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
today_date = today()
- create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+
+ if ignore_duplicates or not is_earned_leave_already_allocated(allocation, annual_allocation):
+ allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False)
+ create_additional_leave_ledger_entry(allocation, earned_leaves, today_date)
+
def get_monthly_earned_leave(annual_leaves, frequency, rounding):
earned_leaves = 0.0
@@ -297,6 +300,28 @@ def get_monthly_earned_leave(annual_leaves, frequency, rounding):
return earned_leaves
+def is_earned_leave_already_allocated(allocation, annual_allocation):
+ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
+ get_leave_type_details,
+ )
+
+ leave_type_details = get_leave_type_details()
+ date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
+
+ assignment = frappe.get_doc("Leave Policy Assignment", allocation.leave_policy_assignment)
+ leaves_for_passed_months = assignment.get_leaves_for_passed_months(allocation.leave_type,
+ annual_allocation, leave_type_details, date_of_joining)
+
+ # exclude carry-forwarded leaves while checking for leave allocation for passed months
+ num_allocations = allocation.total_leaves_allocated
+ if allocation.unused_leaves:
+ num_allocations -= allocation.unused_leaves
+
+ if num_allocations >= leaves_for_passed_months:
+ return True
+ return False
+
+
def get_leave_allocations(date, leave_type):
return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy
from `tabLeave Allocation`
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index f9c201ab60..940a1bbc00 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -46,7 +46,7 @@ frappe.ui.form.on('Loan', {
});
});
- $.each(["payment_account", "loan_account"], function (i, field) {
+ $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) {
frm.set_query(field, function () {
return {
"filters": {
@@ -88,6 +88,10 @@ frappe.ui.form.on('Loan', {
frm.add_custom_button(__('Loan Write Off'), function() {
frm.trigger("make_loan_write_off_entry");
},__('Create'));
+
+ frm.add_custom_button(__('Loan Refund'), function() {
+ frm.trigger("make_loan_refund");
+ },__('Create'));
}
}
frm.trigger("toggle_fields");
@@ -155,6 +159,21 @@ frappe.ui.form.on('Loan', {
})
},
+ make_loan_refund: function(frm) {
+ frappe.call({
+ args: {
+ "loan": frm.doc.name
+ },
+ method: "erpnext.loan_management.doctype.loan.loan.make_refund_jv",
+ callback: function (r) {
+ if (r.message) {
+ let doc = frappe.model.sync(r.message)[0];
+ frappe.set_route("Form", doc.doctype, doc.name);
+ }
+ }
+ })
+ },
+
request_loan_closure: function(frm) {
frappe.confirm(__("Do you really want to close this loan"),
function() {
diff --git a/erpnext/loan_management/doctype/loan/loan.json b/erpnext/loan_management/doctype/loan/loan.json
index af26f7bc5c..196f36f0f4 100644
--- a/erpnext/loan_management/doctype/loan/loan.json
+++ b/erpnext/loan_management/doctype/loan/loan.json
@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"autoname": "ACC-LOAN-.YYYY.-.#####",
- "creation": "2019-08-29 17:29:18.176786",
+ "creation": "2022-01-25 10:30:02.294967",
"doctype": "DocType",
"document_type": "Document",
"editable_grid": 1,
@@ -34,6 +34,7 @@
"is_term_loan",
"account_info",
"mode_of_payment",
+ "disbursement_account",
"payment_account",
"column_break_9",
"loan_account",
@@ -356,12 +357,21 @@
"fieldtype": "Date",
"label": "Closure Date",
"read_only": 1
+ },
+ {
+ "fetch_from": "loan_type.disbursement_account",
+ "fieldname": "disbursement_account",
+ "fieldtype": "Link",
+ "label": "Disbursement Account",
+ "options": "Account",
+ "read_only": 1,
+ "reqd": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-10-12 18:10:32.360818",
+ "modified": "2022-01-25 16:29:16.325501",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan",
@@ -391,5 +401,6 @@
"search_fields": "posting_date",
"sort_field": "creation",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/loan_management/doctype/loan/loan.py b/erpnext/loan_management/doctype/loan/loan.py
index f660a24a6d..b798e088b4 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -10,6 +10,7 @@ from frappe import _
from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate
import erpnext
+from erpnext.accounts.doctype.journal_entry.journal_entry import get_payment_entry
from erpnext.controllers.accounts_controller import AccountsController
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts
from erpnext.loan_management.doctype.loan_security_unpledge.loan_security_unpledge import (
@@ -233,17 +234,15 @@ def request_loan_closure(loan, posting_date=None):
loan_type = frappe.get_value('Loan', loan, 'loan_type')
write_off_limit = frappe.get_value('Loan Type', loan_type, 'write_off_amount')
- # checking greater than 0 as there may be some minor precision error
- if not pending_amount:
- frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
- elif pending_amount < write_off_limit:
+ if pending_amount and abs(pending_amount) < write_off_limit:
# Auto create loan write off and update status as loan closure requested
write_off = make_loan_write_off(loan)
write_off.submit()
- frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
- else:
+ elif pending_amount > 0:
frappe.throw(_("Cannot close loan as there is an outstanding of {0}").format(pending_amount))
+ frappe.db.set_value('Loan', loan, 'status', 'Loan Closure Requested')
+
@frappe.whitelist()
def get_loan_application(loan_application):
loan = frappe.get_doc("Loan Application", loan_application)
@@ -400,4 +399,39 @@ def add_single_month(date):
if getdate(date) == get_last_day(date):
return get_last_day(add_months(date, 1))
else:
- return add_months(date, 1)
\ No newline at end of file
+ return add_months(date, 1)
+
+@frappe.whitelist()
+def make_refund_jv(loan, amount=0, reference_number=None, reference_date=None, submit=0):
+ loan_details = frappe.db.get_value('Loan', loan, ['applicant_type', 'applicant',
+ 'loan_account', 'payment_account', 'posting_date', 'company', 'name',
+ 'total_payment', 'total_principal_paid'], as_dict=1)
+
+ loan_details.doctype = 'Loan'
+ loan_details[loan_details.applicant_type.lower()] = loan_details.applicant
+
+ if not amount:
+ amount = flt(loan_details.total_principal_paid - loan_details.total_payment)
+
+ if amount < 0:
+ frappe.throw(_('No excess amount pending for refund'))
+
+ refund_jv = get_payment_entry(loan_details, {
+ "party_type": loan_details.applicant_type,
+ "party_account": loan_details.loan_account,
+ "amount_field_party": 'debit_in_account_currency',
+ "amount_field_bank": 'credit_in_account_currency',
+ "amount": amount,
+ "bank_account": loan_details.payment_account
+ })
+
+ if reference_number:
+ refund_jv.cheque_no = reference_number
+
+ if reference_date:
+ refund_jv.cheque_date = reference_date
+
+ if submit:
+ refund_jv.submit()
+
+ return refund_jv
\ No newline at end of file
diff --git a/erpnext/loan_management/doctype/loan/test_loan.py b/erpnext/loan_management/doctype/loan/test_loan.py
index 1676c218c8..5ebb2e1bdc 100644
--- a/erpnext/loan_management/doctype/loan/test_loan.py
+++ b/erpnext/loan_management/doctype/loan/test_loan.py
@@ -42,16 +42,17 @@ class TestLoan(unittest.TestCase):
create_loan_type("Personal Loan", 500000, 8.4,
is_term_loan=1,
mode_of_payment='Cash',
+ disbursement_account='Disbursement Account - _TC',
payment_account='Payment Account - _TC',
loan_account='Loan Account - _TC',
interest_income_account='Interest Income Account - _TC',
penalty_income_account='Penalty Income Account - _TC')
- create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
- 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
+ create_loan_type("Stock Loan", 2000000, 13.5, 25, 1, 5, 'Cash', 'Disbursement Account - _TC',
+ 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
- create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
- 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
+ create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
+ 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
create_loan_security_type()
create_loan_security()
@@ -679,6 +680,29 @@ class TestLoan(unittest.TestCase):
loan.load_from_db()
self.assertEqual(loan.status, "Loan Closure Requested")
+ def test_loan_repayment_against_partially_disbursed_loan(self):
+ pledge = [{
+ "loan_security": "Test Security 1",
+ "qty": 4000.00
+ }]
+
+ loan_application = create_loan_application('_Test Company', self.applicant2, 'Demand Loan', pledge)
+ create_pledge(loan_application)
+
+ loan = create_demand_loan(self.applicant2, "Demand Loan", loan_application, posting_date='2019-10-01')
+ loan.submit()
+
+ first_date = '2019-10-01'
+ last_date = '2019-10-30'
+
+ make_loan_disbursement_entry(loan.name, loan.loan_amount/2, disbursement_date=first_date)
+
+ loan.load_from_db()
+
+ self.assertEqual(loan.status, "Partially Disbursed")
+ create_repayment_entry(loan.name, self.applicant2, add_days(last_date, 5),
+ flt(loan.loan_amount/3))
+
def test_loan_amount_write_off(self):
pledge = [{
"loan_security": "Test Security 1",
@@ -790,6 +814,18 @@ def create_loan_accounts():
"account_type": "Bank",
}).insert(ignore_permissions=True)
+ if not frappe.db.exists("Account", "Disbursement Account - _TC"):
+ frappe.get_doc({
+ "doctype": "Account",
+ "company": "_Test Company",
+ "account_name": "Disbursement Account",
+ "root_type": "Asset",
+ "report_type": "Balance Sheet",
+ "currency": "INR",
+ "parent_account": "Bank Accounts - _TC",
+ "account_type": "Bank",
+ }).insert(ignore_permissions=True)
+
if not frappe.db.exists("Account", "Interest Income Account - _TC"):
frappe.get_doc({
"doctype": "Account",
@@ -815,7 +851,7 @@ def create_loan_accounts():
}).insert(ignore_permissions=True)
def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_interest_rate=None, is_term_loan=None, grace_period_in_days=None,
- mode_of_payment=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None,
+ mode_of_payment=None, disbursement_account=None, payment_account=None, loan_account=None, interest_income_account=None, penalty_income_account=None,
repayment_method=None, repayment_periods=None):
if not frappe.db.exists("Loan Type", loan_name):
@@ -829,6 +865,7 @@ def create_loan_type(loan_name, maximum_loan_amount, rate_of_interest, penalty_i
"penalty_interest_rate": penalty_interest_rate,
"grace_period_in_days": grace_period_in_days,
"mode_of_payment": mode_of_payment,
+ "disbursement_account": disbursement_account,
"payment_account": payment_account,
"loan_account": loan_account,
"interest_income_account": interest_income_account,
diff --git a/erpnext/loan_management/doctype/loan_application/test_loan_application.py b/erpnext/loan_management/doctype/loan_application/test_loan_application.py
index d367e92ac4..640709c095 100644
--- a/erpnext/loan_management/doctype/loan_application/test_loan_application.py
+++ b/erpnext/loan_management/doctype/loan_application/test_loan_application.py
@@ -15,7 +15,7 @@ from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
class TestLoanApplication(unittest.TestCase):
def setUp(self):
create_loan_accounts()
- create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
+ create_loan_type("Home Loan", 500000, 9.2, 0, 1, 0, 'Cash', 'Disbursement Account - _TC', 'Payment Account - _TC', 'Loan Account - _TC',
'Interest Income Account - _TC', 'Penalty Income Account - _TC', 'Repay Over Number of Periods', 18)
self.applicant = make_employee("kate_loan@loan.com", "_Test Company")
make_salary_structure("Test Salary Structure Loan", "Monthly", employee=self.applicant, currency='INR')
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index e2d758b1b9..df3aadfb18 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -122,7 +122,7 @@ class LoanDisbursement(AccountsController):
gle_map.append(
self.get_gl_dict({
"account": loan_details.loan_account,
- "against": loan_details.payment_account,
+ "against": loan_details.disbursement_account,
"debit": self.disbursed_amount,
"debit_in_account_currency": self.disbursed_amount,
"against_voucher_type": "Loan",
@@ -137,7 +137,7 @@ class LoanDisbursement(AccountsController):
gle_map.append(
self.get_gl_dict({
- "account": loan_details.payment_account,
+ "account": loan_details.disbursement_account,
"against": loan_details.loan_account,
"credit": self.disbursed_amount,
"credit_in_account_currency": self.disbursed_amount,
diff --git a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py
index 94ec84ea5d..10be750b44 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/test_loan_disbursement.py
@@ -44,8 +44,8 @@ class TestLoanDisbursement(unittest.TestCase):
def setUp(self):
create_loan_accounts()
- create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
- 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
+ create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
+ 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
create_loan_security_type()
create_loan_security()
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py
index 46aaaad9fd..e8c77506fc 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/test_loan_interest_accrual.py
@@ -30,8 +30,8 @@ class TestLoanInterestAccrual(unittest.TestCase):
def setUp(self):
create_loan_accounts()
- create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Payment Account - _TC', 'Loan Account - _TC',
- 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
+ create_loan_type("Demand Loan", 2000000, 13.5, 25, 0, 5, 'Cash', 'Disbursement Account - _TC',
+ 'Payment Account - _TC', 'Loan Account - _TC', 'Interest Income Account - _TC', 'Penalty Income Account - _TC')
create_loan_security_type()
create_loan_security()
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index 7e997e87c3..acf3a655de 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -125,7 +125,7 @@ class LoanRepayment(AccountsController):
def update_paid_amount(self):
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
- 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
loan.update({
@@ -153,7 +153,7 @@ class LoanRepayment(AccountsController):
def mark_as_unpaid(self):
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
- 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
+ 'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'disbursed_amount', 'total_interest_payable',
'written_off_amount'], as_dict=1)
no_of_repayments = len(self.repayment_details)
diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.js b/erpnext/loan_management/doctype/loan_type/loan_type.js
index 04c89c4549..9f9137cfbc 100644
--- a/erpnext/loan_management/doctype/loan_type/loan_type.js
+++ b/erpnext/loan_management/doctype/loan_type/loan_type.js
@@ -15,7 +15,7 @@ frappe.ui.form.on('Loan Type', {
});
});
- $.each(["payment_account", "loan_account"], function (i, field) {
+ $.each(["payment_account", "loan_account", "disbursement_account"], function (i, field) {
frm.set_query(field, function () {
return {
"filters": {
diff --git a/erpnext/loan_management/doctype/loan_type/loan_type.json b/erpnext/loan_management/doctype/loan_type/loan_type.json
index c0a5d2cda1..00337e4b4c 100644
--- a/erpnext/loan_management/doctype/loan_type/loan_type.json
+++ b/erpnext/loan_management/doctype/loan_type/loan_type.json
@@ -19,9 +19,10 @@
"description",
"account_details_section",
"mode_of_payment",
+ "disbursement_account",
"payment_account",
- "loan_account",
"column_break_12",
+ "loan_account",
"interest_income_account",
"penalty_income_account",
"amended_from"
@@ -79,7 +80,7 @@
{
"fieldname": "payment_account",
"fieldtype": "Link",
- "label": "Payment Account",
+ "label": "Repayment Account",
"options": "Account",
"reqd": 1
},
@@ -149,15 +150,23 @@
"fieldtype": "Currency",
"label": "Auto Write Off Amount ",
"options": "Company:company:default_currency"
+ },
+ {
+ "fieldname": "disbursement_account",
+ "fieldtype": "Link",
+ "label": "Disbursement Account",
+ "options": "Account",
+ "reqd": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-04-19 18:10:57.368490",
+ "modified": "2022-01-25 16:23:57.009349",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Type",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -181,5 +190,6 @@
}
],
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index fc3b971bcb..8a7634e24e 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -93,7 +93,7 @@ frappe.ui.form.on("BOM", {
});
}
- if(frm.doc.docstatus!=0) {
+ if(frm.doc.docstatus==1) {
frm.add_custom_button(__("Work Order"), function() {
frm.trigger("make_work_order");
}, __("Create"));
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 21a126b2a7..276e70859e 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -385,6 +385,61 @@ class TestProductionPlan(ERPNextTestCase):
# lowest most level of subassembly should be first
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
+ def test_multiple_work_order_for_production_plan_item(self):
+ def create_work_order(item, pln, qty):
+ # Get Production Items
+ items_data = pln.get_production_items()
+
+ # Update qty
+ items_data[(item, None, None)]["qty"] = qty
+
+ # Create and Submit Work Order for each item in items_data
+ for key, item in items_data.items():
+ if pln.sub_assembly_items:
+ item['use_multi_level_bom'] = 0
+
+ wo_name = pln.create_work_order(item)
+ wo_doc = frappe.get_doc("Work Order", wo_name)
+ wo_doc.update({
+ 'wip_warehouse': 'Work In Progress - _TC',
+ 'fg_warehouse': 'Finished Goods - _TC'
+ })
+ wo_doc.submit()
+ wo_list.append(wo_name)
+
+ item = "Test Production Item 1"
+ raw_materials = ["Raw Material Item 1", "Raw Material Item 2"]
+
+ # Create BOM
+ bom = make_bom(item=item, raw_materials=raw_materials)
+
+ # Create Production Plan
+ pln = create_production_plan(item_code=bom.item, planned_qty=10)
+
+ # All the created Work Orders
+ wo_list = []
+
+ # Create and Submit 1st Work Order for 5 qty
+ create_work_order(item, pln, 5)
+ pln.reload()
+ self.assertEqual(pln.po_items[0].ordered_qty, 5)
+
+ # Create and Submit 2nd Work Order for 3 qty
+ create_work_order(item, pln, 3)
+ pln.reload()
+ self.assertEqual(pln.po_items[0].ordered_qty, 8)
+
+ # Cancel 1st Work Order
+ wo1 = frappe.get_doc("Work Order", wo_list[0])
+ wo1.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].ordered_qty, 3)
+
+ # Cancel 2nd Work Order
+ wo2 = frappe.get_doc("Work Order", wo_list[1])
+ wo2.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].ordered_qty, 0)
def create_production_plan(**args):
args = frappe._dict(args)
diff --git a/erpnext/manufacturing/doctype/work_order/test_work_order.py b/erpnext/manufacturing/doctype/work_order/test_work_order.py
index a399edda70..76978017a6 100644
--- a/erpnext/manufacturing/doctype/work_order/test_work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/test_work_order.py
@@ -703,7 +703,8 @@ class TestWorkOrder(ERPNextTestCase):
wo = make_wo_order_test_record(item=item_name, qty=1, source_warehouse=source_warehouse,
company=company)
- self.assertRaises(frappe.ValidationError, make_stock_entry, wo.name, 'Material Transfer for Manufacture')
+ stock_entry = frappe.get_doc(make_stock_entry(wo.name, 'Material Transfer for Manufacture'))
+ self.assertRaises(frappe.ValidationError, stock_entry.save)
def test_wo_completion_with_pl_bom(self):
from erpnext.manufacturing.doctype.bom.test_bom import (
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 03e0910345..a86edfa45f 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -449,7 +449,13 @@ class WorkOrder(Document):
def update_ordered_qty(self):
if self.production_plan and self.production_plan_item:
- qty = self.qty if self.docstatus == 1 else 0
+ qty = frappe.get_value("Production Plan Item", self.production_plan_item, "ordered_qty") or 0.0
+
+ if self.docstatus == 1:
+ qty += self.qty
+ elif self.docstatus == 2:
+ qty -= self.qty
+
frappe.db.set_value('Production Plan Item',
self.production_plan_item, 'ordered_qty', qty)
diff --git a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
index 090a3e74fc..2693352324 100644
--- a/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
+++ b/erpnext/manufacturing/report/bom_stock_calculated/bom_stock_calculated.py
@@ -89,10 +89,10 @@ def get_bom_stock(filters):
GROUP BY bom_item.item_code""".format(qty_field=qty_field, table=table, conditions=conditions, bom=bom), as_dict=1)
def get_manufacturer_records():
- details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "parent"])
+ details = frappe.get_all('Item Manufacturer', fields = ["manufacturer", "manufacturer_part_no", "item_code"])
manufacture_details = frappe._dict()
for detail in details:
- dic = manufacture_details.setdefault(detail.get('parent'), {})
+ dic = manufacture_details.setdefault(detail.get('item_code'), {})
dic.setdefault('manufacturer', []).append(detail.get('manufacturer'))
dic.setdefault('manufacturer_part', []).append(detail.get('manufacturer_part_no'))
diff --git a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
index 8368db6374..e1e7225e05 100644
--- a/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
+++ b/erpnext/manufacturing/report/production_planning_report/production_planning_report.py
@@ -172,10 +172,15 @@ class ProductionPlanReport(object):
self.purchase_details = {}
- for d in frappe.get_all("Purchase Order Item",
+ purchased_items = frappe.get_all("Purchase Order Item",
fields=["item_code", "min(schedule_date) as arrival_date", "qty as arrival_qty", "warehouse"],
- filters = {"item_code": ("in", self.item_codes), "warehouse": ("in", self.warehouses)},
- group_by = "item_code, warehouse"):
+ filters={
+ "item_code": ("in", self.item_codes),
+ "warehouse": ("in", self.warehouses),
+ "docstatus": 1,
+ },
+ group_by = "item_code, warehouse")
+ for d in purchased_items:
key = (d.item_code, d.warehouse)
if key not in self.purchase_details:
self.purchase_details.setdefault(key, d)
diff --git a/erpnext/modules.txt b/erpnext/modules.txt
index e62e2bcfab..8c79ee5c9a 100644
--- a/erpnext/modules.txt
+++ b/erpnext/modules.txt
@@ -9,7 +9,6 @@ Manufacturing
Stock
Support
Utilities
-Shopping Cart
Assets
Portal
Maintenance
@@ -21,4 +20,6 @@ Quality Management
Communication
Loan Management
Payroll
-Telephony
\ No newline at end of file
+Telephony
+Bulk Transaction
+E-commerce
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 8bd2214f38..feafecbc04 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -293,6 +293,9 @@ erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
erpnext.patches.v13_0.set_operation_time_based_on_operating_cost
erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021
erpnext.patches.v13_0.fix_invoice_statuses
+erpnext.patches.v13_0.create_website_items #30-09-2021
+erpnext.patches.v13_0.populate_e_commerce_settings
+erpnext.patches.v13_0.make_homepage_products_website_items
erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item
erpnext.patches.v13_0.update_dates_in_tax_withholding_category
erpnext.patches.v14_0.update_opportunity_currency_fields
@@ -314,6 +317,7 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory
erpnext.patches.v14_0.delete_healthcare_doctypes
erpnext.patches.v13_0.update_category_in_ltds_certificate
erpnext.patches.v13_0.create_pan_field_for_india #2
+erpnext.patches.v13_0.fetch_thumbnail_in_website_items
erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022
erpnext.patches.v14_0.migrate_crm_settings
@@ -343,3 +347,6 @@ erpnext.patches.v14_0.restore_einvoice_fields
erpnext.patches.v13_0.update_sane_transfer_against
erpnext.patches.v12_0.add_company_link_to_einvoice_settings
erpnext.patches.v14_0.migrate_cost_center_allocations
+erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
+erpnext.patches.v13_0.shopping_cart_to_ecommerce
+erpnext.patches.v13_0.update_disbursement_account
diff --git a/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py
new file mode 100644
index 0000000000..d3ee3f8276
--- /dev/null
+++ b/erpnext/patches/v13_0/convert_to_website_item_in_item_card_group_template.py
@@ -0,0 +1,57 @@
+import json
+from typing import List, Union
+
+import frappe
+
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+
+
+def execute():
+ """
+ Convert all Item links to Website Item link values in
+ exisitng 'Item Card Group' Web Page Block data.
+ """
+ frappe.reload_doc("e_commerce", "web_template", "item_card_group")
+
+ blocks = frappe.db.get_all(
+ "Web Page Block",
+ filters={"web_template": "Item Card Group"},
+ fields=["parent", "web_template_values", "name"]
+ )
+
+ fields = generate_fields_to_edit()
+
+ for block in blocks:
+ web_template_value = json.loads(block.get('web_template_values'))
+
+ for field in fields:
+ item = web_template_value.get(field)
+ if not item:
+ continue
+
+ if frappe.db.exists("Website Item", {"item_code": item}):
+ website_item = frappe.db.get_value("Website Item", {"item_code": item})
+ else:
+ website_item = make_new_website_item(item)
+
+ if website_item:
+ web_template_value[field] = website_item
+
+ frappe.db.set_value("Web Page Block", block.name, "web_template_values", json.dumps(web_template_value))
+
+def generate_fields_to_edit() -> List:
+ fields = []
+ for i in range(1, 13):
+ fields.append(f"card_{i}_item") # fields like 'card_1_item', etc.
+
+ return fields
+
+def make_new_website_item(item: str) -> Union[str, None]:
+ try:
+ doc = frappe.get_doc("Item", item)
+ web_item = make_website_item(doc) # returns [website_item.name, item_name]
+ return web_item[0]
+ except Exception:
+ title = f"{item}: Error while converting to Website Item "
+ frappe.log_error(title + "for Item Card Group Template" + "\n\n" + frappe.get_traceback(), title=title)
+ return None
diff --git a/erpnext/patches/v13_0/create_website_items.py b/erpnext/patches/v13_0/create_website_items.py
new file mode 100644
index 0000000000..da162a3ab1
--- /dev/null
+++ b/erpnext/patches/v13_0/create_website_items.py
@@ -0,0 +1,72 @@
+import frappe
+
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+
+
+def execute():
+ frappe.reload_doc("e_commerce", "doctype", "website_item")
+ frappe.reload_doc("e_commerce", "doctype", "website_item_tabbed_section")
+ frappe.reload_doc("e_commerce", "doctype", "website_offer")
+ frappe.reload_doc("e_commerce", "doctype", "recommended_items")
+ frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings")
+ frappe.reload_doc("stock", "doctype", "item")
+
+ item_fields = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image",
+ "has_variants", "variant_of", "description", "weightage"]
+ web_fields_to_map = ["route", "slideshow", "website_image_alt",
+ "website_warehouse", "web_long_description", "website_content", "thumbnail"]
+
+ # get all valid columns (fields) from Item master DB schema
+ item_table_fields = frappe.db.sql("desc `tabItem`", as_dict=1) # nosemgrep
+ item_table_fields = [d.get('Field') for d in item_table_fields]
+
+ # prepare fields to query from Item, check if the web field exists in Item master
+ web_query_fields = []
+ for web_field in web_fields_to_map:
+ if web_field in item_table_fields:
+ web_query_fields.append(web_field)
+ item_fields.append(web_field)
+
+ # check if the filter fields exist in Item master
+ or_filters = {}
+ for field in ["show_in_website", "show_variant_in_website"]:
+ if field in item_table_fields:
+ or_filters[field] = 1
+
+ if not web_query_fields or not or_filters:
+ # web fields to map are not present in Item master schema
+ # most likely a fresh installation that doesnt need this patch
+ return
+
+ items = frappe.db.get_all(
+ "Item",
+ fields=item_fields,
+ or_filters=or_filters
+ )
+ total_count = len(items)
+
+ for count, item in enumerate(items, start=1):
+ if frappe.db.exists("Website Item", {"item_code": item.item_code}):
+ continue
+
+ # make new website item from item (publish item)
+ website_item = make_website_item(item, save=False)
+ website_item.ranking = item.get("weightage")
+
+ for field in web_fields_to_map:
+ website_item.update({field: item.get(field)})
+
+ website_item.save()
+
+ # move Website Item Group & Website Specification table to Website Item
+ for doctype in ("Website Item Group", "Item Website Specification"):
+ frappe.db.set_value(
+ doctype,
+ {"parenttype": "Item", "parent": item.item_code}, # filters
+ {"parenttype": "Website Item", "parent": website_item.name} # value dict
+ )
+
+ if count % 20 == 0: # commit after every 20 items
+ frappe.db.commit()
+
+ frappe.utils.update_progress_bar('Creating Website Items', count, total_count)
diff --git a/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py
new file mode 100644
index 0000000000..32ad542cf8
--- /dev/null
+++ b/erpnext/patches/v13_0/fetch_thumbnail_in_website_items.py
@@ -0,0 +1,16 @@
+import frappe
+
+
+def execute():
+ if frappe.db.has_column("Item", "thumbnail"):
+ website_item = frappe.qb.DocType("Website Item").as_("wi")
+ item = frappe.qb.DocType("Item")
+
+ frappe.qb.update(website_item).inner_join(item).on(
+ website_item.item_code == item.item_code
+ ).set(
+ website_item.thumbnail, item.thumbnail
+ ).where(
+ website_item.website_image.notnull()
+ & website_item.thumbnail.isnull()
+ ).run()
diff --git a/erpnext/patches/v13_0/make_homepage_products_website_items.py b/erpnext/patches/v13_0/make_homepage_products_website_items.py
new file mode 100644
index 0000000000..7a7ddba12d
--- /dev/null
+++ b/erpnext/patches/v13_0/make_homepage_products_website_items.py
@@ -0,0 +1,15 @@
+import frappe
+
+
+def execute():
+ homepage = frappe.get_doc("Homepage")
+
+ for row in homepage.products:
+ web_item = frappe.db.get_value("Website Item", {"item_code": row.item_code}, "name")
+ if not web_item:
+ continue
+
+ row.item_code = web_item
+
+ homepage.flags.ignore_mandatory = True
+ homepage.save()
\ No newline at end of file
diff --git a/erpnext/patches/v13_0/populate_e_commerce_settings.py b/erpnext/patches/v13_0/populate_e_commerce_settings.py
new file mode 100644
index 0000000000..8f9ee512fd
--- /dev/null
+++ b/erpnext/patches/v13_0/populate_e_commerce_settings.py
@@ -0,0 +1,62 @@
+import frappe
+from frappe.utils import cint
+
+
+def execute():
+ frappe.reload_doc("e_commerce", "doctype", "e_commerce_settings")
+ frappe.reload_doc("portal", "doctype", "website_filter_field")
+ frappe.reload_doc("portal", "doctype", "website_attribute")
+
+ products_settings_fields = [
+ "hide_variants", "products_per_page",
+ "enable_attribute_filters", "enable_field_filters"
+ ]
+
+ shopping_cart_settings_fields = [
+ "enabled", "show_attachments", "show_price",
+ "show_stock_availability", "enable_variants", "show_contact_us_button",
+ "show_quantity_in_website", "show_apply_coupon_code_in_website",
+ "allow_items_not_in_stock", "company", "price_list", "default_customer_group",
+ "quotation_series", "enable_checkout", "payment_success_url",
+ "payment_gateway_account", "save_quotations_as_draft"
+ ]
+
+ settings = frappe.get_doc("E Commerce Settings")
+
+ def map_into_e_commerce_settings(doctype, fields):
+ singles = frappe.qb.DocType("Singles")
+ query = (
+ frappe.qb.from_(singles)
+ .select(
+ singles["field"], singles.value
+ ).where(
+ (singles.doctype == doctype)
+ & (singles["field"].isin(fields))
+ )
+ )
+ data = query.run(as_dict=True)
+
+ # {'enable_attribute_filters': '1', ...}
+ mapper = {row.field: row.value for row in data}
+
+ for key, value in mapper.items():
+ value = cint(value) if (value and value.isdigit()) else value
+ settings.update({key: value})
+
+ settings.save()
+
+ # shift data to E Commerce Settings
+ map_into_e_commerce_settings("Products Settings", products_settings_fields)
+ map_into_e_commerce_settings("Shopping Cart Settings", shopping_cart_settings_fields)
+
+ # move filters and attributes tables to E Commerce Settings from Products Settings
+ for doctype in ("Website Filter Field", "Website Attribute"):
+ frappe.db.set_value(
+ doctype,
+ {"parent": "Products Settings"},
+ {
+ "parenttype": "E Commerce Settings",
+ "parent": "E Commerce Settings"
+ },
+ update_modified=False
+ )
diff --git a/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py
new file mode 100644
index 0000000000..35710a9bb4
--- /dev/null
+++ b/erpnext/patches/v13_0/shopping_cart_to_ecommerce.py
@@ -0,0 +1,29 @@
+import click
+import frappe
+
+
+def execute():
+
+ frappe.delete_doc("DocType", "Shopping Cart Settings", ignore_missing=True)
+ frappe.delete_doc("DocType", "Products Settings", ignore_missing=True)
+ frappe.delete_doc("DocType", "Supplier Item Group", ignore_missing=True)
+
+ if frappe.db.get_single_value("E Commerce Settings", "enabled"):
+ notify_users()
+
+
+def notify_users():
+
+ click.secho(
+ "Shopping cart and Product settings are merged into E-commerce settings.\n"
+ "Checkout the documentation to learn more:"
+ "https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce",
+ fg="yellow",
+ )
+
+ note = frappe.new_doc("Note")
+ note.title = "New E-Commerce Module"
+ note.public = 1
+ note.notify_on_login = 1
+ note.content = """
"""
+ note.insert(ignore_mandatory=True)
diff --git a/erpnext/patches/v13_0/update_disbursement_account.py b/erpnext/patches/v13_0/update_disbursement_account.py
new file mode 100644
index 0000000000..c56fa8fdc6
--- /dev/null
+++ b/erpnext/patches/v13_0/update_disbursement_account.py
@@ -0,0 +1,22 @@
+import frappe
+
+
+def execute():
+
+ frappe.reload_doc("loan_management", "doctype", "loan_type")
+ frappe.reload_doc("loan_management", "doctype", "loan")
+
+ loan_type = frappe.qb.DocType("Loan Type")
+ loan = frappe.qb.DocType("Loan")
+
+ frappe.qb.update(
+ loan_type
+ ).set(
+ loan_type.disbursement_account, loan_type.payment_account
+ ).run()
+
+ frappe.qb.update(
+ loan
+ ).set(
+ loan.disbursement_account, loan.payment_account
+ ).run()
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/migrate_cost_center_allocations.py b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
index 3d217d89e2..c4f097fdd9 100644
--- a/erpnext/patches/v14_0/migrate_cost_center_allocations.py
+++ b/erpnext/patches/v14_0/migrate_cost_center_allocations.py
@@ -27,7 +27,7 @@ def create_new_cost_center_allocation_records(cc_allocations):
cca.submit()
def get_existing_cost_center_allocations():
- if not frappe.get_meta("Cost Center").has_field("enable_distributed_cost_center"):
+ if not frappe.db.exists("DocType", "Distributed Cost Center"):
return
par = frappe.qb.DocType("Cost Center")
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index 4f097fa2c3..5f836db2f0 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -214,6 +214,7 @@ class TestPayrollEntry(unittest.TestCase):
create_loan_type("Car Loan", 500000, 8.4,
is_term_loan=1,
mode_of_payment='Cash',
+ disbursement_account='Disbursement Account - _TC',
payment_account='Payment Account - _TC',
loan_account='Loan Account - _TC',
interest_income_account='Interest Income Account - _TC',
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index f33443d0d7..f727ff4378 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -746,11 +746,12 @@ class SalarySlip(TransactionBase):
previous_total_paid_taxes = self.get_tax_paid_in_period(payroll_period.start_date, self.start_date, tax_component)
# get taxable_earnings for current period (all days)
- current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption)
+ current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption, payroll_period=payroll_period)
future_structured_taxable_earnings = current_taxable_earnings.taxable_earnings * (math.ceil(remaining_sub_periods) - 1)
# get taxable_earnings, addition_earnings for current actual payment days
- current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, based_on_payment_days=1)
+ current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption,
+ based_on_payment_days=1, payroll_period=payroll_period)
current_structured_taxable_earnings = current_taxable_earnings_for_payment_days.taxable_earnings
current_additional_earnings = current_taxable_earnings_for_payment_days.additional_income
current_additional_earnings_with_full_tax = current_taxable_earnings_for_payment_days.additional_income_with_full_tax
@@ -876,7 +877,7 @@ class SalarySlip(TransactionBase):
return total_tax_paid
- def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0):
+ def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0, payroll_period=None):
joining_date, relieving_date = self.get_joining_and_relieving_dates()
taxable_earnings = 0
@@ -903,7 +904,7 @@ class SalarySlip(TransactionBase):
# Get additional amount based on future recurring additional salary
if additional_amount and earning.is_recurring_additional_salary:
additional_income += self.get_future_recurring_additional_amount(earning.additional_salary,
- earning.additional_amount) # Used earning.additional_amount to consider the amount for the full month
+ earning.additional_amount, payroll_period) # Used earning.additional_amount to consider the amount for the full month
if earning.deduct_full_tax_on_selected_payroll_date:
additional_income_with_full_tax += additional_amount
@@ -920,7 +921,7 @@ class SalarySlip(TransactionBase):
if additional_amount and ded.is_recurring_additional_salary:
additional_income -= self.get_future_recurring_additional_amount(ded.additional_salary,
- ded.additional_amount) # Used ded.additional_amount to consider the amount for the full month
+ ded.additional_amount, payroll_period) # Used ded.additional_amount to consider the amount for the full month
return frappe._dict({
"taxable_earnings": taxable_earnings,
@@ -929,12 +930,18 @@ class SalarySlip(TransactionBase):
"flexi_benefits": flexi_benefits
})
- def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount):
+ def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount, payroll_period):
future_recurring_additional_amount = 0
to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date')
# future month count excluding current
from_date, to_date = getdate(self.start_date), getdate(to_date)
+
+ # If recurring period end date is beyond the payroll period,
+ # last day of payroll period should be considered for recurring period calculation
+ if getdate(to_date) > getdate(payroll_period.end_date):
+ to_date = getdate(payroll_period.end_date)
+
future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_date.month)
if future_recurring_period > 0:
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index bcf981b74d..30b604b2c0 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -147,7 +147,7 @@ class TestSalarySlip(unittest.TestCase):
# Payroll based on attendance
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
- emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
+ emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List")
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
# mark attendance
@@ -370,6 +370,7 @@ class TestSalarySlip(unittest.TestCase):
create_loan_type("Car Loan", 500000, 8.4,
is_term_loan=1,
mode_of_payment='Cash',
+ disbursement_account='Disbursement Account - _TC',
payment_account='Payment Account - _TC',
loan_account='Loan Account - _TC',
interest_income_account='Interest Income Account - _TC',
diff --git a/erpnext/payroll/doctype/salary_structure/salary_structure.json b/erpnext/payroll/doctype/salary_structure/salary_structure.json
index 5dd1d701f0..8df995769d 100644
--- a/erpnext/payroll/doctype/salary_structure/salary_structure.json
+++ b/erpnext/payroll/doctype/salary_structure/salary_structure.json
@@ -58,6 +58,7 @@
"width": "50%"
},
{
+ "allow_on_submit": 1,
"default": "Yes",
"fieldname": "is_active",
"fieldtype": "Select",
@@ -232,10 +233,11 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-03-31 15:41:12.342380",
+ "modified": "2022-02-03 23:50:10.205676",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Structure",
+ "naming_rule": "Set by user",
"owner": "Administrator",
"permissions": [
{
@@ -271,5 +273,6 @@
],
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/portal/doctype/homepage/homepage.js b/erpnext/portal/doctype/homepage/homepage.js
index c7c66e0055..59f808a315 100644
--- a/erpnext/portal/doctype/homepage/homepage.js
+++ b/erpnext/portal/doctype/homepage/homepage.js
@@ -3,9 +3,9 @@
frappe.ui.form.on('Homepage', {
setup: function(frm) {
- frm.fields_dict["products"].grid.get_field("item_code").get_query = function(){
+ frm.fields_dict["products"].grid.get_field("item").get_query = function() {
return {
- filters: {'show_in_website': 1}
+ filters: {'published': 1}
}
}
},
@@ -21,11 +21,10 @@ frappe.ui.form.on('Homepage', {
});
frappe.ui.form.on('Homepage Featured Product', {
-
- view: function(frm, cdt, cdn){
- var child= locals[cdt][cdn]
- if(child.item_code && frm.doc.products_url){
- window.location.href = frm.doc.products_url + '/' + encodeURIComponent(child.item_code);
+ view: function(frm, cdt, cdn) {
+ var child= locals[cdt][cdn];
+ if (child.item_code && child.route) {
+ window.open('/' + child.route, '_blank');
}
}
});
diff --git a/erpnext/portal/doctype/homepage/homepage.json b/erpnext/portal/doctype/homepage/homepage.json
index ad27278dc6..73f816d4d4 100644
--- a/erpnext/portal/doctype/homepage/homepage.json
+++ b/erpnext/portal/doctype/homepage/homepage.json
@@ -1,518 +1,143 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "",
+ "actions": [],
"beta": 1,
"creation": "2016-04-22 05:27:52.109319",
- "custom": 0,
- "docstatus": 0,
"doctype": "DocType",
"document_type": "Setup",
- "editable_grid": 0,
"engine": "InnoDB",
+ "field_order": [
+ "company",
+ "hero_section_based_on",
+ "column_break_2",
+ "title",
+ "section_break_4",
+ "tag_line",
+ "description",
+ "hero_image",
+ "slideshow",
+ "hero_section",
+ "products_section",
+ "products_url",
+ "products"
+ ],
"fields": [
{
- "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": 1,
- "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": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "reqd": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"fieldname": "hero_section_based_on",
"fieldtype": "Select",
- "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": "Hero Section Based On",
- "length": 0,
- "no_copy": 0,
- "options": "Default\nSlideshow\nHomepage Section",
- "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
+ "options": "Default\nSlideshow\nHomepage Section"
},
{
- "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
+ "fieldtype": "Column Break"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "",
"fieldname": "title",
"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": "Title",
- "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
+ "label": "Title"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "",
"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": "Hero Section",
- "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
+ "label": "Hero Section"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"depends_on": "eval:doc.hero_section_based_on === 'Default'",
"description": "Company Tagline for website homepage",
"fieldname": "tag_line",
"fieldtype": "Data",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
"in_list_view": 1,
- "in_standard_filter": 0,
"label": "Tag Line",
- "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": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "reqd": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"depends_on": "eval:doc.hero_section_based_on === 'Default'",
"description": "Company Description for website homepage",
"fieldname": "description",
"fieldtype": "Text",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
"in_list_view": 1,
- "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": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "reqd": 1
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"depends_on": "eval:doc.hero_section_based_on === 'Default'",
"fieldname": "hero_image",
"fieldtype": "Attach Image",
- "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": "Hero Image",
- "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
+ "label": "Hero Image"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"depends_on": "eval:doc.hero_section_based_on === 'Slideshow'",
- "description": "",
"fieldname": "slideshow",
"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": "Homepage Slideshow",
- "length": 0,
- "no_copy": 0,
- "options": "Website Slideshow",
- "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
+ "options": "Website Slideshow"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"depends_on": "eval:doc.hero_section_based_on === 'Homepage Section'",
"fieldname": "hero_section",
"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": "Homepage Section",
- "length": 0,
- "no_copy": 0,
- "options": "Homepage Section",
- "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
+ "options": "Homepage Section"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "",
"fieldname": "products_section",
"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": "Products",
- "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
+ "label": "Products"
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": "/products",
+ "default": "/all-products",
"fieldname": "products_url",
"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": "URL for \"All Products\"",
- "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
+ "label": "URL for \"All Products\""
},
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
"description": "Products to be shown on website homepage",
"fieldname": "products",
"fieldtype": "Table",
- "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": "Products",
- "length": 0,
- "no_copy": 0,
"options": "Homepage Featured Product",
- "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,
"width": "40px"
}
],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
"issingle": 1,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2019-03-02 23:12:59.676202",
+ "links": [],
+ "modified": "2021-02-18 13:29:29.531639",
"modified_by": "Administrator",
"module": "Portal",
"name": "Homepage",
- "name_case": "",
"owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
"print": 1,
"read": 1,
- "report": 0,
"role": "System Manager",
- "set_user_permissions": 0,
"share": 1,
- "submit": 0,
"write": 1
},
{
- "amend": 0,
- "cancel": 0,
"create": 1,
"delete": 1,
"email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
"print": 1,
"read": 1,
- "report": 0,
"role": "Administrator",
- "set_user_permissions": 0,
"share": 1,
- "submit": 0,
"write": 1
}
],
- "quick_entry": 0,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
"sort_field": "modified",
"sort_order": "DESC",
"title_field": "company",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/portal/doctype/homepage/homepage.py b/erpnext/portal/doctype/homepage/homepage.py
index 1e056a6dac..8092ba208a 100644
--- a/erpnext/portal/doctype/homepage/homepage.py
+++ b/erpnext/portal/doctype/homepage/homepage.py
@@ -14,12 +14,14 @@ class Homepage(Document):
delete_page_cache('home')
def setup_items(self):
- for d in frappe.get_all('Item', fields=['name', 'item_name', 'description', 'image'],
- filters={'show_in_website': 1}, limit=3):
+ for d in frappe.get_all('Website Item', fields=['name', 'item_name', 'description', 'image', 'route'],
+ filters={'published': 1}, limit=3):
- doc = frappe.get_doc('Item', d.name)
+ doc = frappe.get_doc('Website Item', d.name)
if not doc.route:
# set missing route
doc.save()
self.append('products', dict(item_code=d.name,
- item_name=d.item_name, description=d.description, image=d.image))
+ item_name=d.item_name, description=d.description,
+ image=d.image, route=d.route))
+
diff --git a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json
index 01c32efec9..63789e35b5 100644
--- a/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json
+++ b/erpnext/portal/doctype/homepage_featured_product/homepage_featured_product.json
@@ -25,10 +25,10 @@
"fieldtype": "Link",
"in_filter": 1,
"in_list_view": 1,
- "label": "Item Code",
+ "label": "Item",
"oldfieldname": "item_code",
"oldfieldtype": "Link",
- "options": "Item",
+ "options": "Website Item",
"print_width": "150px",
"reqd": 1,
"search_index": 1,
@@ -63,7 +63,7 @@
"collapsible": 1,
"fieldname": "section_break_5",
"fieldtype": "Section Break",
- "label": "Description"
+ "label": "Details"
},
{
"fetch_from": "item_code.web_long_description",
@@ -89,12 +89,14 @@
"label": "Image"
},
{
+ "fetch_from": "item_code.thumbnail",
"fieldname": "thumbnail",
"fieldtype": "Attach Image",
"hidden": 1,
"label": "Thumbnail"
},
{
+ "fetch_from": "item_code.route",
"fieldname": "route",
"fieldtype": "Small Text",
"label": "route",
@@ -104,7 +106,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2020-08-25 15:27:49.573537",
+ "modified": "2021-02-18 13:05:50.669311",
"modified_by": "Administrator",
"module": "Portal",
"name": "Homepage Featured Product",
diff --git a/erpnext/portal/doctype/products_settings/products_settings.js b/erpnext/portal/doctype/products_settings/products_settings.js
deleted file mode 100644
index 2f8b037164..0000000000
--- a/erpnext/portal/doctype/products_settings/products_settings.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
-
-frappe.ui.form.on('Products Settings', {
- refresh: function(frm) {
- frappe.model.with_doctype('Item', () => {
- const item_meta = frappe.get_meta('Item');
-
- const valid_fields = item_meta.fields.filter(
- df => ['Link', 'Table MultiSelect'].includes(df.fieldtype) && !df.hidden
- ).map(df => ({ label: df.label, value: df.fieldname }));
-
- frm.fields_dict.filter_fields.grid.update_docfield_property(
- 'fieldname', 'fieldtype', 'Select'
- );
- frm.fields_dict.filter_fields.grid.update_docfield_property(
- 'fieldname', 'options', valid_fields
- );
- });
- }
-});
diff --git a/erpnext/portal/doctype/products_settings/products_settings.json b/erpnext/portal/doctype/products_settings/products_settings.json
deleted file mode 100644
index 2cf8431497..0000000000
--- a/erpnext/portal/doctype/products_settings/products_settings.json
+++ /dev/null
@@ -1,389 +0,0 @@
-{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2016-04-22 09:11:55.272398",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 0,
- "engine": "InnoDB",
- "fields": [
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "description": "If checked, the Home page will be the default Item Group for the website",
- "fieldname": "home_page_is_products",
- "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": "Home Page is Products",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "column_break_3",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "show_availability_status",
- "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": "Show Availability Status",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "section_break_5",
- "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": "Product Page",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "default": "6",
- "fieldname": "products_per_page",
- "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": "Products per Page",
- "length": 0,
- "no_copy": 0,
- "options": "",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "enable_field_filters",
- "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": "Enable Field 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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "enable_field_filters",
- "fieldname": "filter_fields",
- "fieldtype": "Table",
- "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 Fields",
- "length": 0,
- "no_copy": 0,
- "options": "Website Filter Field",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "enable_attribute_filters",
- "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": "Enable Attribute 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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "depends_on": "enable_attribute_filters",
- "fieldname": "filter_attributes",
- "fieldtype": "Table",
- "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": "Attributes",
- "length": 0,
- "no_copy": 0,
- "options": "Website Attribute",
- "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
- },
- {
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "hide_variants",
- "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": "Hide Variants",
- "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
- }
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 1,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2019-03-07 19:18:31.822309",
- "modified_by": "Administrator",
- "module": "Portal",
- "name": "Products Settings",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 0,
- "role": "Website Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
-}
\ No newline at end of file
diff --git a/erpnext/portal/doctype/products_settings/products_settings.py b/erpnext/portal/doctype/products_settings/products_settings.py
deleted file mode 100644
index 0e106c634b..0000000000
--- a/erpnext/portal/doctype/products_settings/products_settings.py
+++ /dev/null
@@ -1,43 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and contributors
-# For license information, please see license.txt
-
-
-import frappe
-from frappe import _
-from frappe.model.document import Document
-from frappe.utils import cint
-
-
-class ProductsSettings(Document):
- def validate(self):
- if self.home_page_is_products:
- frappe.db.set_value("Website Settings", None, "home_page", "products")
- elif frappe.db.get_single_value("Website Settings", "home_page") == 'products':
- frappe.db.set_value("Website Settings", None, "home_page", "home")
-
- self.validate_field_filters()
- self.validate_attribute_filters()
- frappe.clear_document_cache("Product Settings", "Product Settings")
-
- def validate_field_filters(self):
- if not (self.enable_field_filters and self.filter_fields): return
-
- item_meta = frappe.get_meta('Item')
- valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ['Link', 'Table MultiSelect']]
-
- for f in self.filter_fields:
- if f.fieldname not in valid_fields:
- frappe.throw(_('Filter Fields Row #{0}: Fieldname
{1} must be of type "Link" or "Table MultiSelect"').format(f.idx, f.fieldname))
-
- def validate_attribute_filters(self):
- if not (self.enable_attribute_filters and self.filter_attributes): return
-
- # if attribute filters are enabled, hide_variants should be disabled
- self.hide_variants = 0
-
-
-def home_page_is_products(doc, method):
- '''Called on saving Website Settings'''
- home_page_is_products = cint(frappe.db.get_single_value('Products Settings', 'home_page_is_products'))
- if home_page_is_products:
- doc.home_page = 'products'
diff --git a/erpnext/portal/doctype/products_settings/test_products_settings.py b/erpnext/portal/doctype/products_settings/test_products_settings.py
deleted file mode 100644
index 66026fc046..0000000000
--- a/erpnext/portal/doctype/products_settings/test_products_settings.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-import unittest
-
-
-class TestProductsSettings(unittest.TestCase):
- pass
diff --git a/erpnext/portal/doctype/website_attribute/website_attribute.json b/erpnext/portal/doctype/website_attribute/website_attribute.json
index 2874dc432c..eed33ec10e 100644
--- a/erpnext/portal/doctype/website_attribute/website_attribute.json
+++ b/erpnext/portal/doctype/website_attribute/website_attribute.json
@@ -1,76 +1,32 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "beta": 0,
- "creation": "2019-01-01 13:04:54.479079",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "creation": "2019-01-01 13:04:54.479079",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "attribute"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "attribute",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Attribute",
- "length": 0,
- "no_copy": 0,
- "options": "Item Attribute",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
- "unique": 0
+ "fieldname": "attribute",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Attribute",
+ "options": "Item Attribute",
+ "reqd": 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": 1,
- "max_attachments": 0,
- "modified": "2019-01-01 13:04:59.715572",
- "modified_by": "Administrator",
- "module": "Portal",
- "name": "Website Attribute",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0,
- "track_views": 0
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2021-02-18 13:18:57.810536",
+ "modified_by": "Administrator",
+ "module": "Portal",
+ "name": "Website Attribute",
+ "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/portal/product_configurator/test_product_configurator.py b/erpnext/portal/product_configurator/test_product_configurator.py
deleted file mode 100644
index b478489920..0000000000
--- a/erpnext/portal/product_configurator/test_product_configurator.py
+++ /dev/null
@@ -1,143 +0,0 @@
-import unittest
-
-import frappe
-from bs4 import BeautifulSoup
-from frappe.utils import get_html_for_route
-
-from erpnext.portal.product_configurator.utils import get_products_for_website
-
-test_dependencies = ["Item"]
-
-class TestProductConfigurator(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
- cls.create_variant_item()
-
- @classmethod
- def create_variant_item(cls):
- if not frappe.db.exists('Item', '_Test Variant Item - 2XL'):
- frappe.get_doc({
- "description": "_Test Variant Item - 2XL",
- "item_code": "_Test Variant Item - 2XL",
- "item_name": "_Test Variant Item - 2XL",
- "doctype": "Item",
- "is_stock_item": 1,
- "variant_of": "_Test Variant Item",
- "item_group": "_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"
- }],
- "attributes": [
- {
- "attribute": "Test Size",
- "attribute_value": "2XL"
- }
- ],
- "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})
-
- products_settings = frappe.get_doc('Products Settings')
- products_settings.enable_field_filters = 1
- products_settings.append('filter_fields', {'fieldname': 'item_group'})
- products_settings.append('filter_fields', {'fieldname': 'stock_uom'})
- products_settings.save()
-
- html = get_html_for_route('all-products')
-
- soup = BeautifulSoup(html, 'html.parser')
- products_list = soup.find(class_='products-list')
- items = products_list.find_all(class_='card')
- self.assertEqual(len(items), len(template_items + variant_items))
-
- items_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_in_website': 1})
- variants_with_item_group = frappe.get_all('Item', {'item_group': '_Test Item Group Desktops', 'show_variant_in_website': 1})
-
- # mock query params
- frappe.form_dict = frappe._dict({
- 'field_filters': '{"item_group":["_Test Item Group Desktops"]}'
- })
- html = get_html_for_route('all-products')
- soup = BeautifulSoup(html, 'html.parser')
- products_list = soup.find(class_='products-list')
- items = products_list.find_all(class_='card')
- self.assertEqual(len(items), len(items_with_item_group + variants_with_item_group))
-
-
- def test_get_products_for_website(self):
- items = get_products_for_website(attribute_filters={
- '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()
diff --git a/erpnext/portal/product_configurator/utils.py b/erpnext/portal/product_configurator/utils.py
deleted file mode 100644
index cf623c8d42..0000000000
--- a/erpnext/portal/product_configurator/utils.py
+++ /dev/null
@@ -1,446 +0,0 @@
-import frappe
-from frappe.utils import cint
-
-from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager
-from erpnext.setup.doctype.item_group.item_group import get_child_groups
-from erpnext.shopping_cart.product_info import get_product_info_for_website
-
-
-def get_field_filter_data():
- product_settings = get_product_settings()
- filter_fields = [row.fieldname for row in product_settings.filter_fields]
-
- meta = frappe.get_meta('Item')
- fields = [df for df in meta.fields if df.fieldname in filter_fields]
-
- filter_data = []
- for f in fields:
- doctype = f.get_link_doctype()
-
- # apply enable/disable/show_in_website filter
- meta = frappe.get_meta(doctype)
- filters = {}
- if meta.has_field('enabled'):
- filters['enabled'] = 1
- if meta.has_field('disabled'):
- filters['disabled'] = 0
- if meta.has_field('show_in_website'):
- filters['show_in_website'] = 1
-
- values = [d.name for d in frappe.get_all(doctype, filters)]
- filter_data.append([f, values])
-
- return filter_data
-
-
-def get_attribute_filter_data():
- product_settings = get_product_settings()
- attributes = [row.attribute for row in product_settings.filter_attributes]
- attribute_docs = [
- frappe.get_doc('Item Attribute', attribute) for attribute in attributes
- ]
-
- # mark attribute values as checked if they are present in the request url
- if frappe.form_dict:
- for attr in attribute_docs:
- if attr.name in frappe.form_dict:
- value = frappe.form_dict[attr.name]
- if value:
- enabled_values = value.split(',')
- else:
- enabled_values = []
-
- for v in enabled_values:
- for item_attribute_row in attr.item_attribute_values:
- if v == item_attribute_row.attribute_value:
- item_attribute_row.checked = True
-
- return attribute_docs
-
-
-def get_products_for_website(field_filters=None, attribute_filters=None, search=None):
- if attribute_filters:
- item_codes = get_item_codes_by_attributes(attribute_filters)
- items_by_attributes = get_items([['name', 'in', item_codes]])
-
- if field_filters:
- items_by_fields = get_items_by_fields(field_filters)
-
- if attribute_filters and not field_filters:
- return items_by_attributes
-
- if field_filters and not attribute_filters:
- return items_by_fields
-
- if field_filters and attribute_filters:
- items_intersection = []
- item_codes_in_attribute = [item.name for item in items_by_attributes]
-
- for item in items_by_fields:
- if item.name in item_codes_in_attribute:
- items_intersection.append(item)
-
- return items_intersection
-
- if search:
- return get_items(search=search)
-
- return get_items()
-
-
-@frappe.whitelist(allow_guest=True)
-def get_products_html_for_website(field_filters=None, attribute_filters=None):
- field_filters = frappe.parse_json(field_filters)
- attribute_filters = frappe.parse_json(attribute_filters)
- set_item_group_filters(field_filters)
-
- items = get_products_for_website(field_filters, attribute_filters)
- html = ''.join(get_html_for_items(items))
-
- if not items:
- html = frappe.render_template('erpnext/www/all-products/not_found.html', {})
-
- return html
-
-def set_item_group_filters(field_filters):
- if field_filters is not None and 'item_group' in field_filters:
- field_filters['item_group'] = [ig[0] for ig in get_child_groups(field_filters['item_group'])]
-
-
-def get_item_codes_by_attributes(attribute_filters, template_item_code=None):
- items = []
-
- for attribute, values in attribute_filters.items():
- attribute_values = values
-
- if not isinstance(attribute_values, list):
- attribute_values = [attribute_values]
-
- if not attribute_values: continue
-
- wheres = []
- query_values = []
- for attribute_value in attribute_values:
- wheres.append('( attribute = %s and attribute_value = %s )')
- query_values += [attribute, attribute_value]
-
- attribute_query = ' or '.join(wheres)
-
- if template_item_code:
- variant_of_query = 'AND t2.variant_of = %s'
- query_values.append(template_item_code)
- else:
- variant_of_query = ''
-
- query = '''
- SELECT
- t1.parent
- FROM
- `tabItem Variant Attribute` t1
- WHERE
- 1 = 1
- AND (
- {attribute_query}
- )
- AND EXISTS (
- SELECT
- 1
- FROM
- `tabItem` t2
- WHERE
- t2.name = t1.parent
- {variant_of_query}
- )
- GROUP BY
- t1.parent
- ORDER BY
- NULL
- '''.format(attribute_query=attribute_query, variant_of_query=variant_of_query)
-
- item_codes = set([r[0] for r in frappe.db.sql(query, query_values)])
- items.append(item_codes)
-
- res = list(set.intersection(*items))
-
- return res
-
-
-@frappe.whitelist(allow_guest=True)
-def get_attributes_and_values(item_code):
- '''Build a list of attributes and their possible values.
- This will ignore the values upon selection of which there cannot exist one item.
- '''
- item_cache = ItemVariantsCacheManager(item_code)
- item_variants_data = item_cache.get_item_variants_data()
-
- attributes = get_item_attributes(item_code)
- attribute_list = [a.attribute for a in attributes]
-
- valid_options = {}
- for item_code, attribute, attribute_value in item_variants_data:
- if attribute in attribute_list:
- valid_options.setdefault(attribute, set()).add(attribute_value)
-
- item_attribute_values = frappe.db.get_all('Item Attribute Value',
- ['parent', 'attribute_value', 'idx'], order_by='parent asc, idx asc')
- ordered_attribute_value_map = frappe._dict()
- for iv in item_attribute_values:
- ordered_attribute_value_map.setdefault(iv.parent, []).append(iv.attribute_value)
-
- # build attribute values in idx order
- for attr in attributes:
- valid_attribute_values = valid_options.get(attr.attribute, [])
- ordered_values = ordered_attribute_value_map.get(attr.attribute, [])
- attr['values'] = [v for v in ordered_values if v in valid_attribute_values]
-
- return attributes
-
-
-@frappe.whitelist(allow_guest=True)
-def get_next_attribute_and_values(item_code, selected_attributes):
- '''Find the count of Items that match the selected attributes.
- Also, find the attribute values that are not applicable for further searching.
- If less than equal to 10 items are found, return item_codes of those items.
- If one item is matched exactly, return item_code of that item.
- '''
- selected_attributes = frappe.parse_json(selected_attributes)
-
- item_cache = ItemVariantsCacheManager(item_code)
- item_variants_data = item_cache.get_item_variants_data()
-
- attributes = get_item_attributes(item_code)
- attribute_list = [a.attribute for a in attributes]
- filtered_items = get_items_with_selected_attributes(item_code, selected_attributes)
-
- next_attribute = None
-
- for attribute in attribute_list:
- if attribute not in selected_attributes:
- next_attribute = attribute
- break
-
- valid_options_for_attributes = frappe._dict({})
-
- for a in attribute_list:
- valid_options_for_attributes[a] = set()
-
- selected_attribute = selected_attributes.get(a, None)
- if selected_attribute:
- # already selected attribute values are valid options
- valid_options_for_attributes[a].add(selected_attribute)
-
- for row in item_variants_data:
- item_code, attribute, attribute_value = row
- if item_code in filtered_items and attribute not in selected_attributes and attribute in attribute_list:
- valid_options_for_attributes[attribute].add(attribute_value)
-
- optional_attributes = item_cache.get_optional_attributes()
- exact_match = []
- # search for exact match if all selected attributes are required attributes
- if len(selected_attributes.keys()) >= (len(attribute_list) - len(optional_attributes)):
- item_attribute_value_map = item_cache.get_item_attribute_value_map()
- for item_code, attr_dict in item_attribute_value_map.items():
- if item_code in filtered_items and set(attr_dict.keys()) == set(selected_attributes.keys()):
- exact_match.append(item_code)
-
- filtered_items_count = len(filtered_items)
-
- # get product info if exact match
- from erpnext.shopping_cart.product_info import get_product_info_for_website
- if exact_match:
- data = get_product_info_for_website(exact_match[0])
- product_info = data.product_info
- if product_info:
- product_info["allow_items_not_in_stock"] = cint(data.cart_settings.allow_items_not_in_stock)
- if not data.cart_settings.show_price:
- product_info = None
- else:
- product_info = None
-
- return {
- 'next_attribute': next_attribute,
- 'valid_options_for_attributes': valid_options_for_attributes,
- 'filtered_items_count': filtered_items_count,
- 'filtered_items': filtered_items if filtered_items_count < 10 else [],
- 'exact_match': exact_match,
- 'product_info': product_info
- }
-
-
-def get_items_with_selected_attributes(item_code, selected_attributes):
- item_cache = ItemVariantsCacheManager(item_code)
- attribute_value_item_map = item_cache.get_attribute_value_item_map()
-
- items = []
- for attribute, value in selected_attributes.items():
- filtered_items = attribute_value_item_map.get((attribute, value), [])
- items.append(set(filtered_items))
-
- return set.intersection(*items)
-
-
-def get_items_by_fields(field_filters):
- meta = frappe.get_meta('Item')
- filters = []
- for fieldname, values in field_filters.items():
- if not values: continue
-
- _doctype = 'Item'
- _fieldname = fieldname
-
- df = meta.get_field(fieldname)
- if df.fieldtype == 'Table MultiSelect':
- child_doctype = df.options
- child_meta = frappe.get_meta(child_doctype)
- fields = child_meta.get("fields", { "fieldtype": "Link", "in_list_view": 1 })
- if fields:
- _doctype = child_doctype
- _fieldname = fields[0].fieldname
-
- if len(values) == 1:
- filters.append([_doctype, _fieldname, '=', values[0]])
- else:
- filters.append([_doctype, _fieldname, 'in', values])
-
- return get_items(filters)
-
-
-def get_items(filters=None, search=None):
- start = frappe.form_dict.get('start', 0)
- products_settings = get_product_settings()
- page_length = products_settings.products_per_page
-
- filters = filters or []
- # convert to list of filters
- if isinstance(filters, dict):
- filters = [['Item', fieldname, '=', value] for fieldname, value in filters.items()]
-
- enabled_items_filter = get_conditions({ 'disabled': 0 }, 'and')
-
- show_in_website_condition = ''
- if products_settings.hide_variants:
- show_in_website_condition = get_conditions({'show_in_website': 1 }, 'and')
- else:
- show_in_website_condition = get_conditions([
- ['show_in_website', '=', 1],
- ['show_variant_in_website', '=', 1]
- ], 'or')
-
- search_condition = ''
- if search:
- # Default fields to search from
- default_fields = {'name', 'item_name', 'description', 'item_group'}
-
- # Get meta search fields
- meta = frappe.get_meta("Item")
- meta_fields = set(meta.get_search_fields())
-
- # Join the meta fields and default fields set
- search_fields = default_fields.union(meta_fields)
- try:
- if frappe.db.count('Item', cache=True) > 50000:
- search_fields.remove('description')
- except KeyError:
- pass
-
- # Build or filters for query
- search = '%{}%'.format(search)
- or_filters = [[field, 'like', search] for field in search_fields]
-
- search_condition = get_conditions(or_filters, 'or')
-
- filter_condition = get_conditions(filters, 'and')
-
- where_conditions = ' and '.join(
- [condition for condition in [enabled_items_filter, show_in_website_condition, \
- search_condition, filter_condition] if condition]
- )
-
- left_joins = []
- for f in filters:
- if len(f) == 4 and f[0] != 'Item':
- left_joins.append(f[0])
-
- left_join = ' '.join(['LEFT JOIN `tab{0}` on (`tab{0}`.parent = `tabItem`.name)'.format(l) for l in left_joins])
-
- results = frappe.db.sql('''
- SELECT
- `tabItem`.`name`, `tabItem`.`item_name`, `tabItem`.`item_code`,
- `tabItem`.`website_image`, `tabItem`.`image`,
- `tabItem`.`web_long_description`, `tabItem`.`description`,
- `tabItem`.`route`, `tabItem`.`item_group`
- FROM
- `tabItem`
- {left_join}
- WHERE
- {where_conditions}
- GROUP BY
- `tabItem`.`name`
- ORDER BY
- `tabItem`.`weightage` DESC
- LIMIT
- {page_length}
- OFFSET
- {start}
- '''.format(
- where_conditions=where_conditions,
- start=start,
- page_length=page_length,
- left_join=left_join
- )
- , as_dict=1)
-
- for r in results:
- r.description = r.web_long_description or r.description
- r.image = r.website_image or r.image
- product_info = get_product_info_for_website(r.item_code, skip_quotation_creation=True).get('product_info')
- if product_info:
- r.formatted_price = product_info['price'].get('formatted_price') if product_info['price'] else None
-
- return results
-
-
-def get_conditions(filter_list, and_or='and'):
- from frappe.model.db_query import DatabaseQuery
-
- if not filter_list:
- return ''
-
- conditions = []
- DatabaseQuery('Item').build_filter_conditions(filter_list, conditions, ignore_permissions=True)
- join_by = ' {0} '.format(and_or)
-
- return '(' + join_by.join(conditions) + ')'
-
-# utilities
-
-def get_item_attributes(item_code):
- attributes = frappe.db.get_all('Item Variant Attribute',
- fields=['attribute'],
- filters={
- 'parenttype': 'Item',
- 'parent': item_code
- },
- order_by='idx asc'
- )
-
- optional_attributes = ItemVariantsCacheManager(item_code).get_optional_attributes()
-
- for a in attributes:
- if a.attribute in optional_attributes:
- a.optional = True
-
- return attributes
-
-def get_html_for_items(items):
- html = []
- for item in items:
- html.append(frappe.render_template('erpnext/www/all-products/item_row.html', {
- 'item': item
- }))
- return html
-
-def get_product_settings():
- doc = frappe.get_cached_doc('Products Settings')
- doc.products_per_page = doc.products_per_page or 20
- return doc
diff --git a/erpnext/portal/utils.py b/erpnext/portal/utils.py
index 974b51ef0a..24bcab445a 100644
--- a/erpnext/portal/utils.py
+++ b/erpnext/portal/utils.py
@@ -1,10 +1,10 @@
import frappe
from frappe.utils.nestedset import get_root_of
-from erpnext.shopping_cart.cart import get_debtors_account
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
get_shopping_cart_settings,
)
+from erpnext.e_commerce.shopping_cart.cart import get_debtors_account
def set_default_role(doc, method):
diff --git a/erpnext/public/build.json b/erpnext/public/build.json
index f8e817770d..91a752c291 100644
--- a/erpnext/public/build.json
+++ b/erpnext/public/build.json
@@ -7,7 +7,8 @@
],
"js/erpnext-web.min.js": [
"public/js/website_utils.js",
- "public/js/shopping_cart.js"
+ "public/js/shopping_cart.js",
+ "public/js/wishlist.js"
],
"css/erpnext-web.css": [
"public/scss/website.scss",
@@ -38,7 +39,8 @@
"public/js/utils/dimension_tree_filter.js",
"public/js/telephony.js",
"public/js/templates/call_link.html",
- "public/js/templates/node_card.html"
+ "public/js/templates/node_card.html",
+ "public/js/bulk_transaction_processing.js"
],
"js/item-dashboard.min.js": [
"stock/dashboard/item_dashboard.html",
@@ -65,5 +67,11 @@
"js/hierarchy-chart.min.js": [
"public/js/hierarchy_chart/hierarchy_chart_desktop.js",
"public/js/hierarchy_chart/hierarchy_chart_mobile.js"
+ ],
+ "js/e-commerce.min.js": [
+ "e_commerce/product_ui/views.js",
+ "e_commerce/product_ui/grid.js",
+ "e_commerce/product_ui/list.js",
+ "e_commerce/product_ui/search.js"
]
}
diff --git a/erpnext/public/js/bulk_transaction_processing.js b/erpnext/public/js/bulk_transaction_processing.js
new file mode 100644
index 0000000000..101f50c64a
--- /dev/null
+++ b/erpnext/public/js/bulk_transaction_processing.js
@@ -0,0 +1,30 @@
+frappe.provide("erpnext.bulk_transaction_processing");
+
+$.extend(erpnext.bulk_transaction_processing, {
+ create: function(listview, from_doctype, to_doctype) {
+ let checked_items = listview.get_checked_items();
+ const doc_name = [];
+ checked_items.forEach((Item)=> {
+ if (Item.docstatus == 0) {
+ doc_name.push(Item.name);
+ }
+ });
+
+ let count_of_rows = checked_items.length;
+ frappe.confirm(__("Create {0} {1} ?", [count_of_rows, to_doctype]), ()=>{
+ if (doc_name.length == 0) {
+ frappe.call({
+ method: "erpnext.utilities.bulk_transaction.transaction_processing",
+ args: {data: checked_items, from_doctype: from_doctype, to_doctype: to_doctype}
+ }).then(()=> {
+
+ });
+ if (count_of_rows > 10) {
+ frappe.show_alert("Starting a background job to create {0} {1}", [count_of_rows, to_doctype]);
+ }
+ } else {
+ frappe.msgprint(__("Selected document must be in submitted state"));
+ }
+ });
+ }
+});
\ No newline at end of file
diff --git a/erpnext/public/js/conf.js b/erpnext/public/js/conf.js
index eb709e5e85..a0f56a2d07 100644
--- a/erpnext/public/js/conf.js
+++ b/erpnext/public/js/conf.js
@@ -21,6 +21,6 @@ $.extend(frappe.breadcrumbs.module_map, {
'Geo': 'Settings',
'Portal': 'Website',
'Utilities': 'Settings',
- 'Shopping Cart': 'Website',
+ 'E-commerce': 'Website',
'Contacts': 'CRM'
});
diff --git a/erpnext/public/js/controllers/buying.js b/erpnext/public/js/controllers/buying.js
index d696ef55ae..54e5daa6bd 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -441,7 +441,7 @@ erpnext.buying.get_items_from_product_bundle = function(frm) {
type: "GET",
method: "erpnext.stock.doctype.packed_item.packed_item.get_items_from_product_bundle",
args: {
- args: {
+ row: {
item_code: args.product_bundle,
quantity: args.quantity,
parenttype: frm.doc.doctype,
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index 3791741663..ab3e802051 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -2288,7 +2288,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
() => this.frm.doc.ignore_pricing_rule=1,
() => me.ignore_pricing_rule(),
() => this.frm.doc.ignore_pricing_rule=0,
- () => me.apply_pricing_rule()
+ () => me.apply_pricing_rule(),
+ () => this.frm.save()
]);
} else {
frappe.run_serially([
diff --git a/erpnext/public/js/customer_reviews.js b/erpnext/public/js/customer_reviews.js
new file mode 100644
index 0000000000..e13ded6b48
--- /dev/null
+++ b/erpnext/public/js/customer_reviews.js
@@ -0,0 +1,138 @@
+$(() => {
+ class CustomerReviews {
+ constructor() {
+ this.bind_button_actions();
+ this.start = 0;
+ this.page_length = 10;
+ }
+
+ bind_button_actions() {
+ this.write_review();
+ this.view_more();
+ }
+
+ write_review() {
+ //TODO: make dialog popup on stray page
+ $('.page_content').on('click', '.btn-write-review', (e) => {
+ // Bind action on write a review button
+ const $btn = $(e.currentTarget);
+
+ let d = new frappe.ui.Dialog({
+ title: __("Write a Review"),
+ fields: [
+ {fieldname: "title", fieldtype: "Data", label: "Headline", reqd: 1},
+ {fieldname: "rating", fieldtype: "Rating", label: "Overall Rating", reqd: 1},
+ {fieldtype: "Section Break"},
+ {fieldname: "comment", fieldtype: "Small Text", label: "Your Review"}
+ ],
+ primary_action: function() {
+ let data = d.get_values();
+ frappe.call({
+ method: "erpnext.e_commerce.doctype.item_review.item_review.add_item_review",
+ args: {
+ web_item: $btn.attr('data-web-item'),
+ title: data.title,
+ rating: data.rating,
+ comment: data.comment
+ },
+ freeze: true,
+ freeze_message: __("Submitting Review ..."),
+ callback: (r) => {
+ if (!r.exc) {
+ frappe.msgprint({
+ message: __("Thank you for submitting your review"),
+ title: __("Review Submitted"),
+ indicator: "green"
+ });
+ d.hide();
+ location.reload();
+ }
+ }
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ });
+ }
+
+ view_more() {
+ $('.page_content').on('click', '.btn-view-more', (e) => {
+ // Bind action on view more button
+ const $btn = $(e.currentTarget);
+ $btn.prop('disabled', true);
+
+ this.start += this.page_length;
+ let me = this;
+
+ frappe.call({
+ method: "erpnext.e_commerce.doctype.item_review.item_review.get_item_reviews",
+ args: {
+ web_item: $btn.attr('data-web-item'),
+ start: me.start,
+ end: me.page_length
+ },
+ callback: (result) => {
+ if (result.message) {
+ let res = result.message;
+ me.get_user_review_html(res.reviews);
+
+ $btn.prop('disabled', false);
+ if (res.total_reviews <= (me.start + me.page_length)) {
+ $btn.hide();
+ }
+
+ }
+ }
+ });
+ });
+
+ }
+
+ get_user_review_html(reviews) {
+ let me = this;
+ let $content = $('.user-reviews');
+
+ reviews.forEach((review) => {
+ $content.append(`
+
+
+
+ ${__(review.review_title)}
+
+
+ ${me.get_review_stars(review.rating)}
+
+
+
+
+
+ ${__(review.comment)}
+
+
+
+ ${__(review.customer)}
+
+ ${__(review.published_on)}
+
+
+ `);
+ });
+ }
+
+ get_review_stars(rating) {
+ let stars = ``;
+ for (let i = 1; i < 6; i++) {
+ let fill_class = i <= rating ? 'star-click' : '';
+ stars += `
+
+
+
+ `;
+ }
+ return stars;
+ }
+ }
+
+ new CustomerReviews();
+});
\ No newline at end of file
diff --git a/erpnext/public/js/erpnext-web.bundle.js b/erpnext/public/js/erpnext-web.bundle.js
index 7db6967923..cbe899dc06 100644
--- a/erpnext/public/js/erpnext-web.bundle.js
+++ b/erpnext/public/js/erpnext-web.bundle.js
@@ -1,2 +1,8 @@
import "./website_utils";
+import "./wishlist";
import "./shopping_cart";
+import "./customer_reviews";
+import "../../e_commerce/product_ui/list";
+import "../../e_commerce/product_ui/views";
+import "../../e_commerce/product_ui/grid";
+import "../../e_commerce/product_ui/search";
\ No newline at end of file
diff --git a/erpnext/public/js/erpnext.bundle.js b/erpnext/public/js/erpnext.bundle.js
index 5259bdcc76..b3a68b3862 100644
--- a/erpnext/public/js/erpnext.bundle.js
+++ b/erpnext/public/js/erpnext.bundle.js
@@ -22,5 +22,6 @@ import "./call_popup/call_popup";
import "./utils/dimension_tree_filter";
import "./telephony";
import "./templates/call_link.html";
+import "./bulk_transaction_processing";
// import { sum } from 'frappe/public/utils/util.js'
diff --git a/erpnext/public/js/shopping_cart.js b/erpnext/public/js/shopping_cart.js
index 6a923ae423..d14740c106 100644
--- a/erpnext/public/js/shopping_cart.js
+++ b/erpnext/public/js/shopping_cart.js
@@ -2,8 +2,8 @@
// License: GNU General Public License v3. See license.txt
// shopping cart
-frappe.provide("erpnext.shopping_cart");
-var shopping_cart = erpnext.shopping_cart;
+frappe.provide("erpnext.e_commerce.shopping_cart");
+var shopping_cart = erpnext.e_commerce.shopping_cart;
var getParams = function (url) {
var params = [];
@@ -51,10 +51,10 @@ frappe.ready(function() {
if (referral_sales_partner) {
$(".txtreferral_sales_partner").val(referral_sales_partner);
}
+
// update login
shopping_cart.show_shoppingcart_dropdown();
shopping_cart.set_cart_count();
- shopping_cart.bind_dropdown_cart_buttons();
shopping_cart.show_cart_navbar();
});
@@ -63,7 +63,7 @@ $.extend(shopping_cart, {
$(".shopping-cart").on('shown.bs.dropdown', function() {
if (!$('.shopping-cart-menu .cart-container').length) {
return frappe.call({
- method: 'erpnext.shopping_cart.cart.get_shopping_cart_menu',
+ method: 'erpnext.e_commerce.shopping_cart.cart.get_shopping_cart_menu',
callback: function(r) {
if (r.message) {
$('.shopping-cart-menu').html(r.message);
@@ -75,15 +75,18 @@ $.extend(shopping_cart, {
},
update_cart: function(opts) {
- if(frappe.session.user==="Guest") {
- if(localStorage) {
+ if (frappe.session.user==="Guest") {
+ if (localStorage) {
localStorage.setItem("last_visited", window.location.pathname);
}
- window.location.href = "/login";
+ frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => {
+ window.location.href = res.message || "/login";
+ });
} else {
+ shopping_cart.freeze();
return frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.cart.update_cart",
+ method: "erpnext.e_commerce.shopping_cart.cart.update_cart",
args: {
item_code: opts.item_code,
qty: opts.qty,
@@ -92,10 +95,8 @@ $.extend(shopping_cart, {
},
btn: opts.btn,
callback: function(r) {
- shopping_cart.set_cart_count();
- if (r.message.shopping_cart_menu) {
- $('.shopping-cart-menu').html(r.message.shopping_cart_menu);
- }
+ shopping_cart.unfreeze();
+ shopping_cart.set_cart_count(true);
if(opts.callback)
opts.callback(r);
}
@@ -103,7 +104,9 @@ $.extend(shopping_cart, {
}
},
- set_cart_count: function() {
+ set_cart_count: function(animate=false) {
+ $(".intermediate-empty-cart").remove();
+
var cart_count = frappe.get_cookie("cart_count");
if(frappe.session.user==="Guest") {
cart_count = 0;
@@ -118,24 +121,37 @@ $.extend(shopping_cart, {
if(parseInt(cart_count) === 0 || cart_count === undefined) {
$cart.css("display", "none");
- $(".cart-items").html('Cart is Empty');
$(".cart-tax-items").hide();
$(".btn-place-order").hide();
- $(".cart-addresses").hide();
+ $(".cart-payment-addresses").hide();
+
+ let intermediate_empty_cart_msg = `
+
+ ${ __("Cart is Empty") }
+
+ `;
+ $(".cart-table").after(intermediate_empty_cart_msg);
}
else {
$cart.css("display", "inline");
+ $("#cart-count").text(cart_count);
}
if(cart_count) {
$badge.html(cart_count);
+
+ if (animate) {
+ $cart.addClass("cart-animate");
+ setTimeout(() => {
+ $cart.removeClass("cart-animate");
+ }, 500);
+ }
} else {
$badge.remove();
}
},
shopping_cart_update: function({item_code, qty, cart_dropdown, additional_notes}) {
- frappe.freeze();
shopping_cart.update_cart({
item_code,
qty,
@@ -143,10 +159,12 @@ $.extend(shopping_cart, {
with_items: 1,
btn: this,
callback: function(r) {
- frappe.unfreeze();
if(!r.exc) {
$(".cart-items").html(r.message.items);
- $(".cart-tax-items").html(r.message.taxes);
+ $(".cart-tax-items").html(r.message.total);
+ $(".payment-summary").html(r.message.taxes_and_totals);
+ shopping_cart.set_cart_count();
+
if (cart_dropdown != true) {
$(".cart-icon").hide();
}
@@ -155,35 +173,71 @@ $.extend(shopping_cart, {
});
},
-
- bind_dropdown_cart_buttons: function () {
- $(".cart-icon").on('click', '.number-spinner button', function () {
- var btn = $(this),
- input = btn.closest('.number-spinner').find('input'),
- oldValue = input.val().trim(),
- newVal = 0;
-
- if (btn.attr('data-dir') == 'up') {
- newVal = parseInt(oldValue) + 1;
- } else {
- if (oldValue > 1) {
- newVal = parseInt(oldValue) - 1;
- }
- }
- input.val(newVal);
- var item_code = input.attr("data-item-code");
- shopping_cart.shopping_cart_update({item_code, qty: newVal, cart_dropdown: true});
- return false;
- });
-
- },
-
show_cart_navbar: function () {
frappe.call({
- method: "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.is_cart_enabled",
+ method: "erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings.is_cart_enabled",
callback: function(r) {
$(".shopping-cart").toggleClass('hidden', r.message ? false : true);
}
});
+ },
+
+ toggle_button_class(button, remove, add) {
+ button.removeClass(remove);
+ button.addClass(add);
+ },
+
+ bind_add_to_cart_action() {
+ $('.page_content').on('click', '.btn-add-to-cart-list', (e) => {
+ const $btn = $(e.currentTarget);
+ $btn.prop('disabled', true);
+
+ if (frappe.session.user==="Guest") {
+ if (localStorage) {
+ localStorage.setItem("last_visited", window.location.pathname);
+ }
+ frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => {
+ window.location.href = res.message || "/login";
+ });
+ return;
+ }
+
+ $btn.addClass('hidden');
+ $btn.closest('.cart-action-container').addClass('d-flex');
+ $btn.parent().find('.go-to-cart').removeClass('hidden');
+ $btn.parent().find('.go-to-cart-grid').removeClass('hidden');
+ $btn.parent().find('.cart-indicator').removeClass('hidden');
+
+ const item_code = $btn.data('item-code');
+ erpnext.e_commerce.shopping_cart.update_cart({
+ item_code,
+ qty: 1
+ });
+
+ });
+ },
+
+ freeze() {
+ if (window.location.pathname !== "/cart") return;
+
+ if (!$('#freeze').length) {
+ let freeze = $('
')
+ .appendTo("body");
+
+ setTimeout(function() {
+ freeze.addClass("show");
+ }, 1);
+ } else {
+ $("#freeze").addClass("show");
+ }
+ },
+
+ unfreeze() {
+ if ($('#freeze').length) {
+ let freeze = $('#freeze').removeClass("show");
+ setTimeout(function() {
+ freeze.remove();
+ }, 1);
+ }
}
});
diff --git a/erpnext/public/js/wishlist.js b/erpnext/public/js/wishlist.js
new file mode 100644
index 0000000000..f6599e9f6d
--- /dev/null
+++ b/erpnext/public/js/wishlist.js
@@ -0,0 +1,204 @@
+frappe.provide("erpnext.e_commerce.wishlist");
+var wishlist = erpnext.e_commerce.wishlist;
+
+frappe.provide("erpnext.e_commerce.shopping_cart");
+var shopping_cart = erpnext.e_commerce.shopping_cart;
+
+$.extend(wishlist, {
+ set_wishlist_count: function(animate=false) {
+ // set badge count for wishlist icon
+ var wish_count = frappe.get_cookie("wish_count");
+ if (frappe.session.user==="Guest") {
+ wish_count = 0;
+ }
+
+ if (wish_count) {
+ $(".wishlist").toggleClass('hidden', false);
+ }
+
+ var $wishlist = $('.wishlist-icon');
+ var $badge = $wishlist.find("#wish-count");
+
+ if (parseInt(wish_count) === 0 || wish_count === undefined) {
+ $wishlist.css("display", "none");
+ } else {
+ $wishlist.css("display", "inline");
+ }
+ if (wish_count) {
+ $badge.html(wish_count);
+ if (animate) {
+ $wishlist.addClass('cart-animate');
+ setTimeout(() => {
+ $wishlist.removeClass('cart-animate');
+ }, 500);
+ }
+ } else {
+ $badge.remove();
+ }
+ },
+
+ bind_move_to_cart_action: function() {
+ // move item to cart from wishlist
+ $('.page_content').on("click", ".btn-add-to-cart", (e) => {
+ const $move_to_cart_btn = $(e.currentTarget);
+ let item_code = $move_to_cart_btn.data("item-code");
+
+ shopping_cart.shopping_cart_update({
+ item_code,
+ qty: 1,
+ cart_dropdown: true
+ });
+
+ let success_action = function() {
+ const $card_wrapper = $move_to_cart_btn.closest(".wishlist-card");
+ $card_wrapper.addClass("wish-removed");
+ };
+ let args = { item_code: item_code };
+ this.add_remove_from_wishlist("remove", args, success_action, null, true);
+ });
+ },
+
+ bind_remove_action: function() {
+ // remove item from wishlist
+ let me = this;
+
+ $('.page_content').on("click", ".remove-wish", (e) => {
+ const $remove_wish_btn = $(e.currentTarget);
+ let item_code = $remove_wish_btn.data("item-code");
+
+ let success_action = function() {
+ const $card_wrapper = $remove_wish_btn.closest(".wishlist-card");
+ $card_wrapper.addClass("wish-removed");
+ if (frappe.get_cookie("wish_count") == 0) {
+ $(".page_content").empty();
+ me.render_empty_state();
+ }
+ };
+ let args = { item_code: item_code };
+ this.add_remove_from_wishlist("remove", args, success_action);
+ });
+ },
+
+ bind_wishlist_action() {
+ // 'wish'('like') or 'unwish' item in product listing
+ $('.page_content').on('click', '.like-action, .like-action-list', (e) => {
+ const $btn = $(e.currentTarget);
+ this.wishlist_action($btn);
+ });
+ },
+
+ wishlist_action(btn) {
+ const $wish_icon = btn.find('.wish-icon');
+ let me = this;
+
+ if (frappe.session.user==="Guest") {
+ if (localStorage) {
+ localStorage.setItem("last_visited", window.location.pathname);
+ }
+ this.redirect_guest();
+ return;
+ }
+
+ let success_action = function() {
+ erpnext.e_commerce.wishlist.set_wishlist_count(true);
+ };
+
+ if ($wish_icon.hasClass('wished')) {
+ // un-wish item
+ btn.removeClass("like-animate");
+ btn.addClass("like-action-wished");
+ this.toggle_button_class($wish_icon, 'wished', 'not-wished');
+
+ let args = { item_code: btn.data('item-code') };
+ let failure_action = function() {
+ me.toggle_button_class($wish_icon, 'not-wished', 'wished');
+ };
+ this.add_remove_from_wishlist("remove", args, success_action, failure_action);
+ } else {
+ // wish item
+ btn.addClass("like-animate");
+ btn.addClass("like-action-wished");
+ this.toggle_button_class($wish_icon, 'not-wished', 'wished');
+
+ let args = {item_code: btn.data('item-code')};
+ let failure_action = function() {
+ me.toggle_button_class($wish_icon, 'wished', 'not-wished');
+ };
+ this.add_remove_from_wishlist("add", args, success_action, failure_action);
+ }
+ },
+
+ toggle_button_class(button, remove, add) {
+ button.removeClass(remove);
+ button.addClass(add);
+ },
+
+ add_remove_from_wishlist(action, args, success_action, failure_action, async=false) {
+ /* AJAX call to add or remove Item from Wishlist
+ action: "add" or "remove"
+ args: args for method (item_code, price, formatted_price),
+ success_action: method to execute on successs,
+ failure_action: method to execute on failure,
+ async: make call asynchronously (true/false). */
+ if (frappe.session.user==="Guest") {
+ if (localStorage) {
+ localStorage.setItem("last_visited", window.location.pathname);
+ }
+ this.redirect_guest();
+ } else {
+ let method = "erpnext.e_commerce.doctype.wishlist.wishlist.add_to_wishlist";
+ if (action === "remove") {
+ method = "erpnext.e_commerce.doctype.wishlist.wishlist.remove_from_wishlist";
+ }
+
+ frappe.call({
+ async: async,
+ type: "POST",
+ method: method,
+ args: args,
+ callback: function (r) {
+ if (r.exc) {
+ if (failure_action && (typeof failure_action === 'function')) {
+ failure_action();
+ }
+ frappe.msgprint({
+ message: __("Sorry, something went wrong. Please refresh."),
+ indicator: "red", title: __("Note")
+ });
+ } else if (success_action && (typeof success_action === 'function')) {
+ success_action();
+ }
+ }
+ });
+ }
+ },
+
+ redirect_guest() {
+ frappe.call('erpnext.e_commerce.api.get_guest_redirect_on_action').then((res) => {
+ window.location.href = res.message || "/login";
+ });
+ },
+
+ render_empty_state() {
+ $(".page_content").append(`
+
+
+
+
+
${ __('Wishlist is empty !') }
+
+ `);
+ }
+
+});
+
+frappe.ready(function() {
+ if (window.location.pathname !== "/wishlist") {
+ $(".wishlist").toggleClass('hidden', true);
+ wishlist.set_wishlist_count();
+ } else {
+ wishlist.bind_move_to_cart_action();
+ wishlist.bind_remove_action();
+ }
+
+});
\ No newline at end of file
diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss
index fef1e76154..4b645b9dde 100644
--- a/erpnext/public/scss/shopping_cart.scss
+++ b/erpnext/public/scss/shopping_cart.scss
@@ -1,16 +1,17 @@
@import "frappe/public/scss/common/mixins";
-body.product-page {
- background: var(--gray-50);
+:root {
+ --green-info: #38A160;
+ --product-bg-color: white;
+ --body-bg-color: var(--gray-50);
}
+body.product-page {
+ background: var(--body-bg-color);
+}
.item-breadcrumbs {
.breadcrumb-container {
- ol.breadcrumb {
- background-color: var(--gray-50) !important;
- }
-
a {
color: var(--gray-900);
}
@@ -71,9 +72,21 @@ body.product-page {
}
}
+.no-image-item {
+ height: 340px;
+ width: 340px;
+ background: var(--gray-100);
+ border-radius: var(--border-radius);
+ font-size: 2rem;
+ color: var(--gray-500);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
.item-card-group-section {
.card {
- height: 360px;
+ height: 100%;
align-items: center;
justify-content: center;
@@ -83,6 +96,19 @@ body.product-page {
}
}
+ .card:hover, .card:focus-within {
+ .btn-add-to-cart-list {
+ visibility: visible;
+ }
+ .like-action {
+ visibility: visible;
+ }
+ .btn-explore-variants {
+ visibility: visible;
+ }
+ }
+
+
.card-img-container {
height: 210px;
width: 100%;
@@ -96,14 +122,28 @@ body.product-page {
.no-image {
@include flex(flex, center, center, null);
- height: 200px;
- margin: 0 auto;
- margin-top: var(--margin-xl);
+ height: 220px;
+ background: var(--gray-100);
+ width: 100%;
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ font-size: 2rem;
+ color: var(--gray-500);
+ }
+
+ .no-image-list {
+ @include flex(flex, center, center, null);
+ height: 150px;
background: var(--gray-100);
- width: 80%;
border-radius: var(--border-radius);
font-size: 2rem;
color: var(--gray-500);
+ margin-top: 15px;
+ margin-bottom: 15px;
+ }
+
+ .card-body-flex {
+ display: flex;
+ flex-direction: column;
}
.product-title {
@@ -136,15 +176,75 @@ body.product-page {
font-weight: 600;
color: var(--text-color);
margin: var(--margin-sm) 0;
+ margin-bottom: auto !important;
+
+ .striked-price {
+ font-weight: 500;
+ font-size: 15px;
+ color: var(--gray-500);
+ }
+ }
+
+ .product-info-green {
+ color: var(--green-info);
+ font-weight: 600;
}
.item-card {
padding: var(--padding-sm);
+ min-width: 300px;
+ }
+
+ .wishlist-card {
+ padding: var(--padding-sm);
+ min-width: 260px;
+ .card-body-flex {
+ display: flex;
+ flex-direction: column;
+ }
+ }
+}
+
+#products-list-area, #products-grid-area {
+ padding: 0 5px;
+}
+
+.list-row {
+ background-color: white;
+ padding-bottom: 1rem;
+ padding-top: 1.5rem !important;
+ border-radius: 8px;
+ border-bottom: 1px solid var(--gray-50);
+
+ &:hover, &:focus-within {
+ box-shadow: 0px 16px 60px rgba(0, 0, 0, 0.08), 0px 8px 30px -20px rgba(0, 0, 0, 0.04);
+ transition: box-shadow 400ms;
+
+ .btn-add-to-cart-list {
+ visibility: visible;
+ }
+ .like-action-list {
+ visibility: visible;
+ }
+ .btn-explore-variants {
+ visibility: visible;
+ }
+ }
+
+ .product-code {
+ padding-top: 0 !important;
+ }
+
+ .btn-explore-variants {
+ min-width: 135px;
+ max-height: 30px;
+ float: right;
+ padding: 0.25rem 1rem;
}
}
[data-doctype="Item Group"],
-#page-all-products {
+#page-index {
.page-header {
font-size: 20px;
font-weight: 700;
@@ -184,28 +284,76 @@ body.product-page {
}
}
+.product-filter {
+ width: 14px !important;
+ height: 14px !important;
+}
+
+.discount-filter {
+ &:before {
+ width: 14px !important;
+ height: 14px !important;
+ }
+}
+
+.list-image {
+ border: none !important;
+ overflow: hidden;
+ max-height: 200px;
+ background-color: white;
+}
+
.product-container {
@include card($padding: var(--padding-md));
- min-height: 70vh;
+ background-color: var(--product-bg-color) !important;
+ min-height: fit-content;
.product-details {
- max-width: 40%;
- margin-left: -30px;
+ max-width: 50%;
.btn-add-to-cart {
- font-size: var(--text-base);
+ font-size: 14px;
+ }
+ }
+
+ &.item-main {
+ .product-image {
+ width: 100%;
+ }
+ }
+
+ .expand {
+ max-width: 100% !important; // expand in absence of slideshow
+ }
+
+ @media (max-width: 789px) {
+ .product-details {
+ max-width: 90% !important;
+
+ .btn-add-to-cart {
+ font-size: 14px;
+ }
+ }
+ }
+
+ .btn-add-to-wishlist {
+ svg use {
+ stroke: #F47A7A;
+ }
+ }
+
+ .btn-view-in-wishlist {
+ svg use {
+ fill: #F47A7A;
+ stroke: none;
}
}
.product-title {
- font-size: 24px;
+ font-size: 16px;
font-weight: 600;
color: var(--text-color);
- }
-
- .product-code {
- color: var(--text-muted);
- font-size: 13px;
+ padding: 0 !important;
}
.product-description {
@@ -242,7 +390,7 @@ body.product-page {
max-height: 430px;
}
- overflow: scroll;
+ overflow: auto;
}
.item-slideshow-image {
@@ -261,29 +409,116 @@ body.product-page {
.item-cart {
.product-price {
- font-size: 20px;
+ font-size: 22px;
color: var(--text-color);
font-weight: 600;
.formatted-price {
color: var(--text-muted);
- font-size: var(--text-base);
+ font-size: 14px;
}
}
.no-stock {
font-size: var(--text-base);
}
+
+ .offers-heading {
+ font-size: 16px !important;
+ color: var(--text-color);
+ .tag-icon {
+ --icon-stroke: var(--gray-500);
+ }
+ }
+
+ .w-30-40 {
+ width: 30%;
+
+ @media (max-width: 992px) {
+ width: 40%;
+ }
+ }
+ }
+
+ .tab-content {
+ font-size: 14px;
+ }
+}
+
+// Item Recommendations
+.recommended-item-section {
+ padding-right: 0;
+
+ .recommendation-header {
+ font-size: 16px;
+ font-weight: 500
+ }
+
+ .recommendation-container {
+ padding: .5rem;
+ min-height: 0px;
+
+ .r-item-image {
+ min-height: 100px;
+ width: 40%;
+
+ .r-product-image {
+ padding: 2px 15px;
+ }
+
+ .no-image-r-item {
+ display: flex; justify-content: center;
+ background-color: var(--gray-200);
+ align-items: center;
+ color: var(--gray-400);
+ margin-top: .15rem;
+ border-radius: 6px;
+ height: 100%;
+ font-size: 24px;
+ }
+ }
+
+ .r-item-info {
+ font-size: 14px;
+ padding-right: 0;
+ padding-left: 10px;
+ width: 60%;
+
+ a {
+ color: var(--gray-800);
+ font-weight: 400;
+ }
+
+ .item-price {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-color);
+ }
+
+ .striked-item-price {
+ font-weight: 500;
+ color: var(--gray-500);
+ }
+ }
+ }
+}
+
+.product-code {
+ padding: .5rem 0;
+ color: var(--text-muted);
+ font-size: 14px;
+ .product-item-group {
+ padding-right: .25rem;
+ border-right: solid 1px var(--text-muted);
+ }
+
+ .product-item-code {
+ padding-left: .5rem;
}
}
.item-configurator-dialog {
- .modal-header {
- padding: var(--padding-md) var(--padding-xl);
- }
-
.modal-body {
- padding: 0 var(--padding-xl);
padding-bottom: var(--padding-xl);
.status-area {
@@ -323,20 +558,73 @@ body.product-page {
}
}
-.cart-icon {
- .cart-badge {
- position: relative;
- top: -10px;
- left: -12px;
- background: var(--red-600);
- width: 16px;
- align-items: center;
- height: 16px;
- font-size: 10px;
- border-radius: 50%;
+.sub-category-container {
+ padding-bottom: .5rem;
+ margin-bottom: 1.25rem;
+ border-bottom: 1px solid var(--table-border-color);
+
+ .heading {
+ color: var(--gray-500);
}
}
+.scroll-categories {
+ white-space: nowrap;
+ overflow-x: auto;
+
+ .category-pill {
+ margin: 0px 4px;
+ display: inline-block;
+ padding: 6px 12px;
+ background-color: #ecf5fe;
+ width: fit-content;
+ font-size: 14px;
+ border-radius: 18px;
+ color: var(--blue-500);
+ }
+}
+
+
+.shopping-badge {
+ position: relative;
+ top: -10px;
+ left: -12px;
+ background: var(--red-600);
+ align-items: center;
+ height: 16px;
+ font-size: 10px;
+ border-radius: 50%;
+}
+
+
+.cart-animate {
+ animation: wiggle 0.5s linear;
+}
+@keyframes wiggle {
+ 8%,
+ 41% {
+ transform: translateX(-10px);
+ }
+ 25%,
+ 58% {
+ transform: translate(10px);
+ }
+ 75% {
+ transform: translate(-5px);
+ }
+ 92% {
+ transform: translate(5px);
+ }
+ 0%,
+ 100% {
+ transform: translate(0);
+ }
+}
+
+.total-discount {
+ font-size: 14px;
+ color: var(--primary-color) !important;
+}
#page-cart {
.shopping-cart-header {
@@ -350,6 +638,7 @@ body.product-page {
display: flex;
flex-direction: column;
justify-content: space-between;
+ height: fit-content;
}
.cart-items-header {
@@ -357,6 +646,10 @@ body.product-page {
}
.cart-table {
+ tr {
+ margin-bottom: 1rem;
+ }
+
th, tr, td {
border-color: var(--border-color);
border-width: 1px;
@@ -374,71 +667,200 @@ body.product-page {
color: var(--text-color);
}
+ .cart-item-image {
+ width: 20%;
+ min-width: 100px;
+ img {
+ max-height: 112px;
+ }
+ }
+
.cart-items {
.item-title {
- font-size: var(--text-base);
+ width: 80%;
+ font-size: 14px;
font-weight: 500;
color: var(--text-color);
}
.item-subtitle {
color: var(--text-muted);
- font-size: var(--text-md);
+ font-size: 13px;
}
.item-subtotal {
- font-size: var(--text-base);
+ font-size: 14px;
font-weight: 500;
}
+ .sm-item-subtotal {
+ font-size: 14px;
+ font-weight: 500;
+ display: none;
+
+ @media (max-width: 992px) {
+ display: unset !important;
+ }
+ }
+
.item-rate {
- font-size: var(--text-md);
+ font-size: 13px;
color: var(--text-muted);
}
- textarea {
- width: 40%;
+ .free-tag {
+ padding: 4px 8px;
+ border-radius: 4px;
+ background-color: var(--dark-green-50);
}
+
+ textarea {
+ width: 80%;
+ height: 60px;
+ font-size: 14px;
+ }
+
}
.cart-tax-items {
.item-grand-total {
font-size: 16px;
- font-weight: 600;
+ font-weight: 700;
color: var(--text-color);
}
}
+
+ .column-sm-view {
+ @media (max-width: 992px) {
+ display: none !important;
+ }
+ }
+
+ .item-column {
+ width: 50%;
+ @media (max-width: 992px) {
+ width: 70%;
+ }
+ }
+
+ .remove-cart-item {
+ border-radius: 6px;
+ border: 1px solid var(--gray-100);
+ width: 28px;
+ height: 28px;
+ font-weight: 300;
+ color: var(--gray-700);
+ background-color: var(--gray-100);
+ float: right;
+ cursor: pointer;
+ margin-top: .25rem;
+ justify-content: center;
+ }
+
+ .remove-cart-item-logo {
+ margin-top: 2px;
+ margin-left: 2.2px;
+ fill: var(--gray-700) !important;
+ }
}
- .cart-addresses {
+ .cart-payment-addresses {
hr {
border-color: var(--border-color);
}
}
+ .payment-summary {
+ h6 {
+ padding-bottom: 1rem;
+ border-bottom: solid 1px var(--gray-200);
+ }
+
+ table {
+ font-size: 14px;
+ td {
+ padding: 0;
+ padding-top: 0.35rem !important;
+ border: none !important;
+ }
+
+ &.grand-total {
+ border-top: solid 1px var(--gray-200);
+ }
+ }
+
+ .bill-label {
+ color: var(--gray-600);
+ }
+
+ .bill-content {
+ font-weight: 500;
+ &.net-total {
+ font-size: 16px;
+ font-weight: 600;
+ }
+ }
+
+ .btn-coupon-code {
+ font-size: 14px;
+ border: dashed 1px var(--gray-400);
+ box-shadow: none;
+ }
+ }
+
.number-spinner {
width: 75%;
+ min-width: 105px;
.cart-btn {
border: none;
background: var(--gray-100);
box-shadow: none;
+ width: 24px;
height: 28px;
align-items: center;
+ justify-content: center;
display: flex;
+ font-size: 20px;
+ font-weight: 300;
+ color: var(--gray-700);
}
.cart-qty {
height: 28px;
- font-size: var(--text-md);
+ font-size: 13px;
+ &:disabled {
+ background: var(--gray-100);
+ opacity: 0.65;
+ }
}
}
.place-order-container {
.btn-place-order {
- width: 62%;
+ float: right;
}
}
}
+
+ .t-and-c-container {
+ padding: 1.5rem;
+ }
+
+ .t-and-c-terms {
+ font-size: 14px;
+ }
+}
+
+.no-image-cart-item {
+ max-height: 112px;
+ display: flex; justify-content: center;
+ background-color: var(--gray-200);
+ align-items: center;
+ color: var(--gray-400);
+ margin-top: .15rem;
+ border-radius: 6px;
+ height: 100%;
+ font-size: 24px;
}
.cart-empty.frappe-card {
@@ -454,7 +876,7 @@ body.product-page {
.address-card {
.card-title {
- font-size: var(--text-base);
+ font-size: 14px;
font-weight: 500;
}
@@ -463,27 +885,37 @@ body.product-page {
}
.card-text {
- font-size: var(--text-md);
+ font-size: 13px;
color: var(--gray-700);
}
.card-link {
- font-size: var(--text-md);
+ font-size: 13px;
svg use {
- stroke: var(--blue-500);
+ stroke: var(--primary-color);
}
}
.btn-change-address {
- color: var(--blue-500);
+ border: 1px solid var(--primary-color);
+ color: var(--primary-color);
+ box-shadow: none;
}
}
+.address-header {
+ margin-top: .15rem;padding: 0;
+}
+
+.btn-new-address {
+ float: right;
+ font-size: 15px !important;
+ color: var(--primary-color) !important;
+}
+
.btn-new-address:hover, .btn-change-address:hover {
- box-shadow: none;
- color: var(--blue-500) !important;
- border: 1px solid var(--blue-500);
+ color: var(--primary-color) !important;
}
.modal .address-card {
@@ -493,3 +925,451 @@ body.product-page {
border: 1px solid var(--dark-border-color);
}
}
+
+.cart-indicator {
+ position: absolute;
+ text-align: center;
+ width: 22px;
+ height: 22px;
+ left: calc(100% - 40px);
+ top: 22px;
+
+ border-radius: 66px;
+ box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1);
+ background: white;
+ color: var(--primary-color);
+ font-size: 14px;
+
+ &.list-indicator {
+ position: unset;
+ margin-left: auto;
+ }
+}
+
+
+.like-action {
+ visibility: hidden;
+ text-align: center;
+ position: absolute;
+ cursor: pointer;
+ width: 28px;
+ height: 28px;
+ left: 20px;
+ top: 20px;
+
+ /* White */
+ background: white;
+ box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1);
+ border-radius: 66px;
+
+ &.like-action-wished {
+ visibility: visible !important;
+ }
+
+ @media (max-width: 992px) {
+ visibility: visible !important;
+ }
+}
+
+.like-action-list {
+ visibility: hidden;
+ text-align: center;
+ position: absolute;
+ cursor: pointer;
+ width: 28px;
+ height: 28px;
+ left: 20px;
+ top: 0;
+
+ /* White */
+ background: white;
+ box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1);
+ border-radius: 66px;
+
+ &.like-action-wished {
+ visibility: visible !important;
+ }
+
+ @media (max-width: 992px) {
+ visibility: visible !important;
+ }
+}
+
+.like-action-item-fp {
+ visibility: visible !important;
+ position: unset;
+ float: right;
+}
+
+.like-animate {
+ animation: expand cubic-bezier(0.04, 0.4, 0.5, 0.95) 1.6s forwards 1;
+}
+
+@keyframes expand {
+ 30% {
+ transform: scale(1.3);
+ }
+ 50% {
+ transform: scale(0.8);
+ }
+ 70% {
+ transform: scale(1.1);
+ }
+ 100% {
+ transform: scale(1);
+ }
+ }
+
+.not-wished {
+ cursor: pointer;
+ stroke: #F47A7A !important;
+
+ &:hover {
+ fill: #F47A7A;
+ }
+}
+
+.wished {
+ stroke: none;
+ fill: #F47A7A !important;
+}
+
+.list-row-checkbox {
+ &:before {
+ display: none;
+ }
+
+ &:checked:before {
+ display: block;
+ z-index: 1;
+ }
+}
+
+#pay-for-order {
+ padding: .5rem 1rem; // Pay button in SO
+}
+
+.btn-explore-variants {
+ visibility: hidden;
+ box-shadow: none;
+ margin: var(--margin-sm) 0;
+ width: 90px;
+ max-height: 50px; // to avoid resizing on window resize
+ flex: none;
+ transition: 0.3s ease;
+
+ color: white;
+ background-color: var(--orange-500);
+ border: 1px solid var(--orange-500);
+ font-size: 13px;
+
+ &:hover {
+ color: white;
+ }
+}
+
+.btn-add-to-cart-list{
+ visibility: hidden;
+ box-shadow: none;
+ margin: var(--margin-sm) 0;
+ // margin-top: auto !important;
+ max-height: 50px; // to avoid resizing on window resize
+ flex: none;
+ transition: 0.3s ease;
+
+ font-size: 13px;
+
+ &:hover {
+ color: white;
+ }
+
+ @media (max-width: 992px) {
+ visibility: visible !important;
+ }
+}
+
+.go-to-cart-grid {
+ max-height: 30px;
+ margin-top: 1rem !important;
+}
+
+.go-to-cart {
+ max-height: 30px;
+ float: right;
+}
+
+.remove-wish {
+ background-color: white;
+ position: absolute;
+ cursor: pointer;
+ top:10px;
+ right: 20px;
+ width: 32px;
+ height: 32px;
+
+ border-radius: 50%;
+ border: 1px solid var(--gray-100);
+ box-shadow: 0px 2px 6px rgba(17, 43, 66, 0.08), 0px 1px 4px rgba(17, 43, 66, 0.1);
+}
+
+.wish-removed {
+ display: none;
+}
+
+.item-website-specification {
+ font-size: .875rem;
+ .product-title {
+ font-size: 18px;
+ }
+
+ .table {
+ width: 70%;
+ }
+
+ td {
+ border: none !important;
+ }
+
+ .spec-label {
+ color: var(--gray-600);
+ }
+
+ .spec-content {
+ color: var(--gray-800);
+ }
+}
+
+.reviews-full-page {
+ padding: 1rem 2rem;
+}
+
+.ratings-reviews-section {
+ border-top: 1px solid #E2E6E9;
+ padding: .5rem 1rem;
+}
+
+.reviews-header {
+ font-size: 20px;
+ font-weight: 600;
+ color: var(--gray-800);
+ display: flex;
+ align-items: center;
+ padding: 0;
+}
+
+.btn-write-review {
+ float: right;
+ padding: .5rem 1rem;
+ font-size: 14px;
+ font-weight: 400;
+ border: none !important;
+ box-shadow: none;
+
+ color: var(--gray-900);
+ background-color: var(--gray-100);
+
+ &:hover {
+ box-shadow: var(--btn-shadow);
+ }
+}
+
+.btn-view-more {
+ font-size: 14px;
+}
+
+.rating-summary-section {
+ display: flex;
+}
+
+.rating-summary-title {
+ margin-top: 0.15rem;
+ font-size: 18px;
+}
+
+.rating-summary-numbers {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ border-right: solid 1px var(--gray-100);
+}
+
+.user-review-title {
+ margin-top: 0.15rem;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.rating {
+ --star-fill: var(--gray-300);
+ .star-hover {
+ --star-fill: var(--yellow-100);
+ }
+ .star-click {
+ --star-fill: var(--yellow-300);
+ }
+}
+
+.ratings-pill {
+ background-color: var(--gray-100);
+ padding: .5rem 1rem;
+ border-radius: 66px;
+}
+
+.review {
+ max-width: 80%;
+ line-height: 1.6;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid #E2E6E9;
+}
+
+.review-signature {
+ display: flex;
+ font-size: 13px;
+ color: var(--gray-500);
+ font-weight: 400;
+
+ .reviewer {
+ padding-right: 8px;
+ color: var(--gray-600);
+ }
+}
+
+.rating-progress-bar-section {
+ padding-bottom: 2rem;
+
+ .rating-bar-title {
+ margin-left: -15px;
+ }
+
+ .rating-progress-bar {
+ margin-bottom: 4px;
+ height: 7px;
+ margin-top: 6px;
+
+ .progress-bar-cosmetic {
+ background-color: var(--gray-600);
+ border-radius: var(--border-radius);
+ }
+ }
+}
+
+.offer-container {
+ font-size: 14px;
+}
+
+#search-results-container {
+ border: 1px solid var(--gray-200);
+ padding: .25rem 1rem;
+
+ .category-chip {
+ background-color: var(--gray-100);
+ border: none !important;
+ box-shadow: none;
+ }
+
+ .recent-search {
+ padding: .5rem .5rem;
+ border-radius: var(--border-radius);
+
+ &:hover {
+ background-color: var(--gray-100);
+ }
+ }
+}
+
+#search-box {
+ background-color: white;
+ height: 100%;
+ padding-left: 2.5rem;
+ border: 1px solid var(--gray-200);
+}
+
+.search-icon {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 2.5rem;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding-bottom: 1px;
+}
+
+#toggle-view {
+ float: right;
+
+ .btn-primary {
+ background-color: var(--gray-600);
+ box-shadow: 0 0 0 0.2rem var(--gray-400);
+ }
+}
+
+.placeholder-div {
+ height:80%;
+ width: -webkit-fill-available;
+ padding: 50px;
+ text-align: center;
+ background-color: #F9FAFA;
+ border-top-left-radius: calc(0.75rem - 1px);
+ border-top-right-radius: calc(0.75rem - 1px);
+}
+.placeholder {
+ font-size: 72px;
+}
+
+[data-path="cart"] {
+ .modal-backdrop {
+ background-color: var(--gray-50); // lighter backdrop only on cart freeze
+ }
+}
+
+.item-thumb {
+ height: 50px;
+ max-width: 80px;
+ min-width: 80px;
+ object-fit: cover;
+}
+
+.brand-line {
+ color: gray;
+}
+
+.btn-next, .btn-prev {
+ font-size: 14px;
+}
+
+.alert-error {
+ color: #e27a84;
+ background-color: #fff6f7;
+ border-color: #f5c6cb;
+}
+
+.font-md {
+ font-size: 14px !important;
+}
+
+.in-green {
+ color: var(--green-info) !important;
+ font-weight: 500;
+}
+
+.has-stock {
+ font-weight: 400 !important;
+}
+
+.out-of-stock {
+ font-weight: 400;
+ font-size: 14px;
+ line-height: 20px;
+ color: #F47A7A;
+}
+
+.mt-minus-2 {
+ margin-top: -2rem;
+}
+
+.mt-minus-1 {
+ margin-top: -1rem;
+}
\ No newline at end of file
diff --git a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
index d48cd67c38..cb79cf8286 100644
--- a/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
+++ b/erpnext/regional/doctype/gstr_3b_report/gstr_3b_report.py
@@ -295,6 +295,10 @@ class GSTR3BReport(Document):
inter_state_supply_details = {}
for inv, items_based_on_rate in self.items_based_on_tax_rate.items():
+ gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
+ place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
+ export_type = self.invoice_detail_map.get(inv, {}).get('export_type')
+
for rate, items in items_based_on_rate.items():
for item_code, taxable_value in self.invoice_items.get(inv).items():
if item_code in items:
@@ -302,9 +306,8 @@ class GSTR3BReport(Document):
self.report_dict['sup_details']['osup_nil_exmp']['txval'] += taxable_value
elif item_code in self.is_non_gst:
self.report_dict['sup_details']['osup_nongst']['txval'] += taxable_value
- elif rate == 0:
+ elif rate == 0 or (gst_category == 'Overseas' and export_type == 'Without Payment of Tax'):
self.report_dict['sup_details']['osup_zero']['txval'] += taxable_value
- #self.report_dict['sup_details']['osup_zero'][key] += tax_amount
else:
if inv in self.cgst_sgst_invoices:
tax_rate = rate/2
@@ -315,9 +318,6 @@ class GSTR3BReport(Document):
self.report_dict['sup_details']['osup_det']['iamt'] += (taxable_value * rate /100)
self.report_dict['sup_details']['osup_det']['txval'] += taxable_value
- gst_category = self.invoice_detail_map.get(inv, {}).get('gst_category')
- place_of_supply = self.invoice_detail_map.get(inv, {}).get('place_of_supply') or '00-Other Territory'
-
if gst_category in ['Unregistered', 'Registered Composition', 'UIN Holders'] and \
self.gst_details.get("gst_state") != place_of_supply.split("-")[1]:
inter_state_supply_details.setdefault((gst_category, place_of_supply), {
diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py
index d74d5a6df9..7742f26ad1 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -258,30 +258,6 @@ class Customer(TransactionBase):
.format(frappe.bold(self.customer_name))
)
- def create_onboarding_docs(self, args):
- defaults = frappe.defaults.get_defaults()
- company = defaults.get('company') or \
- frappe.db.get_single_value('Global Defaults', 'default_company')
-
- for i in range(1, args.get('max_count')):
- customer = args.get('customer_name_' + str(i))
- if customer:
- try:
- doc = frappe.get_doc({
- 'doctype': self.doctype,
- 'customer_name': customer,
- 'customer_type': 'Company',
- 'customer_group': _('Commercial'),
- 'territory': defaults.get('country'),
- 'company': company
- }).insert()
-
- if args.get('customer_email_' + str(i)):
- create_contact(customer, self.doctype,
- doc.name, args.get("customer_email_" + str(i)))
- except frappe.NameError:
- pass
-
def create_contact(contact, party_type, party, email):
"""Create contact based on given contact name"""
contact = contact.split(' ')
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index daab6fbb8f..eebde766d3 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -287,7 +287,7 @@ def _make_customer(source_name, ignore_permissions=False):
customer = frappe.get_doc(customer_doclist)
customer.flags.ignore_permissions = ignore_permissions
if quotation.get("party_name") == "Shopping Cart":
- customer.customer_group = frappe.db.get_value("Shopping Cart Settings", None,
+ customer.customer_group = frappe.db.get_value("E Commerce Settings", None,
"default_customer_group")
try:
diff --git a/erpnext/selling/doctype/quotation/quotation_list.js b/erpnext/selling/doctype/quotation/quotation_list.js
index b631685bd1..4c8f9c4f84 100644
--- a/erpnext/selling/doctype/quotation/quotation_list.js
+++ b/erpnext/selling/doctype/quotation/quotation_list.js
@@ -12,6 +12,14 @@ frappe.listview_settings['Quotation'] = {
};
};
}
+
+ listview.page.add_action_item(__("Sales Order"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Order");
+ });
+
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Quotation", "Sales Invoice");
+ });
},
get_indicator: function(doc) {
diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json
index 8b53902d32..31a95896bc 100644
--- a/erpnext/selling/doctype/quotation_item/quotation_item.json
+++ b/erpnext/selling/doctype/quotation_item/quotation_item.json
@@ -649,7 +649,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-02-23 01:13:54.670763",
+ "modified": "2021-07-15 12:40:51.074820",
"modified_by": "Administrator",
"module": "Selling",
"name": "Quotation Item",
diff --git a/erpnext/selling/doctype/sales_order/sales_order_list.js b/erpnext/selling/doctype/sales_order/sales_order_list.js
index 26d96d59f2..4691190d2a 100644
--- a/erpnext/selling/doctype/sales_order/sales_order_list.js
+++ b/erpnext/selling/doctype/sales_order/sales_order_list.js
@@ -16,7 +16,7 @@ frappe.listview_settings['Sales Order'] = {
return [__("Overdue"), "red",
"per_delivered,<,100|delivery_date,<,Today|status,!=,Closed"];
} else if (flt(doc.grand_total) === 0) {
- // not delivered (zero-amount order)
+ // not delivered (zeroount order)
return [__("To Deliver"), "orange",
"per_delivered,<,100|grand_total,=,0|status,!=,Closed"];
} else if (flt(doc.per_billed, 6) < 100) {
@@ -48,5 +48,17 @@ frappe.listview_settings['Sales Order'] = {
listview.call_for_selected_items(method, {"status": "Submitted"});
});
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Sales Invoice");
+ });
+
+ listview.page.add_action_item(__("Delivery Note"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Delivery Note");
+ });
+
+ listview.page.add_action_item(__("Advance Payment"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Sales Order", "Advance Payment");
+ });
+
}
};
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 42bc0b70f8..acf048e116 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -1375,6 +1375,30 @@ class TestSalesOrder(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
+ def test_zero_amount_sales_order_billing_status(self):
+ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+
+ so = make_sales_order(uom="Nos", do_not_save=1)
+ so.items[0].rate = 0
+ so.save()
+ so.submit()
+
+ self.assertEqual(so.net_total, 0)
+ self.assertEqual(so.billing_status, 'Not Billed')
+
+ si = create_sales_invoice(qty=10, do_not_save=1)
+ si.price_list = '_Test Price List'
+ si.items[0].rate = 0
+ si.items[0].price_list_rate = 0
+ si.items[0].sales_order = so.name
+ si.items[0].so_detail = so.items[0].name
+ si.save()
+ si.submit()
+
+ self.assertEqual(si.net_total, 0)
+ so.load_from_db()
+ self.assertEqual(so.billing_status, 'Fully Billed')
+
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable
diff --git a/erpnext/selling/doctype/selling_settings/selling_settings.json b/erpnext/selling/doctype/selling_settings/selling_settings.json
index 27bc541d62..7c4a3f63dc 100644
--- a/erpnext/selling/doctype/selling_settings/selling_settings.json
+++ b/erpnext/selling/doctype/selling_settings/selling_settings.json
@@ -80,7 +80,7 @@
"description": "How often should Project and Company be updated based on Sales Transactions?",
"fieldname": "sales_update_frequency",
"fieldtype": "Select",
- "label": "Sales Update Frequency",
+ "label": "Sales Update Frequency in Company and Project",
"options": "Each Transaction\nDaily\nMonthly",
"reqd": 1
},
@@ -171,7 +171,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-09-13 12:32:17.004404",
+ "modified": "2022-02-04 15:41:59.939261",
"modified_by": "Administrator",
"module": "Selling",
"name": "Selling Settings",
@@ -189,5 +189,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json b/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json
deleted file mode 100644
index 92d00bcb38..0000000000
--- a/erpnext/selling/onboarding_slide/add_a_few_customers/add_a_few_customers.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "add_more_button": 1,
- "app": "ERPNext",
- "creation": "2019-11-15 14:44:10.065014",
- "docstatus": 0,
- "doctype": "Onboarding Slide",
- "domains": [],
- "help_links": [
- {
- "label": "Learn More",
- "video_id": "zsrrVDk6VBs"
- }
- ],
- "idx": 0,
- "image_src": "",
- "is_completed": 0,
- "max_count": 3,
- "modified": "2019-12-09 17:54:01.686006",
- "modified_by": "Administrator",
- "name": "Add A Few Customers",
- "owner": "Administrator",
- "ref_doctype": "Customer",
- "slide_desc": "",
- "slide_fields": [
- {
- "align": "",
- "fieldname": "customer_name",
- "fieldtype": "Data",
- "label": "Customer Name",
- "placeholder": "",
- "reqd": 1
- },
- {
- "align": "",
- "fieldtype": "Column Break",
- "reqd": 0
- },
- {
- "align": "",
- "fieldname": "customer_email",
- "fieldtype": "Data",
- "label": "Email ID",
- "reqd": 1
- }
- ],
- "slide_order": 40,
- "slide_title": "Add A Few Customers",
- "slide_type": "Create"
-}
\ No newline at end of file
diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py
index db5b20e3e1..993c61d563 100644
--- a/erpnext/selling/page/point_of_sale/point_of_sale.py
+++ b/erpnext/selling/page/point_of_sale/point_of_sale.py
@@ -24,7 +24,7 @@ def search_by_term(search_term, warehouse, price_list):
["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"],
as_dict=1)
- item_stock_qty = get_stock_availability(item_code, warehouse)
+ item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
price_list_rate, currency = frappe.db.get_value('Item Price', {
'price_list': price_list,
'item_code': item_code
@@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
), {'warehouse': warehouse}, as_dict=1)
if items_data:
- items_data = filter_service_items(items_data)
items = [d.item_code for d in items_data]
item_prices_data = frappe.get_all("Item Price",
fields = ["item_code", "price_list_rate", "currency"],
@@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
for item in items_data:
item_code = item.item_code
item_price = item_prices.get(item_code) or {}
- item_stock_qty = get_stock_availability(item_code, warehouse)
+ item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse)
row = {}
row.update(item)
@@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value):
return {}
-def filter_service_items(items):
- for item in items:
- if not item['is_stock_item']:
- if not frappe.db.exists('Product Bundle', item['item_code']):
- items.remove(item)
-
- return items
-
def get_conditions(search_term):
condition = "("
condition += """item.name like {search_term}
diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js
index ce74f6d0a5..ea8459f970 100644
--- a/erpnext/selling/page/point_of_sale/pos_controller.js
+++ b/erpnext/selling/page/point_of_sale/pos_controller.js
@@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class {
numpad_event: (value, action) => this.update_item_field(value, action),
- checkout: () => this.payment.checkout(),
+ checkout: () => this.save_and_checkout(),
edit_cart: () => this.payment.edit_cart(),
@@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class {
}
async check_stock_availability(item_row, qty_needed, warehouse) {
- const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message;
+ const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message;
+ const available_qty = resp[0];
+ const is_stock_item = resp[1];
frappe.dom.unfreeze();
const bold_item_code = item_row.item_code.bold();
const bold_warehouse = warehouse.bold();
const bold_available_qty = available_qty.toString().bold()
if (!(available_qty > 0)) {
- frappe.model.clear_doc(item_row.doctype, item_row.name);
- frappe.throw({
- title: __("Not Available"),
- message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
- })
+ if (is_stock_item) {
+ frappe.model.clear_doc(item_row.doctype, item_row.name);
+ frappe.throw({
+ title: __("Not Available"),
+ message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse])
+ });
+ } else {
+ return;
+ }
} else if (available_qty < qty_needed) {
frappe.throw({
message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]),
@@ -675,8 +681,8 @@ erpnext.PointOfSale.Controller = class {
},
callback(res) {
if (!me.item_stock_map[item_code])
- me.item_stock_map[item_code] = {}
- me.item_stock_map[item_code][warehouse] = res.message;
+ me.item_stock_map[item_code] = {};
+ me.item_stock_map[item_code][warehouse] = res.message[0];
}
});
}
@@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class {
})
.catch(e => console.log(e));
}
+
+ async save_and_checkout() {
+ this.frm.is_dirty() && await this.frm.save();
+ this.payment.checkout();
+ }
};
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 4920584d95..4a99f068cd 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_cart.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js
@@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class {
this.numpad_value = '';
});
- this.$component.on('click', '.checkout-btn', function() {
+ this.$component.on('click', '.checkout-btn', async function() {
if ($(this).attr('style').indexOf('--blue-500') == -1) return;
- me.events.checkout();
+ await me.events.checkout();
me.toggle_checkout_btn(false);
me.allow_discount_change && me.$add_discount_elem.removeClass("d-none");
@@ -985,6 +985,7 @@ erpnext.PointOfSale.ItemCart = class {
$(frm.wrapper).off('refresh-fields');
$(frm.wrapper).on('refresh-fields', () => {
if (frm.doc.items.length) {
+ this.$cart_items_wrapper.html('');
frm.doc.items.forEach(item => {
this.update_item_html(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 a30bcd7cf6..1177615aee 100644
--- a/erpnext/selling/page/point_of_sale/pos_item_selector.js
+++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js
@@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class {
const me = this;
// eslint-disable-next-line no-unused-vars
const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item;
- const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange";
const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0;
-
+ let indicator_color;
let qty_to_display = actual_qty;
- if (Math.round(qty_to_display) > 999) {
- qty_to_display = Math.round(qty_to_display)/1000;
- qty_to_display = qty_to_display.toFixed(1) + 'K';
+ if (item.is_stock_item) {
+ indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange");
+
+ if (Math.round(qty_to_display) > 999) {
+ qty_to_display = Math.round(qty_to_display)/1000;
+ qty_to_display = qty_to_display.toFixed(1) + 'K';
+ }
+ } else {
+ indicator_color = '';
+ qty_to_display = '';
}
function get_item_image_html() {
diff --git a/erpnext/setup/doctype/brand/brand.json b/erpnext/setup/doctype/brand/brand.json
index a8f0674b1f..45b4db81f1 100644
--- a/erpnext/setup/doctype/brand/brand.json
+++ b/erpnext/setup/doctype/brand/brand.json
@@ -1,270 +1,111 @@
{
- "allow_copy": 0,
- "allow_events_in_timeline": 0,
- "allow_guest_to_view": 0,
- "allow_import": 1,
- "allow_rename": 1,
- "autoname": "field:brand",
- "beta": 0,
- "creation": "2013-02-22 01:27:54",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "Setup",
- "editable_grid": 0,
+ "actions": [],
+ "allow_import": 1,
+ "allow_rename": 1,
+ "autoname": "field:brand",
+ "creation": "2013-02-22 01:27:54",
+ "doctype": "DocType",
+ "document_type": "Setup",
+ "engine": "InnoDB",
+ "field_order": [
+ "brand",
+ "image",
+ "description",
+ "defaults",
+ "brand_defaults"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 1,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "brand",
- "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": "Brand Name",
- "length": 0,
- "no_copy": 0,
- "oldfieldname": "brand",
- "oldfieldtype": "Data",
- "permlevel": 0,
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "translatable": 0,
+ "allow_in_quick_entry": 1,
+ "fieldname": "brand",
+ "fieldtype": "Data",
+ "label": "Brand Name",
+ "oldfieldname": "brand",
+ "oldfieldtype": "Data",
+ "reqd": 1,
"unique": 1
- },
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "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_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Description",
- "length": 0,
- "no_copy": 0,
- "oldfieldname": "description",
- "oldfieldtype": "Text",
- "permlevel": 0,
- "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": "description",
+ "fieldtype": "Text",
+ "in_list_view": 1,
+ "label": "Description",
+ "oldfieldname": "description",
+ "oldfieldtype": "Text",
"width": "300px"
- },
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "defaults",
- "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": "Defaults",
- "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": "defaults",
+ "fieldtype": "Section Break",
+ "label": "Defaults"
+ },
{
- "allow_bulk_edit": 0,
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "brand_defaults",
- "fieldtype": "Table",
- "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": "Brand Defaults",
- "length": 0,
- "no_copy": 0,
- "options": "Item Default",
- "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": "brand_defaults",
+ "fieldtype": "Table",
+ "label": "Brand Defaults",
+ "options": "Item Default"
+ },
+ {
+ "fieldname": "image",
+ "fieldtype": "Attach Image",
+ "hidden": 1,
+ "label": "Image"
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "icon": "fa fa-certificate",
- "idx": 1,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2018-10-23 23:18:06.067612",
- "modified_by": "Administrator",
- "module": "Setup",
- "name": "Brand",
- "owner": "Administrator",
+ ],
+ "icon": "fa fa-certificate",
+ "idx": 1,
+ "image_field": "image",
+ "links": [],
+ "modified": "2021-03-01 15:57:30.005783",
+ "modified_by": "Administrator",
+ "module": "Setup",
+ "name": "Brand",
+ "owner": "Administrator",
"permissions": [
{
- "amend": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 1,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Item Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "import": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Item Manager",
+ "share": 1,
"write": 1
- },
+ },
{
- "amend": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Stock User",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
- },
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock User"
+ },
{
- "amend": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Sales User",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
- },
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Sales User"
+ },
{
- "amend": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Purchase User",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
- },
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Purchase User"
+ },
{
- "amend": 0,
- "cancel": 0,
- "create": 0,
- "delete": 0,
- "email": 1,
- "export": 0,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "Accounts User",
- "set_user_permissions": 0,
- "share": 0,
- "submit": 0,
- "write": 0
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts User"
}
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 1,
- "sort_order": "ASC",
- "track_changes": 0,
- "track_seen": 0,
- "track_views": 0
-}
+ ],
+ "quick_entry": 1,
+ "show_name_in_global_search": 1,
+ "sort_field": "modified",
+ "sort_order": "ASC"
+}
\ No newline at end of file
diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py
index 9f1eb753d9..4f92240c84 100644
--- a/erpnext/setup/doctype/item_group/item_group.py
+++ b/erpnext/setup/doctype/item_group/item_group.py
@@ -1,21 +1,17 @@
-# 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
-
import copy
from urllib.parse import quote
import frappe
from frappe import _
-from frappe.utils import cint, cstr, nowdate
+from frappe.utils import cint
from frappe.utils.nestedset import NestedSet
from frappe.website.utils import clear_cache
from frappe.website.website_generator import WebsiteGenerator
-from erpnext.shopping_cart.filters import ProductFiltersBuilder
-from erpnext.shopping_cart.product_info import set_product_info_for_website
-from erpnext.shopping_cart.product_query import ProductQuery
-from erpnext.utilities.product import get_qty_in_stock
+from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder
class ItemGroup(NestedSet, WebsiteGenerator):
@@ -67,30 +63,11 @@ class ItemGroup(NestedSet, WebsiteGenerator):
self.delete_child_item_groups_key()
def get_context(self, context):
- context.show_search=True
- context.page_length = cint(frappe.db.get_single_value('Products Settings', 'products_per_page')) or 6
+ context.show_search = True
+ context.body_class = "product-page"
+ context.page_length = cint(frappe.db.get_single_value('E Commerce Settings', 'products_per_page')) or 6
context.search_link = '/product_search'
- if frappe.form_dict:
- search = frappe.form_dict.search
- field_filters = frappe.parse_json(frappe.form_dict.field_filters)
- attribute_filters = frappe.parse_json(frappe.form_dict.attribute_filters)
- start = frappe.parse_json(frappe.form_dict.start)
- else:
- search = None
- attribute_filters = None
- field_filters = {}
- start = 0
-
- if not field_filters:
- field_filters = {}
-
- # Ensure the query remains within current item group & sub group
- field_filters['item_group'] = [ig[0] for ig in get_child_groups(self.name)]
-
- engine = ProductQuery()
- context.items = engine.query(attribute_filters, field_filters, search, start, item_group=self.name)
-
filter_engine = ProductFiltersBuilder(self.name)
context.field_filters = filter_engine.get_field_filters()
@@ -114,15 +91,16 @@ class ItemGroup(NestedSet, WebsiteGenerator):
values[f"slide_{index + 1}_image"] = slide.image
values[f"slide_{index + 1}_title"] = slide.heading
values[f"slide_{index + 1}_subtitle"] = slide.description
- values[f"slide_{index + 1}_theme"] = slide.theme or "Light"
- values[f"slide_{index + 1}_content_align"] = slide.content_align or "Centre"
- values[f"slide_{index + 1}_primary_action_label"] = slide.label
+ values[f"slide_{index + 1}_theme"] = slide.get("theme") or "Light"
+ values[f"slide_{index + 1}_content_align"] = slide.get("content_align") or "Centre"
values[f"slide_{index + 1}_primary_action"] = slide.url
context.slideshow = values
- context.breadcrumbs = 0
+ context.no_breadcrumbs = False
context.title = self.website_title or self.name
+ context.name = self.name
+ context.item_group_name = self.item_group_name
return context
@@ -133,91 +111,24 @@ class ItemGroup(NestedSet, WebsiteGenerator):
from erpnext.stock.doctype.item.item import validate_item_default_company_links
validate_item_default_company_links(self.item_group_defaults)
-@frappe.whitelist(allow_guest=True)
-def get_product_list_for_group(product_group=None, start=0, limit=10, search=None):
- if product_group:
- item_group = frappe.get_cached_doc('Item Group', product_group)
- if item_group.is_group:
- # return child item groups if the type is of "Is Group"
- return get_child_groups_for_list_in_html(item_group, start, limit, search)
+def get_child_groups_for_website(item_group_name, immediate=False):
+ """Returns child item groups *excluding* passed group."""
+ item_group = frappe.get_cached_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1)
+ filters = {
+ "lft": [">", item_group.lft],
+ "rgt": ["<", item_group.rgt],
+ "show_in_website": 1
+ }
- child_groups = ", ".join(frappe.db.escape(i[0]) for i in get_child_groups(product_group))
+ if immediate:
+ filters["parent_item_group"] = item_group_name
- # base query
- query = """select I.name, I.item_name, I.item_code, I.route, I.image, I.website_image, I.thumbnail, I.item_group,
- I.description, I.web_long_description as website_description, I.is_stock_item,
- case when (S.actual_qty - S.reserved_qty) > 0 then 1 else 0 end as in_stock, I.website_warehouse,
- I.has_batch_no
- from `tabItem` I
- left join tabBin S on I.item_code = S.item_code and I.website_warehouse = S.warehouse
- where I.show_in_website = 1
- and I.disabled = 0
- and (I.end_of_life is null or I.end_of_life='0000-00-00' or I.end_of_life > %(today)s)
- and (I.variant_of = '' or I.variant_of is null)
- and (I.item_group in ({child_groups})
- or I.name in (select parent from `tabWebsite Item Group` where item_group in ({child_groups})))
- """.format(child_groups=child_groups)
- # search term condition
- if search:
- query += """ and (I.web_long_description like %(search)s
- or I.item_name like %(search)s
- or I.name like %(search)s)"""
- search = "%" + cstr(search) + "%"
-
- query += """order by I.weightage desc, in_stock desc, I.modified desc limit %s, %s""" % (cint(start), cint(limit))
-
- data = frappe.db.sql(query, {"product_group": product_group,"search": search, "today": nowdate()}, as_dict=1)
- data = adjust_qty_for_expired_items(data)
-
- if cint(frappe.db.get_single_value("Shopping Cart Settings", "enabled")):
- for item in data:
- set_product_info_for_website(item)
-
- return data
-
-def get_child_groups_for_list_in_html(item_group, start, limit, search):
- search_filters = None
- if search_filters:
- search_filters = [
- dict(name = ('like', '%{}%'.format(search))),
- dict(description = ('like', '%{}%'.format(search)))
- ]
- data = frappe.db.get_all('Item Group',
- fields = ['name', 'route', 'description', 'image'],
- filters = dict(
- show_in_website = 1,
- parent_item_group = item_group.name,
- lft = ('>', item_group.lft),
- rgt = ('<', item_group.rgt),
- ),
- or_filters = search_filters,
- order_by = 'weightage desc, name asc',
- start = start,
- limit = limit
+ return frappe.get_all(
+ "Item Group",
+ filters=filters,
+ fields=["name", "route"]
)
- return data
-
-def adjust_qty_for_expired_items(data):
- adjusted_data = []
-
- for item in data:
- if item.get('has_batch_no') and item.get('website_warehouse'):
- stock_qty_dict = get_qty_in_stock(
- item.get('name'), 'website_warehouse', item.get('website_warehouse'))
- qty = stock_qty_dict.stock_qty[0][0] if stock_qty_dict.stock_qty else 0
- item['in_stock'] = 1 if qty else 0
- adjusted_data.append(item)
-
- return adjusted_data
-
-
-def get_child_groups(item_group_name):
- item_group = frappe.get_doc("Item Group", item_group_name)
- return frappe.db.sql("""select name
- from `tabItem Group` where lft>=%(lft)s and rgt<=%(rgt)s
- and show_in_website = 1""", {"lft": item_group.lft, "rgt": item_group.rgt})
-
def get_child_item_groups(item_group_name):
item_group = frappe.get_cached_value("Item Group",
item_group_name, ["lft", "rgt"], as_dict=1)
@@ -233,31 +144,33 @@ def get_item_for_list_in_html(context):
if (context.get("website_image") or "").startswith("files/"):
context["website_image"] = "/" + quote(context["website_image"])
- context["show_availability_status"] = cint(frappe.db.get_single_value('Products Settings',
+ context["show_availability_status"] = cint(frappe.db.get_single_value('E Commerce Settings',
'show_availability_status'))
products_template = 'templates/includes/products_as_list.html'
return frappe.get_template(products_template).render(context)
-def get_group_item_count(item_group):
- child_groups = ", ".join('"' + i[0] + '"' for i in get_child_groups(item_group))
- return frappe.db.sql("""select count(*) from `tabItem`
- where docstatus = 0 and show_in_website = 1
- and (item_group in (%s)
- or name in (select parent from `tabWebsite Item Group`
- where item_group in (%s))) """ % (child_groups, child_groups))[0][0]
+def get_parent_item_groups(item_group_name, from_item=False):
+ base_nav_page = {"name": _("Shop by Category"), "route":"/shop-by-category"}
+
+ if from_item and frappe.request.environ.get("HTTP_REFERER"):
+ # base page after 'Home' will vary on Item page
+ last_page = frappe.request.environ["HTTP_REFERER"].split('/')[-1]
+ if last_page and last_page in ("shop-by-category", "all-products"):
+ base_nav_page_title = " ".join(last_page.split("-")).title()
+ base_nav_page = {"name": _(base_nav_page_title), "route":"/"+last_page}
-def get_parent_item_groups(item_group_name):
base_parents = [
- {"name": frappe._("Home"), "route":"/"},
- {"name": frappe._("All Products"), "route":"/all-products"},
+ {"name": _("Home"), "route":"/"},
+ base_nav_page,
]
+
if not item_group_name:
return base_parents
- item_group = frappe.get_doc("Item Group", item_group_name)
+ item_group = frappe.db.get_value("Item Group", item_group_name, ["lft", "rgt"], as_dict=1)
parent_groups = frappe.db.sql("""select name, route from `tabItem Group`
where lft <= %s and rgt >= %s
and show_in_website=1
diff --git a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json
deleted file mode 100644
index f00dc947d2..0000000000
--- a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext!/welcome_back_to_erpnext!.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "add_more_button": 0,
- "app": "ERPNext",
- "creation": "2019-12-04 19:21:39.995776",
- "docstatus": 0,
- "doctype": "Onboarding Slide",
- "domains": [],
- "help_links": [],
- "idx": 0,
- "image_src": "",
- "is_completed": 0,
- "max_count": 3,
- "modified": "2019-12-09 17:53:53.849953",
- "modified_by": "Administrator",
- "name": "Welcome back to ERPNext!",
- "owner": "Administrator",
- "slide_desc": "
Let's continue where you left from!
",
- "slide_fields": [],
- "slide_module": "Setup",
- "slide_order": 0,
- "slide_title": "Welcome back to ERPNext!",
- "slide_type": "Continue"
-}
\ No newline at end of file
diff --git a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json b/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json
deleted file mode 100644
index 37eb67b1d7..0000000000
--- a/erpnext/setup/onboarding_slide/welcome_to_erpnext!/welcome_to_erpnext!.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "add_more_button": 0,
- "app": "ERPNext",
- "creation": "2019-11-26 17:01:26.671859",
- "docstatus": 0,
- "doctype": "Onboarding Slide",
- "domains": [],
- "help_links": [],
- "idx": 0,
- "image_src": "",
- "is_completed": 0,
- "max_count": 0,
- "modified": "2019-12-22 21:26:28.414597",
- "modified_by": "Administrator",
- "name": "Welcome to ERPNext!",
- "owner": "Administrator",
- "slide_desc": "
Setting up an ERP can be overwhelming. But don't worry, we have got your back! This wizard will help you onboard to ERPNext in a short time!
",
- "slide_fields": [],
- "slide_module": "Setup",
- "slide_order": 1,
- "slide_title": "Welcome to ERPNext!",
- "slide_type": "Information"
-}
\ No newline at end of file
diff --git a/erpnext/setup/setup_wizard/operations/company_setup.py b/erpnext/setup/setup_wizard/operations/company_setup.py
index 358b921831..74c1bd835d 100644
--- a/erpnext/setup/setup_wizard/operations/company_setup.py
+++ b/erpnext/setup/setup_wizard/operations/company_setup.py
@@ -29,10 +29,10 @@ def create_fiscal_year_and_company(args):
'domain': args.get('domains')[0]
}).insert()
-def enable_shopping_cart(args):
+def enable_shopping_cart(args): # nosemgrep
# Needs price_lists
frappe.get_doc({
- "doctype": "Shopping Cart Settings",
+ "doctype": "E Commerce Settings",
"enabled": 1,
'company': args.get('company_name') ,
'price_list': frappe.db.get_value("Price List", {"selling": 1}),
diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py
index 9dbf49eae7..cd2738aeaa 100644
--- a/erpnext/setup/setup_wizard/operations/install_fixtures.py
+++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py
@@ -535,8 +535,8 @@ def create_bank_account(args):
# bank account same as a CoA entry
pass
-def update_shopping_cart_settings(args):
- shopping_cart = frappe.get_doc("Shopping Cart Settings")
+def update_shopping_cart_settings(args): # nosemgrep
+ shopping_cart = frappe.get_doc("E Commerce Settings")
shopping_cart.update({
"enabled": 1,
'company': args.company_name,
diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json
deleted file mode 100644
index 7a4bb20136..0000000000
--- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.json
+++ /dev/null
@@ -1,212 +0,0 @@
-{
- "actions": [],
- "creation": "2013-06-19 15:57:32",
- "description": "Default settings for Shopping Cart",
- "doctype": "DocType",
- "document_type": "System",
- "engine": "InnoDB",
- "field_order": [
- "enabled",
- "store_page_docs",
- "display_settings",
- "show_attachments",
- "show_price",
- "show_stock_availability",
- "enable_variants",
- "column_break_7",
- "show_contact_us_button",
- "show_quantity_in_website",
- "show_apply_coupon_code_in_website",
- "allow_items_not_in_stock",
- "section_break_2",
- "company",
- "price_list",
- "column_break_4",
- "default_customer_group",
- "quotation_series",
- "section_break_8",
- "enable_checkout",
- "save_quotations_as_draft",
- "column_break_11",
- "payment_gateway_account",
- "payment_success_url"
- ],
- "fields": [
- {
- "default": "0",
- "fieldname": "enabled",
- "fieldtype": "Check",
- "in_list_view": 1,
- "label": "Enable Shopping Cart"
- },
- {
- "fieldname": "display_settings",
- "fieldtype": "Section Break",
- "label": "Display Settings"
- },
- {
- "default": "0",
- "fieldname": "show_attachments",
- "fieldtype": "Check",
- "label": "Show Public Attachments"
- },
- {
- "default": "0",
- "fieldname": "show_price",
- "fieldtype": "Check",
- "label": "Show Price"
- },
- {
- "default": "0",
- "fieldname": "show_stock_availability",
- "fieldtype": "Check",
- "label": "Show Stock Availability"
- },
- {
- "default": "0",
- "fieldname": "show_contact_us_button",
- "fieldtype": "Check",
- "label": "Show Contact Us Button"
- },
- {
- "default": "0",
- "depends_on": "show_stock_availability",
- "fieldname": "show_quantity_in_website",
- "fieldtype": "Check",
- "label": "Show Stock Quantity"
- },
- {
- "default": "0",
- "fieldname": "show_apply_coupon_code_in_website",
- "fieldtype": "Check",
- "label": "Show Apply Coupon Code"
- },
- {
- "default": "0",
- "fieldname": "allow_items_not_in_stock",
- "fieldtype": "Check",
- "label": "Allow items not in stock to be added to cart"
- },
- {
- "depends_on": "enabled",
- "fieldname": "section_break_2",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "company",
- "fieldtype": "Link",
- "in_list_view": 1,
- "label": "Company",
- "mandatory_depends_on": "eval: doc.enabled === 1",
- "options": "Company",
- "remember_last_selected_value": 1
- },
- {
- "description": "Prices will not be shown if Price List is not set",
- "fieldname": "price_list",
- "fieldtype": "Link",
- "label": "Price List",
- "mandatory_depends_on": "eval: doc.enabled === 1",
- "options": "Price List"
- },
- {
- "fieldname": "column_break_4",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "default_customer_group",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "label": "Default Customer Group",
- "mandatory_depends_on": "eval: doc.enabled === 1",
- "options": "Customer Group"
- },
- {
- "fieldname": "quotation_series",
- "fieldtype": "Select",
- "label": "Quotation Series",
- "mandatory_depends_on": "eval: doc.enabled === 1"
- },
- {
- "collapsible": 1,
- "collapsible_depends_on": "eval:doc.enable_checkout",
- "depends_on": "enabled",
- "fieldname": "section_break_8",
- "fieldtype": "Section Break",
- "label": "Checkout Settings"
- },
- {
- "default": "0",
- "fieldname": "enable_checkout",
- "fieldtype": "Check",
- "label": "Enable Checkout"
- },
- {
- "default": "Orders",
- "depends_on": "enable_checkout",
- "description": "After payment completion redirect user to selected page.",
- "fieldname": "payment_success_url",
- "fieldtype": "Select",
- "label": "Payment Success Url",
- "mandatory_depends_on": "enable_checkout",
- "options": "\nOrders\nInvoices\nMy Account"
- },
- {
- "fieldname": "column_break_11",
- "fieldtype": "Column Break"
- },
- {
- "depends_on": "enable_checkout",
- "fieldname": "payment_gateway_account",
- "fieldtype": "Link",
- "label": "Payment Gateway Account",
- "mandatory_depends_on": "enable_checkout",
- "options": "Payment Gateway Account"
- },
- {
- "fieldname": "column_break_7",
- "fieldtype": "Column Break"
- },
- {
- "default": "0",
- "fieldname": "enable_variants",
- "fieldtype": "Check",
- "label": "Enable Variants"
- },
- {
- "default": "0",
- "depends_on": "eval: doc.enable_checkout == 0",
- "fieldname": "save_quotations_as_draft",
- "fieldtype": "Check",
- "label": "Save Quotations as Draft"
- },
- {
- "depends_on": "doc.enabled",
- "fieldname": "store_page_docs",
- "fieldtype": "HTML"
- }
- ],
- "icon": "fa fa-shopping-cart",
- "idx": 1,
- "issingle": 1,
- "links": [],
- "modified": "2021-03-02 17:34:57.642565",
- "modified_by": "Administrator",
- "module": "Shopping Cart",
- "name": "Shopping Cart Settings",
- "owner": "Administrator",
- "permissions": [
- {
- "create": 1,
- "email": 1,
- "print": 1,
- "read": 1,
- "role": "Website Manager",
- "share": 1,
- "write": 1
- }
- ],
- "sort_field": "modified",
- "sort_order": "ASC",
- "track_changes": 1
-}
\ No newline at end of file
diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py
deleted file mode 100644
index ef0badc8c8..0000000000
--- a/erpnext/shopping_cart/filters.py
+++ /dev/null
@@ -1,85 +0,0 @@
-# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-
-import frappe
-
-
-class ProductFiltersBuilder:
- def __init__(self, item_group=None):
- if not item_group or item_group == "Products Settings":
- self.doc = frappe.get_doc("Products Settings")
- else:
- self.doc = frappe.get_doc("Item Group", item_group)
-
- self.item_group = item_group
-
- def get_field_filters(self):
- filter_fields = [row.fieldname for row in self.doc.filter_fields]
-
- meta = frappe.get_meta('Item')
- fields = [df for df in meta.fields if df.fieldname in filter_fields]
-
- filter_data = []
- for df in fields:
- filters, or_filters = {}, []
- if df.fieldtype == "Link":
- if 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, or_filters=or_filters, distinct="True", pluck=df.fieldname)
- else:
- doctype = df.get_link_doctype()
-
- # apply enable/disable/show_in_website filter
- meta = frappe.get_meta(doctype)
-
- if meta.has_field('enabled'):
- filters['enabled'] = 1
- if meta.has_field('disabled'):
- filters['disabled'] = 0
- if meta.has_field('show_in_website'):
- filters['show_in_website'] = 1
-
- values = [d.name for d in frappe.get_all(doctype, filters)]
-
- # Remove None
- if None in values:
- values.remove(None)
-
- if values:
- filter_data.append([df, values])
-
- return filter_data
-
- def get_attribute_filters(self):
- attributes = [row.attribute for row in self.doc.filter_attributes]
-
- if not attributes:
- return []
-
- result = frappe.db.sql(
- """
- select
- distinct attribute, attribute_value
- from
- `tabItem Variant Attribute`
- where
- attribute in %(attributes)s
- and attribute_value is not null
- """,
- {"attributes": attributes},
- as_dict=1,
- )
-
- attribute_value_map = {}
- for d in result:
- attribute_value_map.setdefault(d.attribute, []).append(d.attribute_value)
-
- out = []
- for name, values in attribute_value_map.items():
- out.append(frappe._dict(name=name, item_attribute_values=values))
- return out
diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py
deleted file mode 100644
index 5cc0505aed..0000000000
--- a/erpnext/shopping_cart/product_query.py
+++ /dev/null
@@ -1,161 +0,0 @@
-# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-
-from erpnext.shopping_cart.product_info import get_product_info_for_website
-
-
-class ProductQuery:
- """Query engine for product listing
-
- Attributes:
- cart_settings (Document): Settings for Cart
- fields (list): Fields to fetch in query
- filters (TYPE): Description
- or_filters (list): Description
- page_length (Int): Length of page for the query
- settings (Document): Products Settings DocType
- filters (list)
- or_filters (list)
- """
-
- def __init__(self):
- 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', '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, item_group=None):
- """Summary
-
- Args:
- attributes (dict, optional): Item Attribute filters
- fields (dict, optional): Field level filters
- search_term (str, optional): Search term to lookup
- start (int, optional): Page start
-
- Returns:
- list: List of results with set fields
- """
- if fields: self.build_fields_filters(fields)
- 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 = []
- for attribute, values in attributes.items():
- if not isinstance(values, list):
- values = [values]
-
- items = frappe.get_all(
- "Item",
- fields=self.fields,
- filters=[
- *self.filters,
- ["Item Variant Attribute", "attribute", "=", attribute],
- ["Item Variant Attribute", "attribute_value", "in", values],
- ],
- or_filters=self.or_filters,
- start=start,
- limit=self.page_length,
- order_by="weightage desc"
- )
-
- items_dict = {item.name: item for item in items}
-
- 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,
- 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.get('price') or {}).get('formatted_price')
-
- return result
-
- def build_fields_filters(self, filters):
- """Build filters for field values
-
- Args:
- filters (dict): Filters
- """
- for field, values in filters.items():
- if not values:
- continue
-
- # 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:
- # `=` will be faster than `IN` for most cases
- self.filters.append([field, '=', values])
-
- def build_search_filters(self, search_term):
- """Query search term in specified fields
-
- Args:
- search_term (str): Search candidate
- """
- # Default fields to search from
- default_fields = {'name', 'item_name', 'description', 'item_group'}
-
- # Get meta search fields
- meta = frappe.get_meta("Item")
- meta_fields = set(meta.get_search_fields())
-
- # Join the meta fields and default fields set
- search_fields = default_fields.union(meta_fields)
- try:
- if frappe.db.count('Item', cache=True) > 50000:
- search_fields.remove('description')
- except KeyError:
- pass
-
- # Build or filters for query
- search = '%{}%'.format(search_term)
- self.or_filters += [[field, 'like', search] for field in search_fields]
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note.py b/erpnext/stock/doctype/delivery_note/delivery_note.py
index d1e22440b9..2a4d63954a 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note.py
+++ b/erpnext/stock/doctype/delivery_note/delivery_note.py
@@ -339,17 +339,35 @@ class DeliveryNote(SellingController):
frappe.throw(_("Could not create Credit Note automatically, please uncheck 'Issue Credit Note' and submit again"))
def update_billed_amount_based_on_so(so_detail, update_modified=True):
+ from frappe.query_builder.functions import Sum
+
# Billed against Sales Order directly
- billed_against_so = frappe.db.sql("""select sum(amount) from `tabSales Invoice Item`
- where so_detail=%s and (dn_detail is null or dn_detail = '') and docstatus=1""", so_detail)
+ si = frappe.qb.DocType("Sales Invoice").as_("si")
+ si_item = frappe.qb.DocType("Sales Invoice Item").as_("si_item")
+ sum_amount = Sum(si_item.amount).as_("amount")
+
+ billed_against_so = frappe.qb.from_(si).from_(si_item).select(sum_amount).where(
+ (si_item.parent == si.name) &
+ (si_item.so_detail == so_detail) &
+ ((si_item.dn_detail.isnull()) | (si_item.dn_detail == '')) &
+ (si_item.docstatus == 1) &
+ (si.update_stock == 0)
+ ).run()
billed_against_so = billed_against_so and billed_against_so[0][0] or 0
# Get all Delivery Note Item rows against the Sales Order Item row
- dn_details = frappe.db.sql("""select dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent
- from `tabDelivery Note Item` dn_item, `tabDelivery Note` dn
- where dn.name=dn_item.parent and dn_item.so_detail=%s
- and dn.docstatus=1 and dn.is_return = 0
- order by dn.posting_date asc, dn.posting_time asc, dn.name asc""", so_detail, as_dict=1)
+
+ dn = frappe.qb.DocType("Delivery Note").as_("dn")
+ dn_item = frappe.qb.DocType("Delivery Note Item").as_("dn_item")
+
+ dn_details = frappe.qb.from_(dn).from_(dn_item).select(dn_item.name, dn_item.amount, dn_item.si_detail, dn_item.parent, dn_item.stock_qty, dn_item.returned_qty).where(
+ (dn.name == dn_item.parent) &
+ (dn_item.so_detail == so_detail) &
+ (dn.docstatus == 1) &
+ (dn.is_return == 0)
+ ).orderby(
+ dn.posting_date, dn.posting_time, dn.name
+ ).run(as_dict=True)
updated_dn = []
for dnd in dn_details:
@@ -367,7 +385,11 @@ def update_billed_amount_based_on_so(so_detail, update_modified=True):
# Distribute billed amount directly against SO between DNs based on FIFO
if billed_against_so and billed_amt_agianst_dn < dnd.amount:
- pending_to_bill = flt(dnd.amount) - billed_amt_agianst_dn
+ if dnd.returned_qty:
+ pending_to_bill = flt(dnd.amount) * (dnd.stock_qty - dnd.returned_qty) / dnd.stock_qty
+ else:
+ pending_to_bill = flt(dnd.amount)
+ pending_to_bill -= billed_amt_agianst_dn
if pending_to_bill <= billed_against_so:
billed_amt_agianst_dn += pending_to_bill
billed_against_so -= pending_to_bill
@@ -586,7 +608,18 @@ def make_packing_slip(source_name, target_doc=None):
"validation": {
"docstatus": ["=", 0]
}
+ },
+
+ "Delivery Note Item": {
+ "doctype": "Packing Slip Item",
+ "field_map": {
+ "item_code": "item_code",
+ "item_name": "item_name",
+ "description": "description",
+ "qty": "qty",
+ }
}
+
}, target_doc)
return doclist
diff --git a/erpnext/stock/doctype/delivery_note/delivery_note_list.js b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
index 0402898047..9e6f3bc932 100644
--- a/erpnext/stock/doctype/delivery_note/delivery_note_list.js
+++ b/erpnext/stock/doctype/delivery_note/delivery_note_list.js
@@ -14,7 +14,7 @@ frappe.listview_settings['Delivery Note'] = {
return [__("Completed"), "green", "per_billed,=,100"];
}
},
- onload: function (doclist) {
+ onload: function (listview) {
const action = () => {
const selected_docs = doclist.get_checked_items();
const docnames = doclist.get_checked_items(true);
@@ -54,6 +54,16 @@ frappe.listview_settings['Delivery Note'] = {
};
};
- doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false);
+ // doclist.page.add_actions_menu_item(__('Create Delivery Trip'), action, false);
+
+ listview.page.add_action_item(__('Create Delivery Trip'), action);
+
+ listview.page.add_action_item(__("Sales Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Sales Invoice");
+ });
+
+ listview.page.add_action_item(__("Packaging Slip From Delivery Note"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Delivery Note", "Packing Slip");
+ });
}
};
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 86c702c539..2a30ca11fb 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -17,8 +17,6 @@ frappe.ui.form.on("Item", {
frm.fields_dict["attributes"].grid.set_column_disp("attribute_value", true);
}
- // should never check Private
- frm.fields_dict["website_image"].df.is_private = 0;
if (frm.doc.is_fixed_asset) {
frm.trigger("set_asset_naming_series");
}
@@ -91,6 +89,29 @@ frappe.ui.form.on("Item", {
erpnext.toggle_naming_series();
}
+ if (!frm.doc.published_in_website) {
+ frm.add_custom_button(__("Publish in Website"), function() {
+ frappe.call({
+ method: "erpnext.e_commerce.doctype.website_item.website_item.make_website_item",
+ args: {doc: frm.doc},
+ freeze: true,
+ freeze_message: __("Publishing Item ..."),
+ callback: function(result) {
+ frappe.msgprint({
+ message: __("Website Item {0} has been created.",
+ [repl('
%(item)s ', {
+ item_encoded: encodeURIComponent(result.message[0]),
+ item: result.message[1]
+ })]
+ ),
+ title: __("Published"),
+ indicator: "green"
+ });
+ }
+ });
+ }, __('Actions'));
+ }
+
erpnext.item.edit_prices_button(frm);
erpnext.item.toggle_attributes(frm);
@@ -182,25 +203,8 @@ frappe.ui.form.on("Item", {
}
},
- copy_from_item_group: function(frm) {
- return frm.call({
- doc: frm.doc,
- method: "copy_specification_from_item_group"
- });
- },
-
has_variants: function(frm) {
erpnext.item.toggle_attributes(frm);
- },
-
- show_in_website: function(frm) {
- if (frm.doc.default_warehouse && !frm.doc.website_warehouse){
- frm.set_value("website_warehouse", frm.doc.default_warehouse);
- }
- },
-
- set_meta_tags(frm) {
- frappe.utils.set_meta_tag(frm.doc.route);
}
});
@@ -392,13 +396,15 @@ $.extend(erpnext.item, {
edit_prices_button: function(frm) {
frm.add_custom_button(__("Add / Edit Prices"), function() {
frappe.set_route("List", "Item Price", {"item_code": frm.doc.name});
- }, __("View"));
+ }, __("Actions"));
},
- weight_to_validate: function(frm){
- if((frm.doc.nett_weight || frm.doc.gross_weight) && !frm.doc.weight_uom) {
- frappe.msgprint(__('Weight is mentioned,\nPlease mention "Weight UOM" too'));
- frappe.validated = 0;
+ weight_to_validate: function(frm) {
+ if (frm.doc.weight_per_unit && !frm.doc.weight_uom) {
+ frappe.msgprint({
+ message: __("Please mention 'Weight UOM' along with Weight."),
+ title: __("Note")
+ });
}
},
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index 2d28cc09f9..b05f58a982 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -117,24 +117,8 @@
"customer_code",
"default_item_manufacturer",
"default_manufacturer_part_no",
- "website_section",
- "show_in_website",
- "show_variant_in_website",
- "route",
- "weightage",
- "slideshow",
- "website_image",
- "website_image_alt",
- "thumbnail",
- "cb72",
- "website_warehouse",
- "website_item_groups",
- "set_meta_tags",
- "sb72",
- "copy_from_item_group",
- "website_specifications",
- "web_long_description",
- "website_content",
+ "more_information_section",
+ "published_in_website",
"total_projected_qty"
],
"fields": [
@@ -362,7 +346,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Valuation Method",
- "options": "\nFIFO\nMoving Average"
+ "options": "\nFIFO\nMoving Average\nLIFO"
},
{
"depends_on": "is_stock_item",
@@ -856,125 +840,6 @@
"no_copy": 1,
"print_hide": 1
},
- {
- "collapsible": 1,
- "depends_on": "eval:!doc.is_fixed_asset",
- "fieldname": "website_section",
- "fieldtype": "Section Break",
- "label": "Website",
- "options": "fa fa-globe"
- },
- {
- "default": "0",
- "depends_on": "eval:!doc.variant_of",
- "fieldname": "show_in_website",
- "fieldtype": "Check",
- "label": "Show in Website",
- "search_index": 1
- },
- {
- "default": "0",
- "depends_on": "variant_of",
- "fieldname": "show_variant_in_website",
- "fieldtype": "Check",
- "label": "Show in Website (Variant)",
- "search_index": 1
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "route",
- "fieldtype": "Small Text",
- "label": "Route",
- "no_copy": 1
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "description": "Items with higher weightage will be shown higher",
- "fieldname": "weightage",
- "fieldtype": "Int",
- "label": "Weightage"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "description": "Show a slideshow at the top of the page",
- "fieldname": "slideshow",
- "fieldtype": "Link",
- "label": "Slideshow",
- "options": "Website Slideshow"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "description": "Item Image (if not slideshow)",
- "fieldname": "website_image",
- "fieldtype": "Attach",
- "label": "Website Image"
- },
- {
- "fieldname": "thumbnail",
- "fieldtype": "Data",
- "label": "Thumbnail",
- "read_only": 1
- },
- {
- "fieldname": "cb72",
- "fieldtype": "Column Break"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "description": "Show \"In Stock\" or \"Not in Stock\" based on stock available in this warehouse.",
- "fieldname": "website_warehouse",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "label": "Website Warehouse",
- "options": "Warehouse"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "description": "List this Item in multiple groups on the website.",
- "fieldname": "website_item_groups",
- "fieldtype": "Table",
- "label": "Website Item Groups",
- "options": "Website Item Group"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "set_meta_tags",
- "fieldtype": "Button",
- "label": "Set Meta Tags"
- },
- {
- "collapsible": 1,
- "collapsible_depends_on": "website_specifications",
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "sb72",
- "fieldtype": "Section Break",
- "label": "Website Specifications"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "copy_from_item_group",
- "fieldtype": "Button",
- "label": "Copy From Item Group"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "website_specifications",
- "fieldtype": "Table",
- "label": "Website Specifications",
- "options": "Item Website Specification"
- },
- {
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "web_long_description",
- "fieldtype": "Text Editor",
- "label": "Website Description"
- },
- {
- "description": "You can use any valid Bootstrap 4 markup in this field. It will be shown on your Item Page.",
- "fieldname": "website_content",
- "fieldtype": "HTML Editor",
- "label": "Website Content"
- },
{
"fieldname": "total_projected_qty",
"fieldtype": "Float",
@@ -1017,10 +882,18 @@
"read_only": 1
},
{
- "depends_on": "eval: doc.show_in_website || doc.show_variant_in_website",
- "fieldname": "website_image_alt",
- "fieldtype": "Data",
- "label": "Image Description"
+ "collapsible": 1,
+ "fieldname": "more_information_section",
+ "fieldtype": "Section Break",
+ "label": "More Information"
+ },
+ {
+ "default": "0",
+ "depends_on": "published_in_website",
+ "fieldname": "published_in_website",
+ "fieldtype": "Check",
+ "label": "Published in Website",
+ "read_only": 1
},
{
"default": "1",
@@ -1036,7 +909,6 @@
"label": "Create Grouped Asset"
}
],
- "has_web_view": 1,
"icon": "fa fa-tag",
"idx": 2,
"image_field": "image",
@@ -1115,4 +987,4 @@
"states": [],
"title_field": "item_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index d99fadca46..b9e8b3f2f1 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -2,12 +2,12 @@
# License: GNU General Public License v3. See license.txt
import copy
-import itertools
import json
from typing import List
import frappe
from frappe import _
+from frappe.model.document import Document
from frappe.utils import (
cint,
cstr,
@@ -17,13 +17,9 @@ from frappe.utils import (
getdate,
now_datetime,
nowtime,
- random_string,
strip,
)
from frappe.utils.html_utils import clean_html
-from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow
-from frappe.website.utils import clear_cache
-from frappe.website.website_generator import WebsiteGenerator
import erpnext
from erpnext.controllers.item_variant import (
@@ -33,10 +29,7 @@ from erpnext.controllers.item_variant import (
make_variant_item_code,
validate_item_variant_attributes,
)
-from erpnext.setup.doctype.item_group.item_group import (
- get_parent_item_groups,
- invalidate_cache_for,
-)
+from erpnext.setup.doctype.item_group.item_group import invalidate_cache_for
from erpnext.stock.doctype.item_default.item_default import ItemDefault
@@ -51,18 +44,11 @@ class StockExistsForTemplate(frappe.ValidationError):
class InvalidBarcode(frappe.ValidationError):
pass
+class DataValidationError(frappe.ValidationError):
+ pass
-class Item(WebsiteGenerator):
- website = frappe._dict(
- page_title_field="item_name",
- condition_field="show_in_website",
- template="templates/generators/item/item.html",
- no_cache=1
- )
-
+class Item(Document):
def onload(self):
- super(Item, self).onload()
-
self.set_onload('stock_exists', self.stock_ledger_created())
self.set_asset_naming_series()
@@ -103,8 +89,6 @@ class Item(WebsiteGenerator):
self.set_opening_stock()
def validate(self):
- super(Item, self).validate()
-
if not self.item_name:
self.item_name = self.item_code
@@ -130,8 +114,6 @@ class Item(WebsiteGenerator):
self.validate_attributes()
self.validate_variant_attributes()
self.validate_variant_based_on_change()
- self.validate_website_image()
- self.make_thumbnail()
self.validate_fixed_asset()
self.validate_retain_sample()
self.validate_uom_conversion_factor()
@@ -140,21 +122,17 @@ class Item(WebsiteGenerator):
self.validate_item_defaults()
self.validate_auto_reorder_enabled_in_stock_settings()
self.cant_change()
- self.update_show_in_website()
self.validate_item_tax_net_rate_range()
set_item_tax_from_hsn_code(self)
if not self.is_new():
self.old_item_group = frappe.db.get_value(self.doctype, self.name, "item_group")
- self.old_website_item_groups = frappe.db.sql_list("""select item_group
- from `tabWebsite Item Group`
- where parentfield='website_item_groups' and parenttype='Item' and parent=%s""", self.name)
def on_update(self):
invalidate_cache_for_item(self)
self.update_variants()
self.update_item_price()
- self.update_template_item()
+ self.update_website_item()
def validate_description(self):
'''Clean HTML description if set'''
@@ -216,97 +194,6 @@ class Item(WebsiteGenerator):
stock_entry.add_comment("Comment", _("Opening Stock"))
- def make_route(self):
- if not self.route:
- return cstr(frappe.db.get_value('Item Group', self.item_group,
- 'route')) + '/' + self.scrub((self.item_name or self.item_code) + '-' + random_string(5))
-
- def validate_website_image(self):
- """Validate if the website image is a public file"""
-
- if frappe.flags.in_import:
- return
-
- auto_set_website_image = False
- if not self.website_image and self.image:
- auto_set_website_image = True
- self.website_image = self.image
-
- if not self.website_image:
- return
-
- # find if website image url exists as public
- file_doc = frappe.get_all("File", filters={
- "file_url": self.website_image
- }, fields=["name", "is_private"], order_by="is_private asc", limit_page_length=1)
-
- if file_doc:
- file_doc = file_doc[0]
-
- if not file_doc:
- if not auto_set_website_image:
- frappe.msgprint(_("Website Image {0} attached to Item {1} cannot be found").format(self.website_image, self.name))
-
- self.website_image = None
-
- elif file_doc.is_private:
- if not auto_set_website_image:
- frappe.msgprint(_("Website Image should be a public file or website URL"))
-
- self.website_image = None
-
- def make_thumbnail(self):
- """Make a thumbnail of `website_image`"""
-
- if frappe.flags.in_import:
- return
-
- import requests.exceptions
-
- if not self.is_new() and self.website_image != frappe.db.get_value(self.doctype, self.name, "website_image"):
- self.thumbnail = None
-
- if self.website_image and not self.thumbnail:
- file_doc = None
-
- try:
- file_doc = frappe.get_doc("File", {
- "file_url": self.website_image,
- "attached_to_doctype": "Item",
- "attached_to_name": self.name
- })
- except frappe.DoesNotExistError:
- # cleanup
- frappe.local.message_log.pop()
-
- except requests.exceptions.HTTPError:
- frappe.msgprint(_("Warning: Invalid attachment {0}").format(self.website_image))
- self.website_image = None
-
- except requests.exceptions.SSLError:
- frappe.msgprint(
- _("Warning: Invalid SSL certificate on attachment {0}").format(self.website_image))
- self.website_image = None
-
- # for CSV import
- if self.website_image and not file_doc:
- try:
- file_doc = frappe.get_doc({
- "doctype": "File",
- "file_url": self.website_image,
- "attached_to_doctype": "Item",
- "attached_to_name": self.name
- }).save()
-
- except IOError:
- self.website_image = None
-
- if file_doc:
- if not file_doc.thumbnail_url:
- file_doc.make_thumbnail()
-
- self.thumbnail = file_doc.thumbnail_url
-
def validate_fixed_asset(self):
if self.is_fixed_asset:
if self.is_stock_item:
@@ -330,167 +217,6 @@ class Item(WebsiteGenerator):
frappe.throw(_("{0} Retain Sample is based on batch, please check Has Batch No to retain sample of item").format(
self.item_code))
- def get_context(self, context):
- context.show_search = True
- context.search_link = '/product_search'
-
- context.parents = get_parent_item_groups(self.item_group)
- context.body_class = "product-page"
-
- self.set_variant_context(context)
- self.set_attribute_context(context)
- self.set_disabled_attributes(context)
- self.set_metatags(context)
- self.set_shopping_cart_data(context)
-
- return context
-
- def set_variant_context(self, context):
- if self.has_variants:
- context.no_cache = True
-
- # load variants
- # also used in set_attribute_context
- context.variants = frappe.get_all("Item",
- filters={"variant_of": self.name, "show_variant_in_website": 1},
- order_by="name asc")
-
- variant = frappe.form_dict.variant
- if not variant and context.variants:
- # the case when the item is opened for the first time from its list
- variant = context.variants[0]
-
- if variant:
- context.variant = frappe.get_doc("Item", variant)
-
- for fieldname in ("website_image", "website_image_alt", "web_long_description", "description",
- "website_specifications"):
- if context.variant.get(fieldname):
- value = context.variant.get(fieldname)
- if isinstance(value, list):
- value = [d.as_dict() for d in value]
-
- context[fieldname] = value
-
- if self.slideshow:
- if context.variant and context.variant.slideshow:
- context.update(get_slideshow(context.variant))
- else:
- context.update(get_slideshow(self))
-
- def set_attribute_context(self, context):
- if not self.has_variants:
- return
-
- attribute_values_available = {}
- context.attribute_values = {}
- context.selected_attributes = {}
-
- # load attributes
- for v in context.variants:
- v.attributes = frappe.get_all("Item Variant Attribute",
- fields=["attribute", "attribute_value"],
- filters={"parent": v.name})
- # make a map for easier access in templates
- v.attribute_map = frappe._dict({})
- for attr in v.attributes:
- v.attribute_map[attr.attribute] = attr.attribute_value
-
- for attr in v.attributes:
- values = attribute_values_available.setdefault(attr.attribute, [])
- if attr.attribute_value not in values:
- values.append(attr.attribute_value)
-
- if v.name == context.variant.name:
- context.selected_attributes[attr.attribute] = attr.attribute_value
-
- # filter attributes, order based on attribute table
- for attr in self.attributes:
- values = context.attribute_values.setdefault(attr.attribute, [])
-
- if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")):
- for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt):
- values.append(val)
-
- else:
- # get list of values defined (for sequence)
- for attr_value in frappe.db.get_all("Item Attribute Value",
- fields=["attribute_value"],
- filters={"parent": attr.attribute}, order_by="idx asc"):
-
- if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []):
- values.append(attr_value.attribute_value)
-
- context.variant_info = json.dumps(context.variants)
-
- def set_disabled_attributes(self, context):
- """Disable selection options of attribute combinations that do not result in a variant"""
- if not self.attributes or not self.has_variants:
- return
-
- context.disabled_attributes = {}
- attributes = [attr.attribute for attr in self.attributes]
-
- def find_variant(combination):
- for variant in context.variants:
- if len(variant.attributes) < len(attributes):
- continue
-
- if "combination" not in variant:
- ref_combination = []
-
- for attr in variant.attributes:
- idx = attributes.index(attr.attribute)
- ref_combination.insert(idx, attr.attribute_value)
-
- variant["combination"] = ref_combination
-
- if not (set(combination) - set(variant["combination"])):
- # check if the combination is a subset of a variant combination
- # eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5]
- return True
-
- for i, attr in enumerate(self.attributes):
- if i == 0:
- continue
-
- combination_source = []
-
- # loop through previous attributes
- for prev_attr in self.attributes[:i]:
- combination_source.append([context.selected_attributes.get(prev_attr.attribute)])
-
- combination_source.append(context.attribute_values[attr.attribute])
-
- for combination in itertools.product(*combination_source):
- if not find_variant(combination):
- context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1])
-
- def set_metatags(self, context):
- context.metatags = frappe._dict({})
-
- safe_description = frappe.utils.to_markdown(self.description)
-
- context.metatags.url = frappe.utils.get_url() + '/' + context.route
-
- if context.website_image:
- if context.website_image.startswith('http'):
- url = context.website_image
- else:
- url = frappe.utils.get_url() + context.website_image
- context.metatags.image = url
-
- context.metatags.description = safe_description[:300]
-
- context.metatags.title = self.item_name or self.item_code
-
- context.metatags['og:type'] = 'product'
- context.metatags['og:site_name'] = 'ERPNext'
-
- def set_shopping_cart_data(self, context):
- from erpnext.shopping_cart.product_info import get_product_info_for_website
- context.shopping_cart = get_product_info_for_website(self.name, skip_quotation_creation=True)
-
def add_default_uom_in_conversion_factor_table(self):
if not self.is_new() and self.has_value_changed("stock_uom"):
self.uoms = []
@@ -507,9 +233,29 @@ class Item(WebsiteGenerator):
"conversion_factor": 1
})
- def update_show_in_website(self):
- if self.disabled:
- self.show_in_website = False
+ def update_website_item(self):
+ """Update Website Item if change in Item impacts it."""
+ web_item = frappe.db.exists("Website Item", {"item_code": self.item_code})
+
+ if web_item:
+ changed = {}
+ editable_fields = ["item_name", "item_group", "stock_uom", "brand", "description",
+ "disabled"]
+ doc_before_save = self.get_doc_before_save()
+
+ for field in editable_fields:
+ if doc_before_save.get(field) != self.get(field):
+ if field == "disabled":
+ changed["published"] = not self.get(field)
+ else:
+ changed[field] = self.get(field)
+
+ if not changed:
+ return
+
+ web_item_doc = frappe.get_doc("Website Item", web_item)
+ web_item_doc.update(changed)
+ web_item_doc.save()
def validate_item_tax_net_rate_range(self):
for tax in self.get('taxes'):
@@ -641,7 +387,6 @@ class Item(WebsiteGenerator):
)
def on_trash(self):
- super(Item, self).on_trash()
frappe.db.sql("""delete from tabBin where item_code=%s""", self.name)
frappe.db.sql("delete from `tabItem Price` where item_code=%s", self.name)
for variant_of in frappe.get_all("Item", filters={"variant_of": self.name}):
@@ -652,15 +397,8 @@ class Item(WebsiteGenerator):
frappe.db.set_value("Item", old_name, "item_name", new_name)
if merge:
- # Validate properties before merging
- if not frappe.db.exists("Item", new_name):
- frappe.throw(_("Item {0} does not exist").format(new_name))
-
- field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"]
- new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)]
- if new_properties != [cstr(self.get(fld)) for fld in field_list]:
- frappe.throw(_("To merge, following properties must be same for both items")
- + ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list]))
+ self.validate_properties_before_merge(new_name)
+ self.validate_duplicate_website_item_before_merge(old_name, new_name)
def after_rename(self, old_name, new_name, merge):
if merge:
@@ -668,9 +406,8 @@ class Item(WebsiteGenerator):
frappe.msgprint(_("It can take upto few hours for accurate stock values to be visible after merging items."),
indicator="orange", title="Note")
- if self.route:
+ if self.published_in_website:
invalidate_cache_for_item(self)
- clear_cache(self.route)
frappe.db.set_value("Item", new_name, "item_code", new_name)
@@ -710,7 +447,41 @@ class Item(WebsiteGenerator):
msg += _("Note: To merge the items, create a separate Stock Reconciliation for the old item {0}").format(
frappe.bold(old_name))
- frappe.throw(_(msg), title=_("Merge not allowed"))
+ frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError)
+
+ def validate_properties_before_merge(self, new_name):
+ # Validate properties before merging
+ if not frappe.db.exists("Item", new_name):
+ frappe.throw(_("Item {0} does not exist").format(new_name))
+
+ field_list = ["stock_uom", "is_stock_item", "has_serial_no", "has_batch_no"]
+ new_properties = [cstr(d) for d in frappe.db.get_value("Item", new_name, field_list)]
+
+ if new_properties != [cstr(self.get(field)) for field in field_list]:
+ msg = _("To merge, following properties must be same for both items")
+ msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])
+ frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError)
+
+ def validate_duplicate_website_item_before_merge(self, old_name, new_name):
+ """
+ Block merge if both old and new items have website items against them.
+ This is to avoid duplicate website items after merging.
+ """
+ web_items = frappe.get_all(
+ "Website Item",
+ filters={
+ "item_code": ["in", [old_name, new_name]]
+ },
+ fields=["item_code", "name"])
+
+ if len(web_items) <= 1:
+ return
+
+ old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0]
+ web_item_link = get_link_to_form("Website Item", old_web_item)
+
+ msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} and {new_name}"
+ frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError)
def set_last_purchase_rate(self, new_name):
last_purchase_rate = get_last_purchase_details(new_name).get("base_net_rate", 0)
@@ -732,16 +503,6 @@ class Item(WebsiteGenerator):
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", existing_allow_negative_stock)
- @frappe.whitelist()
- def copy_specification_from_item_group(self):
- self.set("website_specifications", [])
- if self.item_group:
- for label, desc in frappe.db.get_values("Item Website Specification",
- {"parent": self.item_group}, ["label", "description"]):
- row = self.append("website_specifications")
- row.label = label
- row.description = desc
-
def update_bom_item_desc(self):
if self.is_new():
return
@@ -765,25 +526,6 @@ class Item(WebsiteGenerator):
where item_code = %s and docstatus < 2
""", (self.description, self.name))
- def update_template_item(self):
- """Set Show in Website for Template Item if True for its Variant"""
- if not self.variant_of:
- return
-
- if self.show_in_website:
- self.show_variant_in_website = 1
- self.show_in_website = 0
-
- if self.show_variant_in_website:
- # show template
- template_item = frappe.get_doc("Item", self.variant_of)
-
- if not template_item.show_in_website:
- template_item.show_in_website = 1
- template_item.flags.dont_update_variants = True
- template_item.flags.ignore_permissions = True
- template_item.save()
-
def validate_item_defaults(self):
companies = {row.company for row in self.item_defaults}
@@ -1034,47 +776,6 @@ class Item(WebsiteGenerator):
if not enabled:
frappe.msgprint(msg=_("You have to enable auto re-order in Stock Settings to maintain re-order levels."), title=_("Enable Auto Re-Order"), indicator="orange")
- def create_onboarding_docs(self, args):
- company = frappe.defaults.get_defaults().get('company') or \
- frappe.db.get_single_value('Global Defaults', 'default_company')
-
- for i in range(1, args.get('max_count')):
- item = args.get('item_' + str(i))
- if item:
- default_warehouse = ''
- default_warehouse = frappe.db.get_value('Warehouse', filters={
- 'warehouse_name': _('Finished Goods'),
- 'company': company
- })
-
- try:
- frappe.get_doc({
- 'doctype': self.doctype,
- 'item_code': item,
- 'item_name': item,
- 'description': item,
- 'show_in_website': 1,
- 'is_sales_item': 1,
- 'is_purchase_item': 1,
- 'is_stock_item': 1,
- 'item_group': _('Products'),
- 'stock_uom': _(args.get('item_uom_' + str(i))),
- 'item_defaults': [{
- 'default_warehouse': default_warehouse,
- 'company': company
- }]
- }).insert()
-
- except frappe.NameError:
- pass
- else:
- if args.get('item_price_' + str(i)):
- item_price = flt(args.get('item_price_' + str(i)))
-
- price_list_name = frappe.db.get_value('Price List', {'selling': 1})
- make_item_price(item, price_list_name, item_price)
- price_list_name = frappe.db.get_value('Price List', {'buying': 1})
- make_item_price(item, price_list_name, item_price)
def make_item_price(item, price_list_name, item_price):
frappe.get_doc({
@@ -1189,14 +890,9 @@ def get_last_purchase_details(item_code, doc_name=None, conversion_rate=1.0):
def invalidate_cache_for_item(doc):
+ """Invalidate Item Group cache and rebuild ItemVariantsCacheManager."""
invalidate_cache_for(doc, doc.item_group)
- website_item_groups = list(set((doc.get("old_website_item_groups") or [])
- + [d.item_group for d in doc.get({"doctype": "Website Item Group"}) if d.item_group]))
-
- for item_group in website_item_groups:
- invalidate_cache_for(doc, item_group)
-
if doc.get("old_item_group") and doc.get("old_item_group") != doc.item_group:
invalidate_cache_for(doc, doc.old_item_group)
@@ -1204,12 +900,14 @@ def invalidate_cache_for_item(doc):
def invalidate_item_variants_cache_for_website(doc):
- from erpnext.portal.product_configurator.item_variants_cache import ItemVariantsCacheManager
+ """Rebuild ItemVariantsCacheManager via Item or Website Item."""
+ from erpnext.e_commerce.variant_selector.item_variants_cache import ItemVariantsCacheManager
item_code = None
- if doc.has_variants and doc.show_in_website:
- item_code = doc.name
- elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'show_in_website'):
+ is_web_item = doc.get("published_in_website") or doc.get("published")
+ if doc.has_variants and is_web_item:
+ item_code = doc.item_code
+ elif doc.variant_of and frappe.db.get_value('Item', doc.variant_of, 'published_in_website'):
item_code = doc.variant_of
if item_code:
@@ -1333,10 +1031,6 @@ def update_variants(variants, template, publish_progress=True):
if publish_progress:
frappe.publish_progress(count / total * 100, title=_("Updating Variants..."))
-def on_doctype_update():
- # since route is a Text column, it needs a length for indexing
- frappe.db.add_index("Item", ["route(500)"])
-
@erpnext.allow_regional
def set_item_tax_from_hsn_code(item):
pass
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index 0957ce0615..fc45ba99c4 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -536,7 +536,7 @@ class TestItem(ERPNextTestCase):
"check if index is getting created in db"
indices = frappe.db.sql("show index from tabItem", as_dict=1)
- expected_columns = {"item_code", "item_name", "item_group", "route"}
+ expected_columns = {"item_code", "item_name", "item_group"}
for index in indices:
expected_columns.discard(index.get("Column_name"))
diff --git a/erpnext/stock/doctype/item/test_records.json b/erpnext/stock/doctype/item/test_records.json
index 6cec85288f..91c77d5152 100644
--- a/erpnext/stock/doctype/item/test_records.json
+++ b/erpnext/stock/doctype/item/test_records.json
@@ -40,9 +40,7 @@
"conversion_factor": 10.0
}
],
- "stock_uom": "_Test UOM",
- "show_in_website": 1,
- "website_warehouse": "_Test Warehouse - _TC"
+ "stock_uom": "_Test UOM"
},
{
"description": "_Test Item 2",
@@ -56,8 +54,6 @@
"item_group": "_Test Item Group",
"item_name": "_Test Item 2",
"stock_uom": "_Test UOM",
- "show_in_website": 1,
- "website_warehouse": "_Test Warehouse - _TC",
"gst_hsn_code": "999800",
"opening_stock": 10,
"valuation_rate": 100,
@@ -311,8 +307,7 @@
"warehouse_reorder_level": 20,
"warehouse_reorder_qty": 20
}
- ],
- "show_in_website": 1
+ ]
},
{
"description": "_Test Item 1",
@@ -344,9 +339,7 @@
"warehouse_reorder_qty": 20
}
],
- "stock_uom": "_Test UOM",
- "show_in_website": 1,
- "website_warehouse": "_Test Warehouse Group-C1 - _TC"
+ "stock_uom": "_Test UOM"
},
{
"description": "_Test Item With Item Tax Template",
diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
index 488920aadb..5e1f7d5322 100644
--- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
+++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.js
@@ -7,9 +7,8 @@ frappe.ui.form.on('Item Variant Settings', {
const existing_fields = frm.doc.fields.map(row => row.field_name);
const exclude_fields = [...existing_fields, "naming_series", "item_code", "item_name",
- "show_in_website", "show_variant_in_website", "standard_rate", "opening_stock", "image",
- "variant_of", "valuation_rate", "barcodes", "website_image", "thumbnail",
- "website_specifiations", "web_long_description", "has_variants", "attributes"];
+ "published_in_website", "standard_rate", "opening_stock", "image",
+ "variant_of", "valuation_rate", "barcodes", "has_variants", "attributes"];
const exclude_field_types = ['HTML', 'Section Break', 'Column Break', 'Button', 'Read Only'];
diff --git a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py
index f63498b9ac..be1517eb58 100644
--- a/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py
+++ b/erpnext/stock/doctype/item_variant_settings/item_variant_settings.py
@@ -1,4 +1,4 @@
-# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and contributors
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
@@ -13,10 +13,9 @@ class ItemVariantSettings(Document):
def set_default_fields(self):
self.fields = []
fields = frappe.get_meta('Item').fields
- exclude_fields = {"naming_series", "item_code", "item_name", "show_in_website",
- "show_variant_in_website", "standard_rate", "opening_stock", "image", "description",
+ exclude_fields = {"naming_series", "item_code", "item_name", "published_in_website",
+ "standard_rate", "opening_stock", "image", "description",
"variant_of", "valuation_rate", "description", "barcodes",
- "website_image", "thumbnail", "website_specifiations", "web_long_description",
"has_variants", "attributes"}
for d in fields:
diff --git a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
index 9204842b8f..df8cadd7f8 100644
--- a/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
+++ b/erpnext/stock/doctype/landed_cost_voucher/test_landed_cost_voucher.py
@@ -4,10 +4,11 @@
import frappe
-from frappe.utils import flt
+from frappe.utils import add_to_date, flt, now
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
+from erpnext.accounts.utils import update_gl_entries_after
from erpnext.assets.doctype.asset.test_asset import create_asset_category, create_fixed_asset_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries,
@@ -28,7 +29,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
- "warehouse": "Stores - TCP1"
+ "warehouse": "Stores - TCP1",
+ "is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
@@ -41,14 +43,39 @@ class TestLandedCostVoucher(ERPNextTestCase):
"voucher_type": pr.doctype,
"voucher_no": pr.name,
"item_code": "_Test Item",
- "warehouse": "Stores - TCP1"
+ "warehouse": "Stores - TCP1",
+ "is_cancelled": 0,
},
fieldname=["qty_after_transaction", "stock_value"], as_dict=1)
self.assertEqual(last_sle.qty_after_transaction, last_sle_after_landed_cost.qty_after_transaction)
-
self.assertEqual(last_sle_after_landed_cost.stock_value - last_sle.stock_value, 25.0)
+ # assert after submit
+ self.assertPurchaseReceiptLCVGLEntries(pr)
+
+ # Mess up cancelled SLE modified timestamp to check
+ # if they aren't effective in any business logic.
+ frappe.db.set_value("Stock Ledger Entry",
+ {
+ "is_cancelled": 1,
+ "voucher_type": pr.doctype,
+ "voucher_no": pr.name
+ },
+ "is_cancelled", 1,
+ modified=add_to_date(now(), hours=1, as_datetime=True, as_string=True)
+ )
+
+ items, warehouses = pr.get_items_and_warehouses()
+ update_gl_entries_after(pr.posting_date, pr.posting_time,
+ warehouses, items, company=pr.company)
+
+ # reassert after reposting
+ self.assertPurchaseReceiptLCVGLEntries(pr)
+
+
+ def assertPurchaseReceiptLCVGLEntries(self, pr):
+
gl_entries = get_gl_entries("Purchase Receipt", pr.name)
self.assertTrue(gl_entries)
@@ -74,8 +101,8 @@ class TestLandedCostVoucher(ERPNextTestCase):
for gle in gl_entries:
if not gle.get('is_cancelled'):
- self.assertEqual(expected_values[gle.account][0], gle.debit)
- self.assertEqual(expected_values[gle.account][1], gle.credit)
+ self.assertEqual(expected_values[gle.account][0], gle.debit, msg=f"incorrect debit for {gle.account}")
+ self.assertEqual(expected_values[gle.account][1], gle.credit, msg=f"incorrect credit for {gle.account}")
def test_landed_cost_voucher_against_purchase_invoice(self):
diff --git a/erpnext/stock/doctype/packed_item/packed_item.json b/erpnext/stock/doctype/packed_item/packed_item.json
index 830d5469bf..d2d4789765 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.json
+++ b/erpnext/stock/doctype/packed_item/packed_item.json
@@ -218,8 +218,6 @@
"label": "Conversion Factor"
},
{
- "fetch_from": "item_code.valuation_rate",
- "fetch_if_empty": 1,
"fieldname": "rate",
"fieldtype": "Currency",
"in_list_view": 1,
@@ -232,7 +230,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-09-01 15:10:29.646399",
+ "modified": "2022-01-28 16:03:30.780111",
"modified_by": "Administrator",
"module": "Stock",
"name": "Packed Item",
@@ -240,5 +238,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/packed_item/packed_item.py b/erpnext/stock/doctype/packed_item/packed_item.py
index e4091c40dc..07c2f1f0dd 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -8,187 +8,253 @@ import json
import frappe
from frappe.model.document import Document
-from frappe.utils import cstr, flt
+from frappe.utils import flt
-from erpnext.stock.get_item_details import get_item_details
+from erpnext.stock.get_item_details import get_item_details, get_price_list_rate
class PackedItem(Document):
pass
-def get_product_bundle_items(item_code):
- return frappe.db.sql("""select t1.item_code, t1.qty, t1.uom, t1.description
- from `tabProduct Bundle Item` t1, `tabProduct Bundle` t2
- where t2.new_item_code=%s and t1.parent = t2.name order by t1.idx""", item_code, as_dict=1)
-
-def get_packing_item_details(item, company):
- return frappe.db.sql("""
- select i.item_name, i.is_stock_item, i.description, i.stock_uom, id.default_warehouse
- from `tabItem` i LEFT JOIN `tabItem Default` id ON id.parent=i.name and id.company=%s
- where i.name = %s""",
- (company, item), as_dict = 1)[0]
-
-def get_bin_qty(item, warehouse):
- det = frappe.db.sql("""select actual_qty, projected_qty from `tabBin`
- where item_code = %s and warehouse = %s""", (item, warehouse), as_dict = 1)
- return det and det[0] or frappe._dict()
-
-def update_packing_list_item(doc, packing_item_code, qty, main_item_row, description):
- if doc.amended_from:
- old_packed_items_map = get_old_packed_item_details(doc.packed_items)
- else:
- old_packed_items_map = False
- item = get_packing_item_details(packing_item_code, doc.company)
-
- # check if exists
- exists = 0
- for d in doc.get("packed_items"):
- if d.parent_item == main_item_row.item_code and d.item_code == packing_item_code:
- if d.parent_detail_docname != main_item_row.name:
- d.parent_detail_docname = main_item_row.name
-
- pi, exists = d, 1
- break
-
- if not exists:
- pi = doc.append('packed_items', {})
-
- pi.parent_item = main_item_row.item_code
- pi.item_code = packing_item_code
- pi.item_name = item.item_name
- pi.parent_detail_docname = main_item_row.name
- pi.uom = item.stock_uom
- pi.qty = flt(qty)
- pi.conversion_factor = main_item_row.conversion_factor
- if description and not pi.description:
- pi.description = description
- if not pi.warehouse and not doc.amended_from:
- pi.warehouse = (main_item_row.warehouse if ((doc.get('is_pos') or item.is_stock_item \
- or not item.default_warehouse) and main_item_row.warehouse) else item.default_warehouse)
- if not pi.batch_no and not doc.amended_from:
- pi.batch_no = cstr(main_item_row.get("batch_no"))
- if not pi.target_warehouse:
- pi.target_warehouse = main_item_row.get("target_warehouse")
- bin = get_bin_qty(packing_item_code, pi.warehouse)
- pi.actual_qty = flt(bin.get("actual_qty"))
- pi.projected_qty = flt(bin.get("projected_qty"))
- if old_packed_items_map and old_packed_items_map.get((packing_item_code, main_item_row.item_code)):
- pi.batch_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].batch_no
- pi.serial_no = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].serial_no
- pi.warehouse = old_packed_items_map.get((packing_item_code, main_item_row.item_code))[0].warehouse
def make_packing_list(doc):
- """make packing list for Product Bundle item"""
- if doc.get("_action") and doc._action == "update_after_submit": return
-
- parent_items = []
- for d in doc.get("items"):
- if frappe.db.get_value("Product Bundle", {"new_item_code": d.item_code}):
- for i in get_product_bundle_items(d.item_code):
- update_packing_list_item(doc, i.item_code, flt(i.qty)*flt(d.stock_qty), d, i.description)
-
- if [d.item_code, d.name] not in parent_items:
- parent_items.append([d.item_code, d.name])
-
- cleanup_packing_list(doc, parent_items)
-
- if frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates"):
- update_product_bundle_price(doc, parent_items)
-
-def cleanup_packing_list(doc, parent_items):
- """Remove all those child items which are no longer present in main item table"""
- delete_list = []
- for d in doc.get("packed_items"):
- if [d.parent_item, d.parent_detail_docname] not in parent_items:
- # mark for deletion from doclist
- delete_list.append(d)
-
- if not delete_list:
- return doc
-
- packed_items = doc.get("packed_items")
- doc.set("packed_items", [])
-
- for d in packed_items:
- if d not in delete_list:
- add_item_to_packing_list(doc, d)
-
-def add_item_to_packing_list(doc, packed_item):
- doc.append("packed_items", {
- 'parent_item': packed_item.parent_item,
- 'item_code': packed_item.item_code,
- 'item_name': packed_item.item_name,
- 'uom': packed_item.uom,
- 'qty': packed_item.qty,
- 'rate': packed_item.rate,
- 'conversion_factor': packed_item.conversion_factor,
- 'description': packed_item.description,
- 'warehouse': packed_item.warehouse,
- 'batch_no': packed_item.batch_no,
- 'actual_batch_qty': packed_item.actual_batch_qty,
- 'serial_no': packed_item.serial_no,
- 'target_warehouse': packed_item.target_warehouse,
- 'actual_qty': packed_item.actual_qty,
- 'projected_qty': packed_item.projected_qty,
- 'incoming_rate': packed_item.incoming_rate,
- 'prevdoc_doctype': packed_item.prevdoc_doctype,
- 'parent_detail_docname': packed_item.parent_detail_docname
- })
-
-def update_product_bundle_price(doc, parent_items):
- """Updates the prices of Product Bundles based on the rates of the Items in the bundle."""
-
- if not doc.get('items'):
+ "Make/Update packing list for Product Bundle Item."
+ if doc.get("_action") and doc._action == "update_after_submit":
return
- parent_items_index = 0
- bundle_price = 0
+ parent_items_price, reset = {}, False
+ set_price_from_children = frappe.db.get_single_value("Selling Settings", "editable_bundle_item_rates")
- for bundle_item in doc.get("packed_items"):
- if parent_items[parent_items_index][0] == bundle_item.parent_item:
- bundle_item_rate = bundle_item.rate if bundle_item.rate else 0
- bundle_price += bundle_item.qty * bundle_item_rate
- else:
- update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price)
+ stale_packed_items_table = get_indexed_packed_items_table(doc)
- bundle_item_rate = bundle_item.rate if bundle_item.rate else 0
- bundle_price = bundle_item.qty * bundle_item_rate
- parent_items_index += 1
+ reset = reset_packing_list(doc)
- # for the last product bundle
- if doc.get("packed_items"):
- update_parent_item_price(doc, parent_items[parent_items_index][0], bundle_price)
+ for item_row in doc.get("items"):
+ if frappe.db.exists("Product Bundle", {"new_item_code": item_row.item_code}):
+ for bundle_item in get_product_bundle_items(item_row.item_code):
+ pi_row = add_packed_item_row(
+ doc=doc, packing_item=bundle_item,
+ main_item_row=item_row, packed_items_table=stale_packed_items_table,
+ reset=reset
+ )
+ item_data = get_packed_item_details(bundle_item.item_code, doc.company)
+ update_packed_item_basic_data(item_row, pi_row, bundle_item, item_data)
+ update_packed_item_stock_data(item_row, pi_row, bundle_item, item_data, doc)
+ update_packed_item_price_data(pi_row, item_data, doc)
+ update_packed_item_from_cancelled_doc(item_row, bundle_item, pi_row, doc)
-def update_parent_item_price(doc, parent_item_code, bundle_price):
- parent_item_doc = doc.get('items', {'item_code': parent_item_code})[0]
+ if set_price_from_children: # create/update bundle item wise price dict
+ update_product_bundle_rate(parent_items_price, pi_row)
- current_parent_item_price = parent_item_doc.amount
- if current_parent_item_price != bundle_price:
- parent_item_doc.amount = bundle_price
- update_parent_item_rate(parent_item_doc, bundle_price)
+ if parent_items_price:
+ set_product_bundle_rate_amount(doc, parent_items_price) # set price in bundle item
-def update_parent_item_rate(parent_item_doc, bundle_price):
- parent_item_doc.rate = bundle_price/parent_item_doc.qty
+def get_indexed_packed_items_table(doc):
+ """
+ Create dict from stale packed items table like:
+ {(Parent Item 1, Bundle Item 1, ae4b5678): {...}, (key): {value}}
-@frappe.whitelist()
-def get_items_from_product_bundle(args):
- args = json.loads(args)
- items = []
- bundled_items = get_product_bundle_items(args["item_code"])
- for item in bundled_items:
- args.update({
- "item_code": item.item_code,
- "qty": flt(args["quantity"]) * flt(item.qty)
- })
- items.append(get_item_details(args))
+ Use: to quickly retrieve/check if row existed in table instead of looping n times
+ """
+ indexed_table = {}
+ for packed_item in doc.get("packed_items"):
+ key = (packed_item.parent_item, packed_item.item_code, packed_item.parent_detail_docname)
+ indexed_table[key] = packed_item
- return items
+ return indexed_table
+
+def reset_packing_list(doc):
+ "Conditionally reset the table and return if it was reset or not."
+ reset_table = False
+ doc_before_save = doc.get_doc_before_save()
+
+ if doc_before_save:
+ # reset table if:
+ # 1. items were deleted
+ # 2. if bundle item replaced by another item (same no. of items but different items)
+ # we maintain list to track recurring item rows as well
+ items_before_save = [item.item_code for item in doc_before_save.get("items")]
+ items_after_save = [item.item_code for item in doc.get("items")]
+ reset_table = items_before_save != items_after_save
+ else:
+ # reset: if via Update Items OR
+ # if new mapped doc with packed items set (SO -> DN)
+ # (cannot determine action)
+ reset_table = True
+
+ if reset_table:
+ doc.set("packed_items", [])
+ return reset_table
+
+def get_product_bundle_items(item_code):
+ product_bundle = frappe.qb.DocType("Product Bundle")
+ product_bundle_item = frappe.qb.DocType("Product Bundle Item")
+
+ query = (
+ frappe.qb.from_(product_bundle_item)
+ .join(product_bundle).on(product_bundle_item.parent == product_bundle.name)
+ .select(
+ product_bundle_item.item_code,
+ product_bundle_item.qty,
+ product_bundle_item.uom,
+ product_bundle_item.description
+ ).where(
+ product_bundle.new_item_code == item_code
+ ).orderby(
+ product_bundle_item.idx
+ )
+ )
+ return query.run(as_dict=True)
+
+def add_packed_item_row(doc, packing_item, main_item_row, packed_items_table, reset):
+ """Add and return packed item row.
+ doc: Transaction document
+ packing_item (dict): Packed Item details
+ main_item_row (dict): Items table row corresponding to packed item
+ packed_items_table (dict): Packed Items table before save (indexed)
+ reset (bool): State if table is reset or preserved as is
+ """
+ exists, pi_row = False, {}
+
+ # check if row already exists in packed items table
+ key = (main_item_row.item_code, packing_item.item_code, main_item_row.name)
+ if packed_items_table.get(key):
+ pi_row, exists = packed_items_table.get(key), True
+
+ if not exists:
+ pi_row = doc.append('packed_items', {})
+ elif reset: # add row if row exists but table is reset
+ pi_row.idx, pi_row.name = None, None
+ pi_row = doc.append('packed_items', pi_row)
+
+ return pi_row
+
+def get_packed_item_details(item_code, company):
+ item = frappe.qb.DocType("Item")
+ item_default = frappe.qb.DocType("Item Default")
+ query = (
+ frappe.qb.from_(item)
+ .left_join(item_default)
+ .on(
+ (item_default.parent == item.name)
+ & (item_default.company == company)
+ ).select(
+ item.item_name, item.is_stock_item,
+ item.description, item.stock_uom,
+ item.valuation_rate,
+ item_default.default_warehouse
+ ).where(
+ item.name == item_code
+ )
+ )
+ return query.run(as_dict=True)[0]
+
+def update_packed_item_basic_data(main_item_row, pi_row, packing_item, item_data):
+ pi_row.parent_item = main_item_row.item_code
+ pi_row.parent_detail_docname = main_item_row.name
+ pi_row.item_code = packing_item.item_code
+ pi_row.item_name = item_data.item_name
+ pi_row.uom = item_data.stock_uom
+ pi_row.qty = flt(packing_item.qty) * flt(main_item_row.stock_qty)
+ pi_row.conversion_factor = main_item_row.conversion_factor
+
+ if not pi_row.description:
+ pi_row.description = packing_item.get("description")
+
+def update_packed_item_stock_data(main_item_row, pi_row, packing_item, item_data, doc):
+ # TODO batch_no, actual_batch_qty, incoming_rate
+ if not pi_row.warehouse and not doc.amended_from:
+ fetch_warehouse = (doc.get('is_pos') or item_data.is_stock_item or not item_data.default_warehouse)
+ pi_row.warehouse = (main_item_row.warehouse if (fetch_warehouse and main_item_row.warehouse)
+ else item_data.default_warehouse)
+
+ if not pi_row.target_warehouse:
+ pi_row.target_warehouse = main_item_row.get("target_warehouse")
+
+ bin = get_packed_item_bin_qty(packing_item.item_code, pi_row.warehouse)
+ pi_row.actual_qty = flt(bin.get("actual_qty"))
+ pi_row.projected_qty = flt(bin.get("projected_qty"))
+
+def update_packed_item_price_data(pi_row, item_data, doc):
+ "Set price as per price list or from the Item master."
+ if pi_row.rate:
+ return
+
+ item_doc = frappe.get_cached_doc("Item", pi_row.item_code)
+ row_data = pi_row.as_dict().copy()
+ row_data.update({
+ "company": doc.get("company"),
+ "price_list": doc.get("selling_price_list"),
+ "currency": doc.get("currency")
+ })
+ rate = get_price_list_rate(row_data, item_doc).get("price_list_rate")
+
+ pi_row.rate = rate or item_data.get("valuation_rate") or 0.0
+
+def update_packed_item_from_cancelled_doc(main_item_row, packing_item, pi_row, doc):
+ "Update packed item row details from cancelled doc into amended doc."
+ prev_doc_packed_items_map = None
+ if doc.amended_from:
+ prev_doc_packed_items_map = get_cancelled_doc_packed_item_details(doc.packed_items)
+
+ if prev_doc_packed_items_map and prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code)):
+ prev_doc_row = prev_doc_packed_items_map.get((packing_item.item_code, main_item_row.item_code))
+ pi_row.batch_no = prev_doc_row[0].batch_no
+ pi_row.serial_no = prev_doc_row[0].serial_no
+ pi_row.warehouse = prev_doc_row[0].warehouse
+
+def get_packed_item_bin_qty(item, warehouse):
+ bin_data = frappe.db.get_values(
+ "Bin",
+ fieldname=["actual_qty", "projected_qty"],
+ filters={"item_code": item, "warehouse": warehouse},
+ as_dict=True
+ )
+
+ return bin_data[0] if bin_data else {}
+
+def get_cancelled_doc_packed_item_details(old_packed_items):
+ prev_doc_packed_items_map = {}
+ for items in old_packed_items:
+ prev_doc_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict())
+ return prev_doc_packed_items_map
+
+def update_product_bundle_rate(parent_items_price, pi_row):
+ """
+ Update the price dict of Product Bundles based on the rates of the Items in the bundle.
+
+ Stucture:
+ {(Bundle Item 1, ae56fgji): 150.0, (Bundle Item 2, bc78fkjo): 200.0}
+ """
+ key = (pi_row.parent_item, pi_row.parent_detail_docname)
+ rate = parent_items_price.get(key)
+ if not rate:
+ parent_items_price[key] = 0.0
+
+ parent_items_price[key] += flt(pi_row.rate)
+
+def set_product_bundle_rate_amount(doc, parent_items_price):
+ "Set cumulative rate and amount in bundle item."
+ for item in doc.get("items"):
+ bundle_rate = parent_items_price.get((item.item_code, item.name))
+ if bundle_rate and bundle_rate != item.rate:
+ item.rate = bundle_rate
+ item.amount = flt(bundle_rate * item.qty)
def on_doctype_update():
frappe.db.add_index("Packed Item", ["item_code", "warehouse"])
-def get_old_packed_item_details(old_packed_items):
- old_packed_items_map = {}
- for items in old_packed_items:
- old_packed_items_map.setdefault((items.item_code ,items.parent_item), []).append(items.as_dict())
- return old_packed_items_map
+
+@frappe.whitelist()
+def get_items_from_product_bundle(row):
+ row, items = json.loads(row), []
+
+ bundled_items = get_product_bundle_items(row["item_code"])
+ for item in bundled_items:
+ row.update({
+ "item_code": item.item_code,
+ "qty": flt(row["quantity"]) * flt(item.qty)
+ })
+ items.append(get_item_details(row))
+
+ return items
diff --git a/erpnext/stock/doctype/packed_item/test_packed_item.py b/erpnext/stock/doctype/packed_item/test_packed_item.py
new file mode 100644
index 0000000000..2521ac9fe7
--- /dev/null
+++ b/erpnext/stock/doctype/packed_item/test_packed_item.py
@@ -0,0 +1,158 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+from frappe.utils import add_to_date, nowdate
+
+from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.tests.utils import ERPNextTestCase, change_settings
+
+
+class TestPackedItem(ERPNextTestCase):
+ "Test impact on Packed Items table in various scenarios."
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ cls.bundle = "_Test Product Bundle X"
+ cls.bundle_items = ["_Test Bundle Item 1", "_Test Bundle Item 2"]
+ make_item(cls.bundle, {"is_stock_item": 0})
+ for item in cls.bundle_items:
+ make_item(item, {"is_stock_item": 1})
+
+ make_item("_Test Normal Stock Item", {"is_stock_item": 1})
+
+ make_product_bundle(cls.bundle, cls.bundle_items, qty=2)
+
+ def test_adding_bundle_item(self):
+ "Test impact on packed items if bundle item row is added."
+ so = make_sales_order(item_code = self.bundle, qty=1,
+ do_not_submit=True)
+
+ self.assertEqual(so.items[0].qty, 1)
+ self.assertEqual(len(so.packed_items), 2)
+ self.assertEqual(so.packed_items[0].item_code, self.bundle_items[0])
+ self.assertEqual(so.packed_items[0].qty, 2)
+
+ def test_updating_bundle_item(self):
+ "Test impact on packed items if bundle item row is updated."
+ so = make_sales_order(item_code=self.bundle, qty=1, do_not_submit=True)
+
+ so.items[0].qty = 2 # change qty
+ so.save()
+
+ self.assertEqual(so.packed_items[0].qty, 4)
+ self.assertEqual(so.packed_items[1].qty, 4)
+
+ # change item code to non bundle item
+ so.items[0].item_code = "_Test Normal Stock Item"
+ so.save()
+
+ self.assertEqual(len(so.packed_items), 0)
+
+ def test_recurring_bundle_item(self):
+ "Test impact on packed items if same bundle item is added and removed."
+ so_items = []
+ for qty in [2, 4, 6, 8]:
+ so_items.append({
+ "item_code": self.bundle,
+ "qty": qty,
+ "rate": 400,
+ "warehouse": "_Test Warehouse - _TC"
+ })
+
+ # create SO with recurring bundle item
+ so = make_sales_order(item_list=so_items, do_not_submit=True)
+
+ # check alternate rows for qty
+ self.assertEqual(len(so.packed_items), 8)
+ self.assertEqual(so.packed_items[1].item_code, self.bundle_items[1])
+ self.assertEqual(so.packed_items[1].qty, 4)
+ self.assertEqual(so.packed_items[3].qty, 8)
+ self.assertEqual(so.packed_items[5].qty, 12)
+ self.assertEqual(so.packed_items[7].qty, 16)
+
+ # delete intermediate row (2nd)
+ del so.items[1]
+ so.save()
+
+ # check alternate rows for qty
+ self.assertEqual(len(so.packed_items), 6)
+ self.assertEqual(so.packed_items[1].qty, 4)
+ self.assertEqual(so.packed_items[3].qty, 12)
+ self.assertEqual(so.packed_items[5].qty, 16)
+
+ # delete last row
+ del so.items[2]
+ so.save()
+
+ # check alternate rows for qty
+ self.assertEqual(len(so.packed_items), 4)
+ self.assertEqual(so.packed_items[1].qty, 4)
+ self.assertEqual(so.packed_items[3].qty, 12)
+
+ @change_settings("Selling Settings", {"editable_bundle_item_rates": 1})
+ def test_bundle_item_cumulative_price(self):
+ "Test if Bundle Item rate is cumulative from packed items."
+ so = make_sales_order(item_code=self.bundle, qty=2, do_not_submit=True)
+
+ so.packed_items[0].rate = 150
+ so.packed_items[1].rate = 200
+ so.save()
+
+ self.assertEqual(so.items[0].rate, 350)
+ self.assertEqual(so.items[0].amount, 700)
+
+ def test_newly_mapped_doc_packed_items(self):
+ "Test impact on packed items in newly mapped DN from SO."
+ so_items = []
+ for qty in [2, 4]:
+ so_items.append({
+ "item_code": self.bundle,
+ "qty": qty,
+ "rate": 400,
+ "warehouse": "_Test Warehouse - _TC"
+ })
+
+ # create SO with recurring bundle item
+ so = make_sales_order(item_list=so_items)
+
+ dn = make_delivery_note(so.name)
+ dn.items[1].qty = 3 # change second row qty for inserting doc
+ dn.save()
+
+ self.assertEqual(len(dn.packed_items), 4)
+ self.assertEqual(dn.packed_items[2].qty, 6)
+ self.assertEqual(dn.packed_items[3].qty, 6)
+
+ def test_reposting_packed_items(self):
+ warehouse = "Stores - TCP1"
+ company = "_Test Company with perpetual inventory"
+
+ today = nowdate()
+ yesterday = add_to_date(today, days=-1, as_string=True)
+
+ for item in self.bundle_items:
+ make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=100, posting_date=today)
+
+ so = make_sales_order(item_code = self.bundle, qty=1, company=company, warehouse=warehouse)
+
+ dn = make_delivery_note(so.name)
+ dn.save()
+ dn.submit()
+
+ gles = get_gl_entries(dn.doctype, dn.name)
+ credit_before_repost = sum(gle.credit for gle in gles)
+
+ # backdated stock entry
+ for item in self.bundle_items:
+ make_stock_entry(item_code=item, to_warehouse=warehouse, qty=10, rate=200, posting_date=yesterday)
+
+ # assert correct reposting
+ gles = get_gl_entries(dn.doctype, dn.name)
+ credit_after_reposting = sum(gle.credit for gle in gles)
+ self.assertNotEqual(credit_before_repost, credit_after_reposting)
+ self.assertAlmostEqual(credit_after_reposting, 2 * credit_before_repost)
diff --git a/erpnext/stock/doctype/price_list/price_list.py b/erpnext/stock/doctype/price_list/price_list.py
index 74b823ada3..8a3172e9e2 100644
--- a/erpnext/stock/doctype/price_list/price_list.py
+++ b/erpnext/stock/doctype/price_list/price_list.py
@@ -36,14 +36,14 @@ class PriceList(Document):
(self.currency, cint(self.buying), cint(self.selling), self.name))
def check_impact_on_shopping_cart(self):
- "Check if Price List currency change impacts Shopping Cart."
- from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
+ "Check if Price List currency change impacts E Commerce Cart."
+ from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
validate_cart_settings,
)
doc_before_save = self.get_doc_before_save()
currency_changed = self.currency != doc_before_save.currency
- affects_cart = self.name == frappe.get_cached_value("Shopping Cart Settings", None, "price_list")
+ affects_cart = self.name == frappe.get_cached_value("E Commerce Settings", None, "price_list")
if currency_changed and affects_cart:
validate_cart_settings()
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index 1257057ea3..33e40c85f1 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -286,10 +286,7 @@ class PurchaseReceipt(BuyingController):
if warehouse_account.get(d.warehouse):
stock_value_diff = frappe.db.get_value("Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": self.name,
- "voucher_detail_no": d.name, "warehouse": d.warehouse}, "stock_value_difference")
-
- if not stock_value_diff:
- continue
+ "voucher_detail_no": d.name, "warehouse": d.warehouse, "is_cancelled": 0}, "stock_value_difference")
warehouse_account_name = warehouse_account[d.warehouse]["account"]
warehouse_account_currency = warehouse_account[d.warehouse]["account_currency"]
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
index 77711de93f..4029f0c127 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt_list.js
@@ -13,5 +13,13 @@ frappe.listview_settings['Purchase Receipt'] = {
} else if (flt(doc.grand_total) === 0 || flt(doc.per_billed, 2) === 100) {
return [__("Completed"), "green", "per_billed,=,100"];
}
+ },
+
+ onload: function(listview) {
+
+ listview.page.add_action_item(__("Purchase Invoice"), ()=>{
+ erpnext.bulk_transaction_processing.create(listview, "Purchase Receipt", "Purchase Invoice");
+ });
}
+
};
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index b87d9205e0..5ab7929a2a 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -4,6 +4,7 @@
import json
import unittest
+from collections import defaultdict
import frappe
from frappe.utils import add_days, cint, cstr, flt, today
@@ -16,7 +17,7 @@ from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchas
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
-from erpnext.tests.utils import ERPNextTestCase
+from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestPurchaseReceipt(ERPNextTestCase):
@@ -1387,6 +1388,36 @@ class TestPurchaseReceipt(ERPNextTestCase):
automatically_fetch_payment_terms(enable=0)
+ @change_settings("Stock Settings", {"allow_negative_stock": 1})
+ def test_neg_to_positive(self):
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ item_code = "_TestNegToPosItem"
+ warehouse = "Stores - TCP1"
+ company = "_Test Company with perpetual inventory"
+ account = "Stock Received But Not Billed - TCP1"
+
+ make_item(item_code)
+ se = make_stock_entry(item_code=item_code, from_warehouse=warehouse, qty=50, do_not_save=True, rate=0)
+ se.items[0].allow_zero_valuation_rate = 1
+ se.save()
+ se.submit()
+
+ pr = make_purchase_receipt(
+ qty=50,
+ rate=1,
+ item_code=item_code,
+ warehouse=warehouse,
+ get_taxes_and_charges=True,
+ company=company,
+ )
+ gles = get_gl_entries(pr.doctype, pr.name)
+
+ for gle in gles:
+ if gle.account == account:
+ self.assertEqual(gle.credit, 50)
+
+
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/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 01c5e3e4e2..977d470995 100644
--- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
+++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
@@ -13,7 +13,7 @@ from erpnext.accounts.utils import (
check_if_stock_and_account_balance_synced,
update_gl_entries_after,
)
-from erpnext.stock.stock_ledger import repost_future_sle
+from erpnext.stock.stock_ledger import get_items_to_be_repost, repost_future_sle
class RepostItemValuation(Document):
@@ -138,13 +138,20 @@ def repost_gl_entries(doc):
if doc.based_on == 'Transaction':
ref_doc = frappe.get_doc(doc.voucher_type, doc.voucher_no)
- items, warehouses = ref_doc.get_items_and_warehouses()
+ doc_items, doc_warehouses = ref_doc.get_items_and_warehouses()
+
+ sles = get_items_to_be_repost(doc.voucher_type, doc.voucher_no)
+ sle_items = [sle.item_code for sle in sles]
+ sle_warehouse = [sle.warehouse for sle in sles]
+
+ items = list(set(doc_items).union(set(sle_items)))
+ warehouses = list(set(doc_warehouses).union(set(sle_warehouse)))
else:
items = [doc.item_code]
warehouses = [doc.warehouse]
update_gl_entries_after(doc.posting_date, doc.posting_time,
- warehouses, items, company=doc.company)
+ for_warehouses=warehouses, for_items=items, company=doc.company)
def notify_error_to_stock_managers(doc, traceback):
recipients = get_users_with_role("Stock Manager")
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.json b/erpnext/stock/doctype/stock_entry/stock_entry.json
index 2f37778896..c38dfaa1c8 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.json
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.json
@@ -8,7 +8,6 @@
"engine": "InnoDB",
"field_order": [
"items_section",
- "title",
"naming_series",
"stock_entry_type",
"outgoing_stock_entry",
@@ -83,14 +82,6 @@
"fieldtype": "Section Break",
"oldfieldtype": "Section Break"
},
- {
- "fieldname": "title",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Title",
- "no_copy": 1,
- "print_hide": 1
- },
{
"fieldname": "naming_series",
"fieldtype": "Select",
@@ -353,9 +344,9 @@
},
{
"fieldname": "scan_barcode",
- "options": "Barcode",
"fieldtype": "Data",
- "label": "Scan Barcode"
+ "label": "Scan Barcode",
+ "options": "Barcode"
},
{
"allow_bulk_edit": 1,
@@ -628,10 +619,11 @@
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-08-20 19:19:31.514846",
+ "modified": "2022-02-07 12:55:14.614077",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Entry",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -698,6 +690,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
- "title_field": "title",
+ "states": [],
+ "title_field": "stock_entry_type",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index c51c9bc5f4..782fcf04a5 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -76,7 +76,6 @@ class StockEntry(StockController):
self.validate_posting_time()
self.validate_purpose()
- self.set_title()
self.validate_item()
self.validate_customer_provided_item()
self.validate_qty()
@@ -1116,7 +1115,7 @@ class StockEntry(StockController):
self.set_actual_qty()
self.update_items_for_process_loss()
self.validate_customer_provided_item()
- self.calculate_rate_and_amount()
+ self.calculate_rate_and_amount(raise_error_if_no_rate=False)
def set_scrap_items(self):
if self.purpose != "Send to Subcontractor" and self.purpose in ["Manufacture", "Repack"]:
@@ -1835,14 +1834,6 @@ class StockEntry(StockController):
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos)))
- def set_title(self):
- if frappe.flags.in_import and self.title:
- # Allow updating title during data import/update
- return
-
- self.title = self.purpose
-
-
@frappe.whitelist()
def move_sample_to_retention_warehouse(company, items):
if isinstance(items, str):
diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
index 3402972bb8..a882a61e5a 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.json
@@ -18,7 +18,6 @@
"items",
"section_break_9",
"expense_account",
- "reconciliation_json",
"column_break_13",
"difference_amount",
"amended_from",
@@ -111,15 +110,6 @@
"label": "Cost Center",
"options": "Cost Center"
},
- {
- "fieldname": "reconciliation_json",
- "fieldtype": "Long Text",
- "hidden": 1,
- "label": "Reconciliation JSON",
- "no_copy": 1,
- "print_hide": 1,
- "read_only": 1
- },
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
@@ -155,7 +145,7 @@
"idx": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-11-30 01:33:51.437194",
+ "modified": "2022-02-06 14:28:19.043905",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Reconciliation",
@@ -178,5 +168,6 @@
"search_fields": "posting_date",
"show_name_in_global_search": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 428370cc75..86af0a0cf3 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -25,8 +25,8 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestStockReconciliation(ERPNextTestCase):
@classmethod
def setUpClass(cls):
- super().setUpClass()
create_batch_or_serial_no_items()
+ super().setUpClass()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
def tearDown(self):
diff --git a/erpnext/stock/doctype/stock_settings/stock_settings.json b/erpnext/stock/doctype/stock_settings/stock_settings.json
index 33d9a6ce41..ec7fb0f4a2 100644
--- a/erpnext/stock/doctype/stock_settings/stock_settings.json
+++ b/erpnext/stock/doctype/stock_settings/stock_settings.json
@@ -5,35 +5,41 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
+ "defaults_tab",
"item_defaults_section",
"item_naming_by",
"item_group",
"stock_uom",
- "default_warehouse",
"column_break_4",
- "valuation_method",
+ "default_warehouse",
"sample_retention_warehouse",
- "use_naming_series",
- "naming_series_prefix",
+ "valuation_method",
+ "price_list_defaults_section",
+ "auto_insert_price_list_rate_if_missing",
+ "column_break_12",
+ "update_existing_price_list_rate",
+ "stock_validations_tab",
"section_break_9",
"over_delivery_receipt_allowance",
- "role_allowed_to_over_deliver_receive",
"mr_qty_allowance",
- "column_break_12",
- "auto_insert_price_list_rate_if_missing",
- "update_existing_price_list_rate",
+ "column_break_121",
+ "role_allowed_to_over_deliver_receive",
"allow_negative_stock",
"show_barcode_field",
"clean_description_html",
"quality_inspection_settings_section",
"action_if_quality_inspection_is_not_submitted",
- "column_break_21",
+ "column_break_23",
"action_if_quality_inspection_is_rejected",
+ "serial_and_batch_item_settings_tab",
"section_break_7",
"automatically_set_serial_nos_based_on_fifo",
"set_qty_in_transactions_based_on_serial_no_input",
"column_break_10",
"disable_serial_no_and_batch_selector",
+ "use_naming_series",
+ "naming_series_prefix",
+ "stock_planning_tab",
"auto_material_request",
"auto_indent",
"column_break_27",
@@ -42,6 +48,7 @@
"allow_from_dn",
"column_break_31",
"allow_from_pr",
+ "stock_closing_tab",
"control_historical_stock_transactions_section",
"stock_frozen_upto",
"stock_frozen_upto_days",
@@ -92,7 +99,7 @@
"fieldname": "valuation_method",
"fieldtype": "Select",
"label": "Default Valuation Method",
- "options": "FIFO\nMoving Average"
+ "options": "FIFO\nMoving Average\nLIFO"
},
{
"description": "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.",
@@ -122,7 +129,7 @@
{
"fieldname": "section_break_7",
"fieldtype": "Section Break",
- "label": "Serialised and Batch Setting"
+ "label": "Serial & Batch Item Settings"
},
{
"default": "0",
@@ -275,10 +282,6 @@
"fieldtype": "Section Break",
"label": "Quality Inspection Settings"
},
- {
- "fieldname": "column_break_21",
- "fieldtype": "Column Break"
- },
{
"default": "Stop",
"fieldname": "action_if_quality_inspection_is_rejected",
@@ -298,6 +301,44 @@
"fieldname": "update_existing_price_list_rate",
"fieldtype": "Check",
"label": "Update Existing Price List Rate"
+ },
+ {
+ "fieldname": "defaults_tab",
+ "fieldtype": "Tab Break",
+ "label": "Defaults"
+ },
+ {
+ "fieldname": "stock_validations_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Validations"
+ },
+ {
+ "fieldname": "stock_planning_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Planning"
+ },
+ {
+ "fieldname": "stock_closing_tab",
+ "fieldtype": "Tab Break",
+ "label": "Stock Closing"
+ },
+ {
+ "fieldname": "serial_and_batch_item_settings_tab",
+ "fieldtype": "Tab Break",
+ "label": "Serial & Batch Item"
+ },
+ {
+ "fieldname": "column_break_23",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "price_list_defaults_section",
+ "fieldtype": "Section Break",
+ "label": "Price List Defaults"
+ },
+ {
+ "fieldname": "column_break_121",
+ "fieldtype": "Column Break"
}
],
"icon": "icon-cog",
@@ -305,7 +346,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-11-06 19:40:02.183592",
+ "modified": "2022-02-05 15:33:43.692736",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",
@@ -324,5 +365,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json b/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json
deleted file mode 100644
index 5ee316786c..0000000000
--- a/erpnext/stock/onboarding_slide/add_a_few_products_you_buy_or_sell/add_a_few_products_you_buy_or_sell.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "add_more_button": 1,
- "app": "ERPNext",
- "creation": "2019-11-15 14:41:12.007359",
- "docstatus": 0,
- "doctype": "Onboarding Slide",
- "domains": [],
- "help_links": [],
- "idx": 0,
- "image_src": "",
- "is_completed": 0,
- "max_count": 3,
- "modified": "2019-12-09 17:54:09.602885",
- "modified_by": "Administrator",
- "name": "Add A Few Products You Buy Or Sell",
- "owner": "Administrator",
- "ref_doctype": "Item",
- "slide_desc": "",
- "slide_fields": [
- {
- "align": "",
- "fieldname": "item",
- "fieldtype": "Data",
- "label": "Item",
- "placeholder": "Product Name",
- "reqd": 1
- },
- {
- "align": "",
- "fieldname": "item_price",
- "fieldtype": "Currency",
- "label": "Item Price",
- "reqd": 1
- },
- {
- "align": "",
- "fieldtype": "Column Break",
- "reqd": 0
- },
- {
- "align": "",
- "fieldname": "uom",
- "fieldtype": "Link",
- "label": "UOM",
- "options": "UOM",
- "reqd": 1
- }
- ],
- "slide_order": 30,
- "slide_title": "Add A Few Products You Buy Or Sell",
- "slide_type": "Create"
-}
\ No newline at end of file
diff --git a/erpnext/stock/report/stock_ledger/stock_ledger.py b/erpnext/stock/report/stock_ledger/stock_ledger.py
index c60a6ca56e..81fa0458f2 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -104,6 +104,7 @@ def get_columns():
{"label": _("Incoming Rate"), "fieldname": "incoming_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
{"label": _("Valuation Rate"), "fieldname": "valuation_rate", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency", "convertible": "rate"},
{"label": _("Balance Value"), "fieldname": "stock_value", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
+ {"label": _("Value Change"), "fieldname": "stock_value_difference", "fieldtype": "Currency", "width": 110, "options": "Company:company:default_currency"},
{"label": _("Voucher Type"), "fieldname": "voucher_type", "width": 110},
{"label": _("Voucher #"), "fieldname": "voucher_no", "fieldtype": "Dynamic Link", "options": "voucher_type", "width": 100},
{"label": _("Batch"), "fieldname": "batch_no", "fieldtype": "Link", "options": "Batch", "width": 100},
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index 48753b0edd..cb35bf75d1 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -167,7 +167,7 @@ def get_columns():
{
"fieldname": "stock_queue",
"fieldtype": "Data",
- "label": "FIFO Queue",
+ "label": "FIFO/LIFO Queue",
},
{
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 0a7ab4009c..41c4002e3f 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -16,7 +16,7 @@ from erpnext.stock.utils import (
get_or_make_bin,
get_valuation_method,
)
-from erpnext.stock.valuation import FIFOValuation
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class NegativeStockError(frappe.ValidationError): pass
@@ -461,7 +461,7 @@ class update_entries_after(object):
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else:
- self.update_fifo_values(sle)
+ self.update_queue_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision
@@ -701,14 +701,18 @@ class update_entries_after(object):
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
- def update_fifo_values(self, sle):
+ def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate)
- fifo_queue = FIFOValuation(self.wh_data.stock_queue)
+ if self.valuation_method == "LIFO":
+ stock_queue = LIFOValuation(self.wh_data.stock_queue)
+ else:
+ stock_queue = FIFOValuation(self.wh_data.stock_queue)
+
if actual_qty > 0:
- fifo_queue.add_stock(qty=actual_qty, rate=incoming_rate)
+ stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else:
def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
@@ -719,11 +723,11 @@ class update_entries_after(object):
else:
return 0.0
- fifo_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
+ stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
- stock_qty, stock_value = fifo_queue.get_total_stock_and_value()
+ stock_qty, stock_value = stock_queue.get_total_stock_and_value()
- self.wh_data.stock_queue = fifo_queue.get_state()
+ self.wh_data.stock_queue = stock_queue.state
self.wh_data.stock_value = stock_value
if stock_qty:
self.wh_data.valuation_rate = stock_value / stock_qty
diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py
index 85788bac7f..648d4406ca 100644
--- a/erpnext/stock/tests/test_valuation.py
+++ b/erpnext/stock/tests/test_valuation.py
@@ -1,16 +1,21 @@
+import json
import unittest
+import frappe
from hypothesis import given
from hypothesis import strategies as st
-from erpnext.stock.valuation import FIFOValuation, _round_off_if_near_zero
+from erpnext.stock.doctype.item.test_item import make_item
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
+from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6)
value_gen = st.floats(min_value=1, max_value=1e6)
stock_queue_generator = st.lists(st.tuples(qty_gen, value_gen), min_size=10)
-class TestFifoValuation(unittest.TestCase):
+class TestFIFOValuation(unittest.TestCase):
def setUp(self):
self.queue = FIFOValuation([])
@@ -164,3 +169,184 @@ class TestFifoValuation(unittest.TestCase):
total_value -= sum(q * r for q, r in consumed)
self.assertTotalQty(total_qty)
self.assertTotalValue(total_value)
+
+
+class TestLIFOValuation(unittest.TestCase):
+
+ def setUp(self):
+ self.stack = LIFOValuation([])
+
+ def tearDown(self):
+ qty, value = self.stack.get_total_stock_and_value()
+ self.assertTotalQty(qty)
+ self.assertTotalValue(value)
+
+ def assertTotalQty(self, qty):
+ self.assertAlmostEqual(sum(q for q, _ in self.stack), qty, msg=f"stack: {self.stack}", places=4)
+
+ def assertTotalValue(self, value):
+ self.assertAlmostEqual(sum(q * r for q, r in self.stack), value, msg=f"stack: {self.stack}", places=2)
+
+ def test_simple_addition(self):
+ self.stack.add_stock(1, 10)
+ self.assertTotalQty(1)
+
+ def test_merge_new_stock(self):
+ self.stack.add_stock(1, 10)
+ self.stack.add_stock(1, 10)
+ self.assertEqual(self.stack, [[2, 10]])
+
+ def test_simple_removal(self):
+ self.stack.add_stock(1, 10)
+ self.stack.remove_stock(1)
+ self.assertTotalQty(0)
+
+ def test_adding_negative_stock_keeps_rate(self):
+ self.stack = LIFOValuation([[-5.0, 100]])
+ self.stack.add_stock(1, 10)
+ self.assertEqual(self.stack, [[-4, 100]])
+
+ def test_adding_negative_stock_updates_rate(self):
+ self.stack = LIFOValuation([[-5.0, 100]])
+ self.stack.add_stock(6, 10)
+ self.assertEqual(self.stack, [[1, 10]])
+
+ def test_rounding_off(self):
+ self.stack.add_stock(1.0, 1.0)
+ self.stack.remove_stock(1.0 - 1e-9)
+ self.assertTotalQty(0)
+
+ def test_lifo_consumption(self):
+ self.stack.add_stock(10, 10)
+ self.stack.add_stock(10, 20)
+ consumed = self.stack.remove_stock(15)
+ self.assertEqual(consumed, [[10, 20], [5, 10]])
+ self.assertTotalQty(5)
+
+ def test_lifo_consumption_going_negative(self):
+ self.stack.add_stock(10, 10)
+ self.stack.add_stock(10, 20)
+ consumed = self.stack.remove_stock(25)
+ self.assertEqual(consumed, [[10, 20], [10, 10], [5, 10]])
+ self.assertTotalQty(-5)
+
+ def test_lifo_consumption_multiple(self):
+ self.stack.add_stock(1, 1)
+ self.stack.add_stock(2, 2)
+ consumed = self.stack.remove_stock(1)
+ self.assertEqual(consumed, [[1, 2]])
+
+ self.stack.add_stock(3, 3)
+ consumed = self.stack.remove_stock(4)
+ self.assertEqual(consumed, [[3, 3], [1, 2]])
+
+ self.stack.add_stock(4, 4)
+ consumed = self.stack.remove_stock(5)
+ self.assertEqual(consumed, [[4, 4], [1, 1]])
+
+ self.stack.add_stock(5, 5)
+ consumed = self.stack.remove_stock(5)
+ self.assertEqual(consumed, [[5, 5]])
+
+
+ @given(stock_queue_generator)
+ def test_lifo_qty_hypothesis(self, stock_stack):
+ self.stack = LIFOValuation([])
+ total_qty = 0
+
+ for qty, rate in stock_stack:
+ if qty == 0:
+ continue
+ if qty > 0:
+ self.stack.add_stock(qty, rate)
+ total_qty += qty
+ else:
+ qty = abs(qty)
+ consumed = self.stack.remove_stock(qty)
+ self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
+ total_qty -= qty
+ self.assertTotalQty(total_qty)
+
+ @given(stock_queue_generator)
+ def test_lifo_qty_value_nonneg_hypothesis(self, stock_stack):
+ self.stack = LIFOValuation([])
+ total_qty = 0.0
+ total_value = 0.0
+
+ for qty, rate in stock_stack:
+ # don't allow negative stock
+ if qty == 0 or total_qty + qty < 0 or abs(qty) < 0.1:
+ continue
+ if qty > 0:
+ self.stack.add_stock(qty, rate)
+ total_qty += qty
+ total_value += qty * rate
+ else:
+ qty = abs(qty)
+ consumed = self.stack.remove_stock(qty)
+ self.assertAlmostEqual(qty, sum(q for q, _ in consumed), msg=f"incorrect consumption {consumed}")
+ total_qty -= qty
+ total_value -= sum(q * r for q, r in consumed)
+ self.assertTotalQty(total_qty)
+ self.assertTotalValue(total_value)
+
+class TestLIFOValuationSLE(ERPNextTestCase):
+ ITEM_CODE = "_Test LIFO item"
+ WAREHOUSE = "_Test Warehouse - _TC"
+
+ @classmethod
+ def setUpClass(cls) -> None:
+ super().setUpClass()
+ make_item(cls.ITEM_CODE, {"valuation_method": "LIFO"})
+
+ def _make_stock_entry(self, qty, rate=None):
+ kwargs = {
+ "item_code": self.ITEM_CODE,
+ "from_warehouse" if qty < 0 else "to_warehouse": self.WAREHOUSE,
+ "rate": rate,
+ "qty": abs(qty),
+ }
+ return make_stock_entry(**kwargs)
+
+ def assertStockQueue(self, se, expected_queue):
+ sle_name = frappe.db.get_value("Stock Ledger Entry", {"voucher_no": se.name, "is_cancelled": 0, "voucher_type": "Stock Entry"})
+ sle = frappe.get_doc("Stock Ledger Entry", sle_name)
+
+ stock_queue = json.loads(sle.stock_queue)
+
+ total_qty, total_value = LIFOValuation(stock_queue).get_total_stock_and_value()
+ self.assertEqual(sle.qty_after_transaction, total_qty)
+ self.assertEqual(sle.stock_value, total_value)
+
+ if total_qty > 0:
+ self.assertEqual(stock_queue, expected_queue)
+
+
+ def test_lifo_values(self):
+
+ in1 = self._make_stock_entry(1, 1)
+ self.assertStockQueue(in1, [[1, 1]])
+
+ in2 = self._make_stock_entry(2, 2)
+ self.assertStockQueue(in2, [[1, 1], [2, 2]])
+
+ out1 = self._make_stock_entry(-1)
+ self.assertStockQueue(out1, [[1, 1], [1, 2]])
+
+ in3 = self._make_stock_entry(3, 3)
+ self.assertStockQueue(in3, [[1, 1], [1, 2], [3, 3]])
+
+ out2 = self._make_stock_entry(-4)
+ self.assertStockQueue(out2, [[1, 1]])
+
+ in4 = self._make_stock_entry(4, 4)
+ self.assertStockQueue(in4, [[1, 1], [4,4]])
+
+ out3 = self._make_stock_entry(-5)
+ self.assertStockQueue(out3, [])
+
+ in5 = self._make_stock_entry(5, 5)
+ self.assertStockQueue(in5, [[5, 5]])
+
+ out5 = self._make_stock_entry(-5)
+ self.assertStockQueue(out5, [])
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index 7c63c17ad0..c75c737fc5 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -9,6 +9,7 @@ from frappe import _
from frappe.utils import cstr, flt, get_link_to_form, nowdate, nowtime
import erpnext
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation
class InvalidWarehouseCompany(frappe.ValidationError): pass
@@ -228,10 +229,10 @@ def get_incoming_rate(args, raise_error_if_no_rate=True):
else:
valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args)
- if valuation_method == 'FIFO':
+ if valuation_method in ('FIFO', 'LIFO'):
if previous_sle:
previous_stock_queue = json.loads(previous_sle.get('stock_queue', '[]') or '[]')
- in_rate = get_fifo_rate(previous_stock_queue, args.get("qty") or 0) if previous_stock_queue else 0
+ in_rate = _get_fifo_lifo_rate(previous_stock_queue, args.get("qty") or 0, valuation_method) if previous_stock_queue else 0
elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0
@@ -261,29 +262,25 @@ def get_valuation_method(item_code):
def get_fifo_rate(previous_stock_queue, qty):
"""get FIFO (average) Rate from Queue"""
- if flt(qty) >= 0:
- total = sum(f[0] for f in previous_stock_queue)
- return sum(flt(f[0]) * flt(f[1]) for f in previous_stock_queue) / flt(total) if total else 0.0
- else:
- available_qty_for_outgoing, outgoing_cost = 0, 0
- qty_to_pop = abs(flt(qty))
- while qty_to_pop and previous_stock_queue:
- batch = previous_stock_queue[0]
- if 0 < batch[0] <= qty_to_pop:
- # if batch qty > 0
- # not enough or exactly same qty in current batch, clear batch
- available_qty_for_outgoing += flt(batch[0])
- outgoing_cost += flt(batch[0]) * flt(batch[1])
- qty_to_pop -= batch[0]
- previous_stock_queue.pop(0)
- else:
- # all from current batch
- available_qty_for_outgoing += flt(qty_to_pop)
- outgoing_cost += flt(qty_to_pop) * flt(batch[1])
- batch[0] -= qty_to_pop
- qty_to_pop = 0
+ return _get_fifo_lifo_rate(previous_stock_queue, qty, "FIFO")
- return outgoing_cost / available_qty_for_outgoing
+def get_lifo_rate(previous_stock_queue, qty):
+ """get LIFO (average) Rate from Queue"""
+ return _get_fifo_lifo_rate(previous_stock_queue, qty, "LIFO")
+
+
+def _get_fifo_lifo_rate(previous_stock_queue, qty, method):
+ ValuationKlass = LIFOValuation if method == "LIFO" else FIFOValuation
+
+ stock_queue = ValuationKlass(previous_stock_queue)
+ if flt(qty) >= 0:
+ total_qty, total_value = stock_queue.get_total_stock_and_value()
+ return total_value / total_qty if total_qty else 0.0
+ else:
+ popped_bins = stock_queue.remove_stock(abs(flt(qty)))
+
+ total_qty, total_value = ValuationKlass(popped_bins).get_total_stock_and_value()
+ return total_value / total_qty if total_qty else 0.0
def get_valid_serial_nos(sr_nos, qty=0, item_code=''):
"""split serial nos, validate and return list of valid serial nos"""
diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py
index 45c5083099..ee9477ed74 100644
--- a/erpnext/stock/valuation.py
+++ b/erpnext/stock/valuation.py
@@ -1,15 +1,54 @@
+from abc import ABC, abstractmethod, abstractproperty
from typing import Callable, List, NewType, Optional, Tuple
from frappe.utils import flt
-FifoBin = NewType("FifoBin", List[float])
+StockBin = NewType("StockBin", List[float]) # [[qty, rate], ...]
# Indexes of values inside FIFO bin 2-tuple
QTY = 0
RATE = 1
-class FIFOValuation:
+class BinWiseValuation(ABC):
+
+ @abstractmethod
+ def add_stock(self, qty: float, rate: float) -> None:
+ pass
+
+ @abstractmethod
+ def remove_stock(
+ self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
+ ) -> List[StockBin]:
+ pass
+
+ @abstractproperty
+ def state(self) -> List[StockBin]:
+ pass
+
+ def get_total_stock_and_value(self) -> Tuple[float, float]:
+ total_qty = 0.0
+ total_value = 0.0
+
+ for qty, rate in self.state:
+ total_qty += flt(qty)
+ total_value += flt(qty) * flt(rate)
+
+ return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
+
+ def __repr__(self):
+ return str(self.state)
+
+ def __iter__(self):
+ return iter(self.state)
+
+ def __eq__(self, other):
+ if isinstance(other, list):
+ return self.state == other
+ return type(self) == type(other) and self.state == other.state
+
+
+class FIFOValuation(BinWiseValuation):
"""Valuation method where a queue of all the incoming stock is maintained.
New stock is added at end of the queue.
@@ -24,34 +63,14 @@ class FIFOValuation:
# ref: https://docs.python.org/3/reference/datamodel.html#slots
__slots__ = ["queue",]
- def __init__(self, state: Optional[List[FifoBin]]):
- self.queue: List[FifoBin] = state if state is not None else []
+ def __init__(self, state: Optional[List[StockBin]]):
+ self.queue: List[StockBin] = state if state is not None else []
- def __repr__(self):
- return str(self.queue)
-
- def __iter__(self):
- return iter(self.queue)
-
- def __eq__(self, other):
- if isinstance(other, list):
- return self.queue == other
- return self.queue == other.queue
-
- def get_state(self) -> List[FifoBin]:
+ @property
+ def state(self) -> List[StockBin]:
"""Get current state of queue."""
return self.queue
- def get_total_stock_and_value(self) -> Tuple[float, float]:
- total_qty = 0.0
- total_value = 0.0
-
- for qty, rate in self.queue:
- total_qty += flt(qty)
- total_value += flt(qty) * flt(rate)
-
- return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
-
def add_stock(self, qty: float, rate: float) -> None:
"""Update fifo queue with new stock.
@@ -78,7 +97,7 @@ class FIFOValuation:
def remove_stock(
self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
- ) -> List[FifoBin]:
+ ) -> List[StockBin]:
"""Remove stock from the queue and return popped bins.
args:
@@ -136,6 +155,101 @@ class FIFOValuation:
return consumed_bins
+class LIFOValuation(BinWiseValuation):
+ """Valuation method where a *stack* of all the incoming stock is maintained.
+
+ New stock is added at top of the stack.
+ Qty consumption happens on Last In First Out basis.
+
+ Stack is implemented using "bins" of [qty, rate].
+
+ ref: https://en.wikipedia.org/wiki/FIFO_and_LIFO_accounting
+ Implementation detail: appends and pops both at end of list.
+ """
+
+ # specifying the attributes to save resources
+ # ref: https://docs.python.org/3/reference/datamodel.html#slots
+ __slots__ = ["stack",]
+
+ def __init__(self, state: Optional[List[StockBin]]):
+ self.stack: List[StockBin] = state if state is not None else []
+
+ @property
+ def state(self) -> List[StockBin]:
+ """Get current state of stack."""
+ return self.stack
+
+ def add_stock(self, qty: float, rate: float) -> None:
+ """Update lifo stack with new stock.
+
+ args:
+ qty: new quantity to add
+ rate: incoming rate of new quantity.
+
+ Behaviour of this is same as FIFO valuation.
+ """
+ if not len(self.stack):
+ self.stack.append([0, 0])
+
+ # last row has the same rate, merge new bin.
+ if self.stack[-1][RATE] == rate:
+ self.stack[-1][QTY] += qty
+ else:
+ # Item has a positive balance qty, add new entry
+ if self.stack[-1][QTY] > 0:
+ self.stack.append([qty, rate])
+ else: # negative balance qty
+ qty = self.stack[-1][QTY] + qty
+ if qty > 0: # new balance qty is positive
+ self.stack[-1] = [qty, rate]
+ else: # new balance qty is still negative, maintain same rate
+ self.stack[-1][QTY] = qty
+
+
+ def remove_stock(
+ self, qty: float, outgoing_rate: float = 0.0, rate_generator: Callable[[], float] = None
+ ) -> List[StockBin]:
+ """Remove stock from the stack and return popped bins.
+
+ args:
+ qty: quantity to remove
+ rate: outgoing rate - ignored. Kept for backwards compatibility.
+ rate_generator: function to be called if stack is not found and rate is required.
+ """
+ if not rate_generator:
+ rate_generator = lambda : 0.0 # noqa
+
+ consumed_bins = []
+ while qty:
+ if not len(self.stack):
+ # rely on rate generator.
+ self.stack.append([0, rate_generator()])
+
+ # start at the end.
+ index = -1
+
+ stock_bin = self.stack[index]
+ if qty >= stock_bin[QTY]:
+ # consume current bin
+ qty = _round_off_if_near_zero(qty - stock_bin[QTY])
+ to_consume = self.stack.pop(index)
+ consumed_bins.append(list(to_consume))
+
+ if not self.stack and qty:
+ # stock finished, qty still remains to be withdrawn
+ # negative stock, keep in as a negative bin
+ self.stack.append([-qty, outgoing_rate or stock_bin[RATE]])
+ consumed_bins.append([qty, outgoing_rate or stock_bin[RATE]])
+ break
+ else:
+ # qty found in current bin consume it and exit
+ stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty)
+ consumed_bins.append([qty, stock_bin[RATE]])
+ qty = 0
+
+ return consumed_bins
+
+
def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
"""Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7.
diff --git a/erpnext/templates/generators/item/item.html b/erpnext/templates/generators/item/item.html
index 17f6880293..4070d40d47 100644
--- a/erpnext/templates/generators/item/item.html
+++ b/erpnext/templates/generators/item/item.html
@@ -1,4 +1,5 @@
{% extends "templates/web.html" %}
+{% from "erpnext/templates/includes/macros.html" import recommended_item_row %}
{% block title %} {{ title }} {% endblock %}
@@ -9,25 +10,70 @@
{% endblock %}
{% block page_content %}
-
+
{% from "erpnext/templates/includes/macros.html" import product_image %}
+
{% include "templates/generators/item/item_image.html" %}
{% include "templates/generators/item/item_details.html" %}
-
- {% include "templates/generators/item/item_specifications.html" %}
-
- {{ doc.website_content or '' }}
+
+
+
+ {% set show_recommended_items = recommended_items and shopping_cart.cart_settings.enable_recommendations %}
+ {% set info_col = 'col-9' if show_recommended_items else 'col-12' %}
+
+ {% set padding_top = 'pt-0' if (show_tabs and tabs) else '' %}
+
+
+
+
+
+ {% if show_tabs and tabs %}
+
+
+ {{ web_block("Section with Tabs", values=tabs, add_container=0,
+ add_top_padding=0, add_bottom_padding=0)
+ }}
+
+ {% elif website_specifications %}
+ {% include "templates/generators/item/item_specifications.html"%}
+ {% endif %}
+
+
+ {{ doc.website_content or '' }}
+
+
+ {% if shopping_cart.cart_settings.enable_reviews and not doc.has_variants %}
+ {% include "templates/generators/item/item_reviews.html"%}
+ {% endif %}
+
+
+
+
+
+ {% if show_recommended_items %}
+
+
+
+ {% for item in recommended_items %}
+ {{ recommended_item_row(item) }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
{% endblock %}
{% block base_scripts %}
+
{{ include_script("frappe-web.bundle.js") }}
{{ include_script("controls.bundle.js") }}
{{ include_script("dialog.bundle.js") }}
diff --git a/erpnext/templates/generators/item/item_add_to_cart.html b/erpnext/templates/generators/item/item_add_to_cart.html
index 167c848eff..8000a2446b 100644
--- a/erpnext/templates/generators/item/item_add_to_cart.html
+++ b/erpnext/templates/generators/item/item_add_to_cart.html
@@ -5,54 +5,115 @@
+
{% if cart_settings.show_price and product_info.price %}
-
- {{ product_info.price.formatted_price_sales_uom }}
- ({{ product_info.price.formatted_price }} / {{ product_info.uom }})
-
+ {% set price_info = product_info.price %}
+
+
+
+ {{ price_info.formatted_price_sales_uom }}
+
+
+ {% if price_info.formatted_mrp %}
+
+ MRP {{ price_info.formatted_mrp }}
+
+
+ -{{ price_info.get("formatted_discount_percent") or price_info.get("formatted_discount_rate")}}
+
+ {% endif %}
+
+
+
+ ({{ price_info.formatted_price }} / {{ product_info.uom }})
+
+
{% else %}
{{ _("UOM") }} : {{ product_info.uom }}
{% endif %}
{% if cart_settings.show_stock_availability %}
-
- {% if product_info.in_stock == 0 %}
-
- {{ _('Not in stock') }}
-
+
+ {% if product_info.get("on_backorder") %}
+
+ {{ _('Available on backorder') }}
+
+ {% elif product_info.in_stock == 0 %}
+
+ {{ _('Out of stock') }}
+
{% elif product_info.in_stock == 1 %}
-
- {{ _('In stock') }}
- {% if product_info.show_stock_qty and product_info.stock_qty %}
- ({{ product_info.stock_qty[0][0] }})
- {% endif %}
-
+
+ {{ _('In stock') }}
+ {% if product_info.show_stock_qty and product_info.stock_qty %}
+ ({{ product_info.stock_qty[0][0] }})
+ {% endif %}
+
{% endif %}
{% endif %}
-
- {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %}
-
- {{ _("View in Cart") }}
-
-
-
-
-
+
+
+ {% if doc.offers %}
+
+
+
+
+
+ Available Offers
+
+
+ {% for offer in doc.offers %}
+
+
+
+
+
+
+
-
- {{ _("Add to Cart") }}
-
- {% endif %}
- {% if cart_settings.show_contact_us_button %}
- {% include "templates/generators/item/item_inquiry.html" %}
- {% endif %}
+
+
+ {{ _(offer.offer_title) }}:
+ {{ _(offer.offer_subtitle) if offer.offer_subtitle else '' }}
+
+ {{ _("More") }}
+
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+
+ {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %}
+
+ {{ _("View in Cart") if cart_settings.enable_checkout else _("View in Quote") }}
+
+
+
+
+
+
+
+ {{ _("Add to Cart") if cart_settings.enable_checkout else _("Add to Quote") }}
+
+ {% endif %}
+
+
+ {% if cart_settings.show_contact_us_button %}
+ {% include "templates/generators/item/item_inquiry.html" %}
+ {% endif %}
+
@@ -60,10 +121,11 @@
{% endif %}
diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html
index b61ac73072..e97a275fbd 100644
--- a/erpnext/templates/generators/item/item_configure.html
+++ b/erpnext/templates/generators/item/item_configure.html
@@ -3,11 +3,11 @@
{% if cart_settings.enable_variants | int %}
-
- {{ _('Configure') }}
+ {{ _('Select Variant') }}
{% endif %}
{% if cart_settings.show_contact_us_button %}
diff --git a/erpnext/templates/generators/item/item_configure.js b/erpnext/templates/generators/item/item_configure.js
index 8eadb84289..231ae0587e 100644
--- a/erpnext/templates/generators/item/item_configure.js
+++ b/erpnext/templates/generators/item/item_configure.js
@@ -29,7 +29,7 @@ class ItemConfigure {
});
this.dialog = new frappe.ui.Dialog({
- title: __('Configure {0}', [this.item_name]),
+ title: __('Select Variant for {0}', [this.item_name]),
fields,
on_hide: () => {
set_continue_configuration();
@@ -201,7 +201,7 @@ class ItemConfigure {
${frappe.utils.icon('assets', 'md')}
- ${__("Add to Cart")}s
+ ${__("Add to Cart")}
` : '';
@@ -214,7 +214,7 @@ class ItemConfigure {
? `
${one_item}
- ${product_info && product_info.price
+ ${product_info && product_info.price && !$.isEmptyObject(product_info.price)
? '(' + product_info.price.formatted_price_sales_uom + ')'
: ''
}
@@ -247,7 +247,7 @@ class ItemConfigure {
const additional_notes = Object.keys(this.range_values || {}).map(attribute => {
return `${attribute}: ${this.range_values[attribute]}`;
}).join('\n');
- erpnext.shopping_cart.update_cart({
+ erpnext.e_commerce.shopping_cart.update_cart({
item_code,
additional_notes,
qty: 1
@@ -280,14 +280,14 @@ class ItemConfigure {
}
get_next_attribute_and_values(selected_attributes) {
- return this.call('erpnext.portal.product_configurator.utils.get_next_attribute_and_values', {
+ return this.call('erpnext.e_commerce.variant_selector.utils.get_next_attribute_and_values', {
item_code: this.item_code,
selected_attributes
});
}
get_attributes_and_values() {
- return this.call('erpnext.portal.product_configurator.utils.get_attributes_and_values', {
+ return this.call('erpnext.e_commerce.variant_selector.utils.get_attributes_and_values', {
item_code: this.item_code
});
}
@@ -311,9 +311,9 @@ function set_continue_configuration() {
const { itemCode } = $btn_configure.data();
if (localStorage.getItem(`configure:${itemCode}`)) {
- $btn_configure.text(__('Continue Configuration'));
+ $btn_configure.text(__('Continue Selection'));
} else {
- $btn_configure.text(__('Configure'));
+ $btn_configure.text(__('Select Variant'));
}
}
diff --git a/erpnext/templates/generators/item/item_details.html b/erpnext/templates/generators/item/item_details.html
index 3b77585827..028936bf5f 100644
--- a/erpnext/templates/generators/item/item_details.html
+++ b/erpnext/templates/generators/item/item_details.html
@@ -1,27 +1,63 @@
-
-
-
- {{ item_name }}
-
-
- {{ _("Item Code") }}:
- {{ doc.name }}
-
-{% if has_variants %}
-
- {% include "templates/generators/item/item_configure.html" %}
-{% else %}
-
- {% include "templates/generators/item/item_add_to_cart.html" %}
-{% endif %}
-
-
-{% if frappe.utils.strip_html(doc.web_long_description or '') %}
- {{ doc.web_long_description | safe }}
-{% elif frappe.utils.strip_html(doc.description or '') %}
- {{ doc.description | safe }}
-{% else %}
- {{ _("No description given") }}
-{% endif %}
-
+{% set width_class = "expand" if not slides else "" %}
+{% set cart_settings = shopping_cart.cart_settings %}
+{% set product_info = shopping_cart.product_info %}
+{% set price_info = product_info.get('price') or {} %}
+
+
+
+
+
+ {{ doc.web_item_name }}
+
+
+
+ {% if cart_settings.enable_wishlist %}
+
+
+
+
+
+ {% endif %}
+
+
+
+
+ {{ _(doc.item_group) }}
+
+
+ {{ _("Item Code") }}:
+
+ {{ doc.item_code }}
+
+ {% if has_variants %}
+
+ {% include "templates/generators/item/item_configure.html" %}
+ {% else %}
+
+ {% include "templates/generators/item/item_add_to_cart.html" %}
+ {% endif %}
+
+
+ {% if frappe.utils.strip_html(doc.web_long_description or '') %}
+ {{ doc.web_long_description | safe }}
+ {% elif frappe.utils.strip_html(doc.description or '') %}
+ {{ doc.description | safe }}
+ {% else %}
+ {{ "" }}
+ {% endif %}
+
+
+{% block base_scripts %}
+
+
+{% endblock %}
+
+
\ No newline at end of file
diff --git a/erpnext/templates/generators/item/item_image.html b/erpnext/templates/generators/item/item_image.html
index 39a30d0d7c..930bb7a67d 100644
--- a/erpnext/templates/generators/item/item_image.html
+++ b/erpnext/templates/generators/item/item_image.html
@@ -1,29 +1,30 @@
-
+{% set column_size = 5 if slides else 4 %}
+
{% if slides %}
-
- {% for item in slides %}
-
- {% endfor %}
-
- {{ product_image(slides[0].image, 'product-image') }}
-
-
+ $('.item-slideshow-image').removeClass('active');
+ $img.addClass('active');
+ });
+ })
+
{% else %}
- {{ product_image(website_image or image or 'no-image.jpg', alt=website_image_alt or item_name) }}
+ {{ product_image(doc.website_image or doc.image, alt=doc.website_image_alt or doc.item_name) }}
{% endif %}
diff --git a/erpnext/templates/generators/item/item_inquiry.html b/erpnext/templates/generators/item/item_inquiry.html
index 83653b6821..af636f1582 100644
--- a/erpnext/templates/generators/item/item_inquiry.html
+++ b/erpnext/templates/generators/item/item_inquiry.html
@@ -1,9 +1,9 @@
{% if shopping_cart and shopping_cart.cart_settings.enabled %}
{% set cart_settings = shopping_cart.cart_settings %}
- {% if cart_settings.show_contact_us_button | int %}
-
- {{ _('Contact Us') }}
-
+ {% if cart_settings.show_contact_us_button | int %}
+
+ {{ _('Contact Us') }}
+
{% endif %}
diff --git a/erpnext/templates/generators/item/item_specifications.html b/erpnext/templates/generators/item/item_specifications.html
index d4dfa8e591..0814d81c8a 100644
--- a/erpnext/templates/generators/item/item_specifications.html
+++ b/erpnext/templates/generators/item/item_specifications.html
@@ -1,14 +1,20 @@
-{% if doc.website_specifications -%}
-
-
-
- {% for d in doc.website_specifications -%}
+
+{% if website_specifications %}
+
+
+ {% if not show_tabs %}
+
+ Product Details
+
+ {% endif %}
+
+ {% for d in website_specifications -%}
- {{ d.label }}
- {{ d.description }}
+ {{ d.label }}
+ {{ d.description }}
{%- endfor %}
-{%- endif %}
+{% endif %}
diff --git a/erpnext/templates/generators/item_group.html b/erpnext/templates/generators/item_group.html
index b5f18ba66d..e099cdde6a 100644
--- a/erpnext/templates/generators/item_group.html
+++ b/erpnext/templates/generators/item_group.html
@@ -1,17 +1,25 @@
+{% from "erpnext/templates/includes/macros.html" import field_filter_section, attribute_filter_section, discount_range_filters %}
{% extends "templates/web.html" %}
{% block header %}
-
+{{ _(item_group_name) }}
{% endblock header %}
{% block script %}
{% endblock %}
+{% block breadcrumbs %}
+
+ {% include "templates/includes/breadcrumbs.html" %}
+
+{% endblock %}
+
{% block page_content %}
-
+
- {% if slideshow %}
+ {% if slideshow %}
{{ web_block(
"Hero Slider",
values=slideshow,
@@ -20,91 +28,28 @@
add_bottom_padding=0,
) }}
{% endif %}
-
{{ title }}
- {% if description %}
+
+ {% if description %}
{{ description or ""}}
{% endif %}
-
-
- {% if items %}
- {% for item in items %}
- {% include "erpnext/www/all-products/item_row.html" %}
- {% endfor %}
- {% else %}
- {% include "erpnext/www/all-products/not_found.html" %}
- {% endif %}
-
+
+
+
- {% for field_filter in field_filters %}
- {%- set item_field = field_filter[0] %}
- {%- set values = field_filter[1] %}
-
-
{{ item_field.label }}
+
+ {{ field_filter_section(field_filters) }}
- {% if values | len > 20 %}
-
-
- {% endif %}
+
+ {{ attribute_filter_section(attribute_filters) }}
- {% if values %}
-
- {% for value in values %}
-
-
-
- {{ value }}
-
-
- {% endfor %}
-
- {% else %}
-
{{ _('No values') }}
- {% endif %}
-
- {% endfor %}
-
- {% for attribute in attribute_filters %}
-
-
{{ attribute.name}}
- {% if values | len > 20 %}
-
-
- {% endif %}
-
- {% if attribute.item_attribute_values %}
-
- {% for attr_value in attribute.item_attribute_values %}
-
-
-
- {{ attr_value.attribute_value }}
-
-
- {% endfor %}
-
- {% else %}
-
{{ _('No values') }}
- {% endif %}
-
- {% endfor %}
-
-
-
-
- {% if frappe.form_dict.start|int > 0 %}
-
- {{ _("Prev") }}
-
- {% endif %}
- {% if items|length >= page_length %}
-
- {{ _("Next") }}
-
- {% endif %}
-
-
-->
\ No newline at end of file
diff --git a/erpnext/templates/includes/macros.html b/erpnext/templates/includes/macros.html
index be0d47f371..4741307737 100644
--- a/erpnext/templates/includes/macros.html
+++ b/erpnext/templates/includes/macros.html
@@ -7,9 +7,15 @@
{% endmacro %}
-{% macro product_image(website_image, css_class="product-image", alt="") %}
-
-
+{% macro product_image(website_image, css_class="product-image", alt="", no_border=False) %}
+
+ {% if website_image %}
+
+ {% else %}
+
+ {{ frappe.utils.get_abbr(alt) or "NA" }}
+
+ {% endif %}
{% endmacro %}
@@ -59,65 +65,335 @@
{% endmacro %}
-{%- macro item_card(title, image, url, description, rate, category, is_featured=False, is_full_width=False, align="Left") -%}
+{%- macro item_card(item, is_featured=False, is_full_width=False, align="Left") -%}
{%- set align_items_class = resolve_class({
'align-items-end': align == 'Right',
'align-items-center': align == 'Center',
'align-items-start': align == 'Left',
}) -%}
{%- set col_size = 3 if is_full_width else 4 -%}
+{%- set title = item.web_item_name or item.item_name or item.item_code -%}
+{%- set title = title[:50] + "..." if title|len > 50 else title -%}
+{%- set image = item.website_image or item.image -%}
+{%- set description = item.website_description or item.description-%}
+
{% if is_featured %}
-
+
{% if image %}
-
+
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
{% else %}
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
{% endif %}
{% else %}
-
+
{% if image %}
-
-
-
+
{% else %}
-
- {{ frappe.utils.get_abbr(title) }}
-
+
+
+ {{ frappe.utils.get_abbr(title) }}
+
+
{% endif %}
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
{% endif %}
{%- endmacro -%}
-{%- macro item_card_body(title, description, url, rate, category, is_featured, align) -%}
+{%- macro item_card_body(title, description, item, is_featured, align) -%}
{%- set align_class = resolve_class({
'text-right': align == 'Right',
'text-center': align == 'Center' and not is_featured,
'text-left': align == 'Left' or is_featured,
}) -%}
-
-
{{ title or '' }}
+
+
{% if is_featured %}
-
{{ rate or '' }}
-
{{ description or '' }}
+
+ {{ description or '' }}
+
{% else %}
-
{{ category or '' }}
-
{{ rate or '' }}
+
{{ item.item_group or '' }}
{% endif %}
-
+{%- endmacro -%}
+
+
+{%- macro wishlist_card(item, settings) %}
+{%- set title = item.web_item_name or ''-%}
+{%- set title = title[:90] + "..." if title|len > 90 else title -%}
+
+
+
+
+ {{ wishlist_card_body(item, title, settings) }}
+
+
+{%- endmacro -%}
+
+{%- macro wishlist_card_body(item, title, settings) %}
+
+
+
{{ title or ''}}
+
{{ item.item_group or '' }}
+
+
+ {{ item.get("formatted_price") or '' }}
+
+ {% if item.get("formatted_mrp") %}
+
+ {{ item.formatted_mrp }}
+
+
+ {{ item.discount }} OFF
+
+ {% endif %}
+
+
+ {% if (item.available and settings.show_stock_availability) or (not settings.show_stock_availability) %}
+
+
+
+
+
+
+
+ {{ _("Move to Cart") }}
+
+ {% else %}
+
+ {{ _("Out of stock") }}
+
+ {% endif %}
+
+{%- endmacro -%}
+
+{%- macro ratings_with_title(avg_rating, title, size, rating_header_class, for_summary=False) -%}
+
+
+
+ {% for i in range(1,6) %}
+ {% set fill_class = 'star-click' if i <= avg_rating else '' %}
+
+
+
+ {% endfor %}
+
+
+{%- endmacro -%}
+
+{%- macro ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=False, total_reviews=None)-%}
+
+
+
+ {{ average_rating or 0 }}
+
+
+ {{ frappe.utils.cstr(total_reviews or 0) + " " + _("ratings") }}
+
+
+
+ {% if reviews %}
+ {% set rating_title = frappe.utils.cstr(average_rating) + " " + _("out of 5") if not for_summary else ''%}
+ {{ ratings_with_title(average_whole_rating, rating_title, "md", "rating-summary-title", for_summary) }}
+ {% endif %}
+
+
{{ frappe.utils.cstr(average_rating or 0) + " " + _("out of 5") }}
+
+
+
+
+ {% for percent in reviews_per_rating %}
+
+ {{ loop.index }} star
+
+
+ {% endfor %}
+
+
+{%- endmacro -%}
+
+{%- macro user_review(reviews)-%}
+
+
+ {% for review in reviews %}
+
+ {{ ratings_with_title(review.rating, _(review.review_title), "sm", "user-review-title") }}
+
+
+
+ {{ _(review.comment) }}
+
+
+
+
+ {{ _(review.customer) }}
+
+ {{ review.published_on }}
+
+
+ {% endfor %}
+
+{%- endmacro -%}
+
+{%- macro field_filter_section(filters)-%}
+{% for field_filter in filters %}
+ {%- set item_field = field_filter[0] %}
+ {%- set values = field_filter[1] %}
+
+
{{ item_field.label }}
+
+ {% if values | len > 20 %}
+
+
+ {% endif %}
+
+ {% if values %}
+
+ {% for value in values %}
+
+
+
+ {{ value }}
+
+
+ {% endfor %}
+
+ {% else %}
+
{{ _('No values') }}
+ {% endif %}
+
+{% endfor %}
+{%- endmacro -%}
+
+{%- macro attribute_filter_section(filters)-%}
+{% for attribute in filters %}
+
+
{{ attribute.name}}
+ {% if values | len > 20 %}
+
+
+ {% endif %}
+
+ {% if attribute.item_attribute_values %}
+
+ {% for attr_value in attribute.item_attribute_values %}
+
+
+
+ {{ attr_value }}
+
+
+ {% endfor %}
+
+ {% else %}
+
{{ _('No values') }}
+ {% endif %}
+
+{% endfor %}
+{%- endmacro -%}
+
+{%- macro recommended_item_row(item)-%}
+
+
+ {% if item.website_item_thumbnail %}
+ {{ product_image(item.website_item_thumbnail, css_class="r-product-image", alt="item.website_item_name", no_border=True) }}
+ {% else %}
+
+ {{ frappe.utils.get_abbr(item.website_item_name) or "NA" }}
+
+ {% endif %}
+
+
+
{%- endmacro -%}
diff --git a/erpnext/templates/includes/navbar/navbar_items.html b/erpnext/templates/includes/navbar/navbar_items.html
index 291220629c..327552117b 100644
--- a/erpnext/templates/includes/navbar/navbar_items.html
+++ b/erpnext/templates/includes/navbar/navbar_items.html
@@ -6,7 +6,17 @@
-
+
-
+
+ {% if frappe.db.get_single_value("E Commerce Settings", "enable_wishlist") %}
+
+
+
+
+
+
+
+
+ {% endif %}
{% endblock %}
diff --git a/erpnext/templates/includes/order/order_macros.html b/erpnext/templates/includes/order/order_macros.html
index 7b3c9a4131..3f2c1f2a1d 100644
--- a/erpnext/templates/includes/order/order_macros.html
+++ b/erpnext/templates/includes/order/order_macros.html
@@ -1,43 +1,49 @@
-{% from "erpnext/templates/includes/macros.html" import product_image_square %}
+{% from "erpnext/templates/includes/macros.html" import product_image %}
{% macro item_name_and_description(d) %}
-
-
-
- {{ product_image_square(d.thumbnail or d.image) }}
-
-
-
- {{ d.item_code }}
-
+
+
+
+ {% if d.thumbnail or d.image %}
+ {{ product_image(d.thumbnail or d.image, no_border=True) }}
+ {% else %}
+
+ {{ frappe.utils.get_abbr(d.item_name) or "NA" }}
+
+ {% endif %}
+
+
+
+ {{ d.item_code }}
+
{{ html2text(d.description) | truncate(140) }}
-
-
+
+
{% endmacro %}
{% macro item_name_and_description_cart(d) %}
-
-
-
- {{ product_image_square(d.thumbnail or d.image) }}
-
-
-
- {{ d.item_name|truncate(25) }}
-
+
+ {{ d.item_name|truncate(25) }}
+
+
+
+ –
+
+
+
+
+ +
+
+
+
+
{% endmacro %}
diff --git a/erpnext/templates/includes/order/order_taxes.html b/erpnext/templates/includes/order/order_taxes.html
index d2c458e0a4..b821e6253d 100644
--- a/erpnext/templates/includes/order/order_taxes.html
+++ b/erpnext/templates/includes/order/order_taxes.html
@@ -1,9 +1,9 @@
{% if doc.taxes %}
-
+
{{ _("Net Total") }}
-
+
{{ doc.get_formatted("net_total") }}
@@ -12,10 +12,10 @@
{% for d in doc.taxes %}
{% if d.base_tax_amount %}
-
+
{{ d.description }}
-
+
{{ d.get_formatted("base_tax_amount") }}
@@ -23,76 +23,62 @@
{% endfor %}
{% if doc.doctype == 'Quotation' %}
-{% if doc.coupon_code %}
-
-
- {{ _("Discount") }}
-
-
- {% set tot_quotation_discount = [] %}
- {%- for item in doc.items -%}
- {% if tot_quotation_discount.append((((item.price_list_rate * item.qty)
- * item.discount_percentage) / 100)) %}{% endif %}
- {% endfor %}
- {{ frappe.utils.fmt_money((tot_quotation_discount | sum),currency=doc.currency) }}
-
-
-{% endif %}
+ {% if doc.coupon_code %}
+
+
+ {{ _("Savings") }}
+
+
+ {% set tot_quotation_discount = [] %}
+ {%- for item in doc.items -%}
+ {% if tot_quotation_discount.append((((item.price_list_rate * item.qty)
+ * item.discount_percentage) / 100)) %}
+ {% endif %}
+ {% endfor %}
+ {{ frappe.utils.fmt_money((tot_quotation_discount | sum),currency=doc.currency) }}
+
+
+ {% endif %}
{% endif %}
{% if doc.doctype == 'Sales Order' %}
-{% if doc.coupon_code %}
-
-
- {{ _("Total Amount") }}
-
-
-
- {% set total_amount = [] %}
- {%- for item in doc.items -%}
- {% if total_amount.append((item.price_list_rate * item.qty)) %}{% endif %}
- {% endfor %}
- {{ frappe.utils.fmt_money((total_amount | sum),currency=doc.currency) }}
-
-
-
-
-
- {{ _("Applied Coupon Code") }}
-
-
-
- {%- for row in frappe.get_all(doctype="Coupon Code",
- fields=["coupon_code"], filters={ "name":doc.coupon_code}) -%}
- {{ row.coupon_code }}
- {% endfor %}
-
-
-
-
-
- {{ _("Discount") }}
-
-
-
- {% set tot_SO_discount = [] %}
- {%- for item in doc.items -%}
- {% if tot_SO_discount.append((((item.price_list_rate * item.qty)
- * item.discount_percentage) / 100)) %}{% endif %}
- {% endfor %}
- {{ frappe.utils.fmt_money((tot_SO_discount | sum),currency=doc.currency) }}
-
-
-
-{% endif %}
+ {% if doc.coupon_code %}
+
+
+ {{ _("Applied Coupon Code") }}
+
+
+
+ {%- for row in frappe.get_all(doctype="Coupon Code",
+ fields=["coupon_code"], filters={ "name":doc.coupon_code}) -%}
+ {{ row.coupon_code }}
+ {% endfor %}
+
+
+
+
+
+ {{ _("Savings") }}
+
+
+
+ {% set tot_SO_discount = [] %}
+ {%- for item in doc.items -%}
+ {% if tot_SO_discount.append((((item.price_list_rate * item.qty)
+ * item.discount_percentage) / 100)) %}{% endif %}
+ {% endfor %}
+ {{ frappe.utils.fmt_money((tot_SO_discount | sum),currency=doc.currency) }}
+
+
+
+ {% endif %}
{% endif %}
-
-
+
{{ _("Grand Total") }}
-
+
{{ doc.get_formatted("grand_total") }}
diff --git a/erpnext/templates/includes/product_page.js b/erpnext/templates/includes/product_page.js
index 90a1d862c3..a3979d037b 100644
--- a/erpnext/templates/includes/product_page.js
+++ b/erpnext/templates/includes/product_page.js
@@ -7,7 +7,7 @@ frappe.ready(function() {
frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.product_info.get_product_info_for_website",
+ method: "erpnext.e_commerce.shopping_cart.product_info.get_product_info_for_website",
args: {
item_code: get_item_code()
},
diff --git a/erpnext/templates/includes/products_as_list.html b/erpnext/templates/includes/products_as_list.html
index 9bf9fd95d7..a9369bb8de 100644
--- a/erpnext/templates/includes/products_as_list.html
+++ b/erpnext/templates/includes/products_as_list.html
@@ -1,5 +1,5 @@
-{% from "erpnext/templates/includes/macros.html" import item_card, item_card_body %}
-
+{% from "erpnext/templates/includes/macros.html" import item_card, item_card_body, product_image_square %}
+
diff --git a/erpnext/templates/pages/cart.html b/erpnext/templates/pages/cart.html
index c64c6343cc..2b7d9e3523 100644
--- a/erpnext/templates/pages/cart.html
+++ b/erpnext/templates/pages/cart.html
@@ -4,13 +4,6 @@
{% block header %}