diff --git a/webshop/hooks.py b/webshop/hooks.py index dd3fece5a3..ec80136a9f 100644 --- a/webshop/hooks.py +++ b/webshop/hooks.py @@ -23,10 +23,11 @@ ] my_account_context = "webshop.webshop.shopping_cart.utils.update_my_account_context" -website_generators = ["Website Item"] +website_generators = ["Website Item", "Item Group"] override_doctype_class = { "Payment Request": "webshop.webshop.override_doctype.payment_request.PaymentRequest", + "Item Group": "webshop.webshop.doctype.override_doctype.item_group.WebshopItemGroup", } doctype_js = { diff --git a/webshop/patches/__init__.py b/webshop/patches/__init__.py new file mode 100644 index 0000000000..7a0660b4a6 --- /dev/null +++ b/webshop/patches/__init__.py @@ -0,0 +1,3 @@ + +__version__ = '0.0.1' + diff --git a/webshop/patches/convert_to_website_item_in_item_card_group_template.py b/webshop/patches/convert_to_website_item_in_item_card_group_template.py new file mode 100644 index 0000000000..40ae1979f9 --- /dev/null +++ b/webshop/patches/convert_to_website_item_in_item_card_group_template.py @@ -0,0 +1,60 @@ +import json +from typing import List, Union + +import frappe + +from webshop.webshop.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("webshop", "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: + doc.log_error("Website Item creation failed") + return None \ No newline at end of file diff --git a/webshop/patches/copy_custom_field_filters_to_website_item.py b/webshop/patches/copy_custom_field_filters_to_website_item.py new file mode 100644 index 0000000000..1f986edbf5 --- /dev/null +++ b/webshop/patches/copy_custom_field_filters_to_website_item.py @@ -0,0 +1,94 @@ +import frappe +from frappe.custom.doctype.custom_field.custom_field import create_custom_field + + +def execute(): + "Add Field Filters, that are not standard fields in Website Item, as Custom Fields." + + def move_table_multiselect_data(docfield): + "Copy child table data (Table Multiselect) from Item to Website Item for a docfield." + table_multiselect_data = get_table_multiselect_data(docfield) + field = docfield.fieldname + + for row in table_multiselect_data: + # add copied multiselect data rows in Website Item + web_item = frappe.db.get_value("Website Item", {"item_code": row.parent}) + web_item_doc = frappe.get_doc("Website Item", web_item) + + child_doc = frappe.new_doc(docfield.options, parent_doc=web_item_doc, parentfield=field) + + for field in ["name", "creation", "modified", "idx"]: + row[field] = None + + child_doc.update(row) + + child_doc.parenttype = "Website Item" + child_doc.parent = web_item + + child_doc.insert() + + def get_table_multiselect_data(docfield): + child_table = frappe.qb.DocType(docfield.options) + item = frappe.qb.DocType("Item") + + table_multiselect_data = ( # query table data for field + frappe.qb.from_(child_table) + .join(item) + .on(item.item_code == child_table.parent) + .select(child_table.star) + .where((child_table.parentfield == docfield.fieldname) & (item.published_in_website == 1)) + ).run(as_dict=True) + + return table_multiselect_data + + settings = frappe.get_doc("E Commerce Settings") + + if not (settings.enable_field_filters or settings.filter_fields): + return + + item_meta = frappe.get_meta("Item") + valid_item_fields = [ + df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] + ] + + web_item_meta = frappe.get_meta("Website Item") + valid_web_item_fields = [ + df.fieldname for df in web_item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"] + ] + + for row in settings.filter_fields: + # skip if illegal field + if row.fieldname not in valid_item_fields: + continue + + # if Item field is not in Website Item, add it as a custom field + if row.fieldname not in valid_web_item_fields: + df = item_meta.get_field(row.fieldname) + create_custom_field( + "Website Item", + dict( + owner="Administrator", + fieldname=df.fieldname, + label=df.label, + fieldtype=df.fieldtype, + options=df.options, + description=df.description, + read_only=df.read_only, + no_copy=df.no_copy, + insert_after="on_backorder", + ), + ) + + # map field values + if df.fieldtype == "Table MultiSelect": + move_table_multiselect_data(df) + else: + frappe.db.sql( # nosemgrep + """ + UPDATE `tabWebsite Item` wi, `tabItem` i + SET wi.{0} = i.{0} + WHERE wi.item_code = i.item_code + """.format( + row.fieldname + ) + ) \ No newline at end of file diff --git a/webshop/patches/create_website_items.py b/webshop/patches/create_website_items.py new file mode 100644 index 0000000000..6eabce11c2 --- /dev/null +++ b/webshop/patches/create_website_items.py @@ -0,0 +1,85 @@ +import frappe + +from webshop.webshop.doctype.website_item.website_item import make_website_item + + +def execute(): + frappe.reload_doc("webshop", "doctype", "website_item") + frappe.reload_doc("webshop", "doctype", "website_item_tabbed_section") + frappe.reload_doc("webshop", "doctype", "website_offer") + frappe.reload_doc("webshop", "doctype", "recommended_items") + frappe.reload_doc("webshop", "doctype", "webshop_settings") + frappe.reload_doc("stock", "doctype", "item") + + item_fields = [ + "item_code", + "item_name", + "item_group", + "stock_uom", + "brand", + "has_variants", + "variant_of", + "description", + "weightage", + ] + web_fields_to_map = [ + "route", + "slideshow", + "website_image_alt", + "website_warehouse", + "web_long_description", + "website_content", + "website_image", + "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/webshop/patches/fetch_thumbnail_in_website_items.py b/webshop/patches/fetch_thumbnail_in_website_items.py new file mode 100644 index 0000000000..e87f4f14e2 --- /dev/null +++ b/webshop/patches/fetch_thumbnail_in_website_items.py @@ -0,0 +1,11 @@ +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() \ No newline at end of file diff --git a/webshop/patches/make_homepage_products_website_items.py b/webshop/patches/make_homepage_products_website_items.py new file mode 100644 index 0000000000..7a7ddba12d --- /dev/null +++ b/webshop/patches/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/webshop/patches/populate_e_commerce_settings.py b/webshop/patches/populate_e_commerce_settings.py new file mode 100644 index 0000000000..061a981f39 --- /dev/null +++ b/webshop/patches/populate_e_commerce_settings.py @@ -0,0 +1,68 @@ +import frappe +from frappe.utils import cint + + +def execute(): + frappe.reload_doc("webshop", "doctype", "webshop_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, + ) \ No newline at end of file diff --git a/webshop/patches/shopping_cart_to_ecommerce.py b/webshop/patches/shopping_cart_to_ecommerce.py new file mode 100644 index 0000000000..14d2bd2a23 --- /dev/null +++ b/webshop/patches/shopping_cart_to_ecommerce.py @@ -0,0 +1,9 @@ +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) diff --git a/webshop/setup/install.py b/webshop/setup/install.py index 8480625b6e..71b2845301 100644 --- a/webshop/setup/install.py +++ b/webshop/setup/install.py @@ -6,80 +6,213 @@ def after_install(): - copy_from_ecommerce_settings() - drop_ecommerce_settings() - remove_ecommerce_settings_doctype() - add_custom_fields() - navbar_add_products_link() - frappe.db.commit() - say_thanks() + run_patches() + copy_from_ecommerce_settings() + drop_ecommerce_settings() + remove_ecommerce_settings_doctype() + add_custom_fields() + navbar_add_products_link() + say_thanks() def copy_from_ecommerce_settings(): - qb = frappe.qb - table = frappe.qb.Table("tabSingles") - old_doctype = "E Commerce Settings" - new_doctype = "Webshop Settings" - fields = ("field", "value") + if not frappe.db.table_exists("E Commerce Settings"): + return - entries = ( - qb.from_(table) - .select(*fields) - .where(table.doctype == old_doctype) - .run(as_dict=True) - ) + qb = frappe.qb + table = frappe.qb.Table("tabSingles") + old_doctype = "E Commerce Settings" + new_doctype = "Webshop Settings" + fields = ("field", "value") - for e in entries: - qb.into(table).insert(new_doctype, e.field, e.value).run() + entries = ( + qb.from_(table) + .select(*fields) + .where(table.doctype == old_doctype) + .run(as_dict=True) + ) + + for e in entries: + qb.into(table).insert(new_doctype, e.field, e.value).run() def drop_ecommerce_settings(): - frappe.delete_doc_if_exists("DocType", "E Commerce Settings", force=True) + frappe.delete_doc_if_exists("DocType", "E Commerce Settings", force=True) def remove_ecommerce_settings_doctype(): - table = frappe.qb.Table("tabSingles") - old_doctype = "E Commerce Settings" + if not frappe.db.table_exists("E Commerce Settings"): + return - frappe.qb.from_(table).delete().where(table.doctype == old_doctype).run() + table = frappe.qb.Table("tabSingles") + old_doctype = "E Commerce Settings" + frappe.qb.from_(table).delete().where(table.doctype == old_doctype).run() -def add_custom_fields(): - d = { - "Item": [ - { - "default": 0, - "depends_on": "published_in_website", - "fieldname": "published_in_website", - "fieldtype": "Check", - "ignore_user_permissions": 1, - "insert_after": "default_manufacturer_part_no", - "label": "Published In Website", - "read_only": 1, - } - ] - } - - return create_custom_fields(d) +def add_custom_fields(): + custom_fields = { + "Item": [ + { + "default": 0, + "depends_on": "published_in_website", + "fieldname": "published_in_website", + "fieldtype": "Check", + "ignore_user_permissions": 1, + "insert_after": "default_manufacturer_part_no", + "label": "Published In Website", + "read_only": 1, + } + ], + "Item Group": [ + { + "fieldname": "custom_website_settings", + "fieldtype": "Section Break", + "label": "Website Settings", + "insert_after": "taxes", + }, + { + "default": "0", + "description": "Make Item Group visible in website", + "fieldname": "show_in_website", + "fieldtype": "Check", + "label": "Show in Website", + "insert_after": "custom_website_settings", + }, + { + "depends_on": "show_in_website", + "fieldname": "route", + "fieldtype": "Data", + "label": "Route", + "no_copy": 1, + "unique": 1, + "insert_after": "show_in_website", + }, + { + "depends_on": "show_in_website", + "fieldname": "website_title", + "fieldtype": "Data", + "label": "Title", + "insert_after": "route", + }, + { + "depends_on": "show_in_website", + "description": "HTML / Banner that will show on the top of product list.", + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Description", + "insert_after": "website_title", + }, + { + "default": "0", + "depends_on": "show_in_website", + "description": "Include Website Items belonging to child Item Groups", + "fieldname": "include_descendants", + "fieldtype": "Check", + "label": "Include Descendants", + "insert_after": "website_title", + }, + { + "fieldname": "column_break_16", + "fieldtype": "Column Break", + "insert_after": "include_descendants", + }, + { + "depends_on": "show_in_website", + "fieldname": "weightage", + "fieldtype": "Int", + "label": "Weightage", + "insert_after": "column_break_16", + }, + { + "depends_on": "show_in_website", + "description": "Show this slideshow at the top of the page", + "fieldname": "slideshow", + "fieldtype": "Link", + "label": "Slideshow", + "options": "Website Slideshow", + "insert_after": "weightage", + }, + { + "depends_on": "show_in_website", + "fieldname": "website_specifications", + "fieldtype": "Table", + "label": "Website Specifications", + "options": "Item Website Specification", + "insert_after": "description", + }, + { + "collapsible": 1, + "depends_on": "show_in_website", + "fieldname": "website_filters_section", + "fieldtype": "Section Break", + "label": "Website Filters", + "insert_after": "website_specifications", + }, + { + "fieldname": "filter_fields", + "fieldtype": "Table", + "label": "Item Fields", + "options": "Website Filter Field", + "insert_after": "website_filters_section", + }, + { + "fieldname": "filter_attributes", + "fieldtype": "Table", + "label": "Attributes", + "options": "Website Attribute", + "insert_after": "filter_fields", + }, + ] + } + + return create_custom_fields(custom_fields) def navbar_add_products_link(): - website_settings = frappe.get_single("Website Settings") + website_settings = frappe.get_single("Website Settings") - if not website_settings: - return + if not website_settings: + return - website_settings.append( - "top_bar_items", - { - "label": _("Products"), - "url": "/all-products", - "right": False, - }, - ) + website_settings.append( + "top_bar_items", + { + "label": _("Products"), + "url": "/all-products", + "right": False, + }, + ) - return website_settings.save() + return website_settings.save() def say_thanks(): - click.secho("Thank you for installing Frappe Webshop!", color="green") + click.secho("Thank you for installing Frappe Webshop!", color="green") + + +patches = [ + "create_website_items", + "populate_e_commerce_settings", + "make_homepage_products_website_items", + "fetch_thumbnail_in_website_items", + "convert_to_website_item_in_item_card_group_template", + "shopping_cart_to_ecommerce" + "copy_custom_field_filters_to_website_item", +] + +def run_patches(): + # Customers migrating from v13 to v15 directly need to run all below patches + + if frappe.db.table_exists("Website Item"): + return + + frappe.flags.in_patch = True + + try: + for patch in patches: + frappe.get_attr(f"webshop.patches.after_install.{patch}.execute")() + + finally: + frappe.flags.in_patch = False + + diff --git a/webshop/templates/generators/item/item_add_to_cart.html b/webshop/templates/generators/item/item_add_to_cart.html index 0c4d75264d..937303d7d7 100644 --- a/webshop/templates/generators/item/item_add_to_cart.html +++ b/webshop/templates/generators/item/item_add_to_cart.html @@ -46,7 +46,7 @@ {{ _('In stock') }} {% if product_info.show_stock_qty and product_info.stock_qty %} - ({{ product_info.stock_qty[0][0] }}) + ({{ product_info.stock_qty }}) {% endif %} {% endif %} diff --git a/webshop/templates/generators/item_group.html b/webshop/templates/generators/item_group.html new file mode 100644 index 0000000000..956c3c51e6 --- /dev/null +++ b/webshop/templates/generators/item_group.html @@ -0,0 +1,72 @@ +{% 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 %} + {{ web_block( + "Hero Slider", + values=slideshow, + add_container=0, + add_top_padding=0, + add_bottom_padding=0, + ) }} + {% endif %} + + {% if description %} +
{{ description or ""}}
+ {% endif %} +
+
+
+ +
+ +
+
+
+
{{ _('Filters') }}
+ {{ _('Clear All') }} +
+ + {{ field_filter_section(field_filters) }} + + + {{ attribute_filter_section(attribute_filters) }} + +
+ +
+
+
+ + +{% endblock %} diff --git a/webshop/templates/pages/product_search.py b/webshop/templates/pages/product_search.py index 6c1c5b91a1..ba8348e566 100644 --- a/webshop/templates/pages/product_search.py +++ b/webshop/templates/pages/product_search.py @@ -14,7 +14,7 @@ is_redisearch_enabled, ) from webshop.webshop.shopping_cart.product_info import set_product_info_for_website -from erpnext.setup.doctype.item_group.item_group import get_item_for_list_in_html +from webshop.webshop.doctype.override_doctype.item_group import get_item_for_list_in_html no_cache = 1 diff --git a/webshop/webshop/doctype/override_doctype/__init__.py b/webshop/webshop/doctype/override_doctype/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webshop/webshop/doctype/override_doctype/item_group.py b/webshop/webshop/doctype/override_doctype/item_group.py new file mode 100644 index 0000000000..8d99a78ca8 --- /dev/null +++ b/webshop/webshop/doctype/override_doctype/item_group.py @@ -0,0 +1,50 @@ +import frappe +from urllib.parse import quote +from frappe.utils import get_url +from frappe.website.website_generator import WebsiteGenerator +from erpnext.setup.doctype.item_group.item_group import ItemGroup + +class WebshopItemGroup(ItemGroup, WebsiteGenerator): + nsm_parent_field = "parent_item_group" + website = frappe._dict( + condition_field="show_in_website", + template="templates/generators/item_group.html", + no_cache=1, + no_breadcrumbs=1, + ) + + def validate(self): + WebsiteGenerator.validate(self) + super(WebshopItemGroup, self).validate() + self.make_route() + + def make_route(self): + """Make website route""" + if self.route: + return + + self.route = "" + if self.parent_item_group: + parent_item_group = frappe.get_doc("Item Group", self.parent_item_group) + + # make parent route only if not root + if parent_item_group.parent_item_group and parent_item_group.route: + self.route = parent_item_group.route + "/" + + self.route += self.scrub(self.item_group_name) + + return self.route + + def on_trash(self): + WebsiteGenerator.on_trash(self) + super(WebshopItemGroup, self).on_trash() + +def get_item_for_list_in_html(context): + # add missing absolute link in files + # user may forget it during upload + if (context.get("website_image") or "").startswith("files/"): + context["website_image"] = "/" + quote(context["website_image"]) + + products_template = "templates/includes/products_as_list.html" + + return frappe.get_template(products_template).render(context) diff --git a/webshop/webshop/product_data_engine/query.py b/webshop/webshop/product_data_engine/query.py index 79e1fbda39..c1db5bc0fc 100644 --- a/webshop/webshop/product_data_engine/query.py +++ b/webshop/webshop/product_data_engine/query.py @@ -6,7 +6,7 @@ from webshop.webshop.doctype.item_review.item_review import get_customer from webshop.webshop.shopping_cart.product_info import get_product_info_for_website -from erpnext.utilities.product import get_non_stock_item_status +from webshop.webshop.utils.product import get_non_stock_item_status class ProductQuery: diff --git a/webshop/webshop/shopping_cart/cart.py b/webshop/webshop/shopping_cart/cart.py index e0b31e91bf..db5fdf9f4b 100644 --- a/webshop/webshop/shopping_cart/cart.py +++ b/webshop/webshop/shopping_cart/cart.py @@ -13,7 +13,7 @@ from webshop.webshop.doctype.webshop_settings.webshop_settings import ( get_shopping_cart_settings, ) -from erpnext.utilities.product import get_web_item_qty_in_stock +from webshop.webshop.utils.product import get_web_item_qty_in_stock from erpnext.selling.doctype.quotation.quotation import _make_sales_order diff --git a/webshop/webshop/shopping_cart/product_info.py b/webshop/webshop/shopping_cart/product_info.py index 5fa507ee83..039d2846bb 100644 --- a/webshop/webshop/shopping_cart/product_info.py +++ b/webshop/webshop/shopping_cart/product_info.py @@ -8,107 +8,106 @@ show_quantity_in_website, ) from webshop.webshop.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, -) +from erpnext.utilities.product import (get_price) +from webshop.webshop.utils.product import (get_non_stock_item_status, get_web_item_qty_in_stock) from webshop.webshop.shopping_cart.cart import get_party @frappe.whitelist(allow_guest=True) def get_product_info_for_website(item_code, skip_quotation_creation=False): - """ - Get product price / stock info for website - """ - - cart_settings = get_shopping_cart_settings() - if not cart_settings.enabled: - # 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: - cart_quotation = _get_cart_quotation() - - selling_price_list = ( - cart_quotation.get("selling_price_list") - if cart_quotation - else _set_price_list(cart_settings, None) - ) - - price = {} - if cart_settings.show_price: - is_guest = frappe.session.user == "Guest" - party = get_party() - - # 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, - party=party, - ) - - 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, - "qty": 0, - "uom": frappe.db.get_value("Item", item_code, "stock_uom"), - "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 - ) - if item: - product_info["qty"] = item[0].qty - - return frappe._dict({"product_info": product_info, "cart_settings": cart_settings}) + """ + Get product price / stock info for website + """ + + cart_settings = get_shopping_cart_settings() + if not cart_settings.enabled: + # 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: + cart_quotation = _get_cart_quotation() + + selling_price_list = ( + cart_quotation.get("selling_price_list") + if cart_quotation + else _set_price_list(cart_settings, None) + ) + + price = {} + if cart_settings.show_price: + is_guest = frappe.session.user == "Guest" + party = get_party() + + # 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, + party=party, + ) + + 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, + "qty": 0, + "uom": frappe.db.get_value("Item", item_code, "stock_uom"), + "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 + ) + if item: + product_info["qty"] = item[0].qty + + print(product_info) + print(cart_settings.as_dict()) + return frappe._dict({"product_info": product_info, "cart_settings": cart_settings}) def set_product_info_for_website(item): - """set product price uom for website""" - product_info = get_product_info_for_website( - item.item_code, skip_quotation_creation=True - ).get("product_info") - - if product_info: - item.update(product_info) - item["stock_uom"] = product_info.get("uom") - item["sales_uom"] = product_info.get("sales_uom") - if product_info.get("price"): - item["price_stock_uom"] = product_info.get("price").get("formatted_price") - item["price_sales_uom"] = product_info.get("price").get( - "formatted_price_sales_uom" - ) - else: - item["price_stock_uom"] = "" - item["price_sales_uom"] = "" + """set product price uom for website""" + product_info = get_product_info_for_website( + item.item_code, skip_quotation_creation=True + ).get("product_info") + + if product_info: + item.update(product_info) + item["stock_uom"] = product_info.get("uom") + item["sales_uom"] = product_info.get("sales_uom") + if product_info.get("price"): + item["price_stock_uom"] = product_info.get("price").get("formatted_price") + item["price_sales_uom"] = product_info.get("price").get( + "formatted_price_sales_uom" + ) + else: + item["price_stock_uom"] = "" + item["price_sales_uom"] = "" diff --git a/webshop/webshop/utils/__init__.py b/webshop/webshop/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/webshop/webshop/utils/product.py b/webshop/webshop/utils/product.py new file mode 100644 index 0000000000..129ef58d4d --- /dev/null +++ b/webshop/webshop/utils/product.py @@ -0,0 +1,98 @@ +import frappe +from frappe.utils import cint, flt, fmt_money, getdate, nowdate + +from erpnext.accounts.doctype.pricing_rule.pricing_rule import get_pricing_rule_for_item +from erpnext.stock.doctype.batch.batch import get_batch_qty +from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses + + +def get_web_item_qty_in_stock(item_code, item_warehouse_field, warehouse=None): + in_stock, stock_qty = 0, "" + template_item_code, is_stock_item = frappe.db.get_value( + "Item", item_code, ["variant_of", "is_stock_item"] + ) + + if not warehouse: + warehouse = frappe.db.get_value("Website Item", {"item_code": item_code}, item_warehouse_field) + + if not warehouse and template_item_code and template_item_code != item_code: + warehouse = frappe.db.get_value( + "Website Item", {"item_code": template_item_code}, item_warehouse_field + ) + + if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1: + warehouses = get_child_warehouses(warehouse) + else: + warehouses = [warehouse] if warehouse else [] + + total_stock = 0.0 + if warehouses: + for warehouse in warehouses: + stock_qty = frappe.db.sql( + """ + select GREATEST(S.actual_qty - S.reserved_qty - S.reserved_qty_for_production - S.reserved_qty_for_sub_contract, 0) / IFNULL(C.conversion_factor, 1) + from tabBin S + inner join `tabItem` I on S.item_code = I.Item_code + left join `tabUOM Conversion Detail` C on I.sales_uom = C.uom and C.parent = I.Item_code + where S.item_code=%s and S.warehouse=%s""", + (item_code, warehouse), + ) + + if stock_qty: + total_stock += adjust_qty_for_expired_items(item_code, stock_qty, warehouse) + + in_stock = total_stock > 0 and 1 or 0 + + return frappe._dict( + {"in_stock": in_stock, "stock_qty": total_stock, "is_stock_item": is_stock_item} + ) + + +def adjust_qty_for_expired_items(item_code, stock_qty, warehouse): + batches = frappe.get_all("Batch", filters=[{"item": item_code}], fields=["expiry_date", "name"]) + expired_batches = get_expired_batches(batches) + stock_qty = [list(item) for item in stock_qty] + + for batch in expired_batches: + if warehouse: + stock_qty[0][0] = max(0, stock_qty[0][0] - get_batch_qty(batch, warehouse)) + else: + stock_qty[0][0] = max(0, stock_qty[0][0] - qty_from_all_warehouses(get_batch_qty(batch))) + + if not stock_qty[0][0]: + break + + return stock_qty[0][0] if stock_qty else 0 + + +def get_expired_batches(batches): + """ + :param batches: A list of dict in the form [{'expiry_date': datetime.date(20XX, 1, 1), 'name': 'batch_id'}, ...] + """ + return [b.name for b in batches if b.expiry_date and b.expiry_date <= getdate(nowdate())] + + +def qty_from_all_warehouses(batch_info): + """ + :param batch_info: A list of dict in the form [{u'warehouse': u'Stores - I', u'qty': 0.8}, ...] + """ + qty = 0 + for batch in batch_info: + qty = qty + batch.qty + + return qty + + +def get_non_stock_item_status(item_code, item_warehouse_field): + # if item is a product bundle, check if its bundle items are in stock + if frappe.db.exists("Product Bundle", item_code): + items = frappe.get_doc("Product Bundle", item_code).get_all_children() + bundle_warehouse = frappe.db.get_value( + "Website Item", {"item_code": item_code}, item_warehouse_field + ) + return all( + get_web_item_qty_in_stock(d.item_code, item_warehouse_field, bundle_warehouse).in_stock + for d in items + ) + else: + return 1 \ No newline at end of file