diff --git a/erpnext/config/desktop.py b/erpnext/config/desktop.py index 34f15995f79..ce9b25503b3 100644 --- a/erpnext/config/desktop.py +++ b/erpnext/config/desktop.py @@ -48,5 +48,12 @@ def get_data(): "color": "#2c3e50", "icon": "icon-phone", "type": "module" + }, + "Shopping Cart": { + "color": "#B7E090", + "icon": "icon-shopping-cart", + "label": _("Shopping Cart"), + "link": "Form/Shopping Cart Settings", + "type": "module" } } diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 54b911a596e..ea65400e361 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -11,14 +11,18 @@ error_report_email = "support@erpnext.com" app_include_js = "assets/js/erpnext.min.js" app_include_css = "assets/css/erpnext.css" web_include_js = "assets/js/erpnext-web.min.js" +web_include_css = "assets/css/shopping-cart-web.css" after_install = "erpnext.setup.install.after_install" boot_session = "erpnext.startup.boot.boot_session" notification_config = "erpnext.startup.notifications.get_notification_config" +on_session_creation = "erpnext.shopping_cart.utils.set_cart_count" +on_logout = "erpnext.shopping_cart.utils.clear_cart_count" +update_website_context = ["erpnext.shopping_cart.utils.update_website_context", "erpnext.startup.webutils.update_website_context"] + dump_report_map = "erpnext.startup.report_data_map.data_map" -update_website_context = "erpnext.startup.webutils.update_website_context" before_tests = "erpnext.setup.utils.before_tests" @@ -36,7 +40,13 @@ doc_events = { "User": { "validate": "erpnext.hr.doctype.employee.employee.validate_employee_role", "on_update": "erpnext.hr.doctype.employee.employee.update_user_permissions" - } + }, + "Sales Taxes and Charges Master": { + "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" + }, + "Price List": { + "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" + }, } scheduler_events = { @@ -55,6 +65,5 @@ scheduler_events = { ] } -default_mail_footer = """
""" - +default_mail_footer = """""" diff --git a/erpnext/modules.txt b/erpnext/modules.txt index 68d095e1e14..243c2d859f3 100644 --- a/erpnext/modules.txt +++ b/erpnext/modules.txt @@ -9,3 +9,4 @@ Stock Support Utilities Contacts +Shopping Cart diff --git a/erpnext/patches/v4_2/party_model.py b/erpnext/patches/v4_2/party_model.py index e1fa982b1b2..287153131c0 100644 --- a/erpnext/patches/v4_2/party_model.py +++ b/erpnext/patches/v4_2/party_model.py @@ -74,6 +74,9 @@ def set_party_in_jv_and_gl_entry(receivable_payable_accounts): for d in accounts: account_map.setdefault(d.name, d) + if not account_map: + return + for dt in ["Journal Voucher Detail", "GL Entry"]: records = frappe.db.sql("""select name, account from `tab%s` where account in (%s)""" % (dt, ", ".join(['%s']*len(account_map))), tuple(account_map.keys())) diff --git a/erpnext/patches/v5_0/update_frozen_accounts_permission_role.py b/erpnext/patches/v5_0/update_frozen_accounts_permission_role.py index f569695234c..e4bf9a9b237 100644 --- a/erpnext/patches/v5_0/update_frozen_accounts_permission_role.py +++ b/erpnext/patches/v5_0/update_frozen_accounts_permission_role.py @@ -7,6 +7,6 @@ def execute(): account_settings = frappe.get_doc("Accounts Settings") if not account_settings.frozen_accounts_modifier and account_settings.bde_auth_role: - account_settings.frozen_accounts_modifier = account_settings.bde_auth_role + frappe.db.set_value("Account Settings", None, + "frozen_accounts_modifier", account_settings.bde_auth_role) - account_settings.save() diff --git a/erpnext/public/build.json b/erpnext/public/build.json index 0998c53821b..3f6dd129202 100644 --- a/erpnext/public/build.json +++ b/erpnext/public/build.json @@ -3,7 +3,8 @@ "public/css/erpnext.css" ], "js/erpnext-web.min.js": [ - "public/js/website_utils.js" + "public/js/website_utils.js", + "public/js/shopping_cart.js" ], "js/erpnext.min.js": [ "public/js/conf.js", @@ -11,5 +12,8 @@ "public/js/utils.js", "public/js/queries.js", "public/js/utils/party.js" + ], + "css/shopping-cart-web.css": [ + "public/css/shopping_cart.css" ] } diff --git a/erpnext/public/css/shopping_cart.css b/erpnext/public/css/shopping_cart.css new file mode 100644 index 00000000000..5e869720b56 --- /dev/null +++ b/erpnext/public/css/shopping_cart.css @@ -0,0 +1,11 @@ +.item-main-image { + max-width: 100%; + margin: auto; +} +.web-long-description { + font-size: 18px; + line-height: 200%; +} +.item-stock { + margin-bottom: 10px !important; +} diff --git a/erpnext/public/js/shopping_cart.js b/erpnext/public/js/shopping_cart.js new file mode 100644 index 00000000000..0c718b0065f --- /dev/null +++ b/erpnext/public/js/shopping_cart.js @@ -0,0 +1,66 @@ +// Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. and Contributors +// License: GNU General Public License v3. See license.txt + +// shopping cart +frappe.provide("shopping_cart"); + +$(function() { + // update user + if(full_name) { + $('.navbar li[data-label="User"] a') + .html(' ' + full_name); + } + + // update login + shopping_cart.set_cart_count(); +}); + +$.extend(shopping_cart, { + update_cart: function(opts) { + if(!full_name) { + if(localStorage) { + localStorage.setItem("last_visited", window.location.pathname); + localStorage.setItem("pending_add_to_cart", opts.item_code); + } + window.location.href = "/login"; + } else { + return frappe.call({ + type: "POST", + method: "erpnext.shopping_cart.cart.update_cart", + args: { + item_code: opts.item_code, + qty: opts.qty, + with_doc: opts.with_doc || 0 + }, + btn: opts.btn, + callback: function(r) { + if(opts.callback) + opts.callback(r); + + shopping_cart.set_cart_count(); + } + }); + } + }, + + set_cart_count: function() { + var cart_count = getCookie("cart_count"); + var $cart = $("#website-post-login").find('[data-label="Cart"]'); + var $badge = $cart.find(".badge"); + var $cog = $("#website-post-login").find(".dropdown-toggle"); + var $cog_count = $cog.find(".cart-count"); + if(cart_count) { + if($badge.length === 0) { + var $badge = $('').prependTo($cart.find("a")); + } + $badge.html(cart_count); + if($cog_count.length === 0) { + var $cog_count = $('').insertAfter($cog.find(".icon-cog")); + } + $cog_count.html(cart_count); + } else { + $badge.remove(); + $cog_count.remove(); + } + } +}); diff --git a/erpnext/shopping_cart/__init__.py b/erpnext/shopping_cart/__init__.py new file mode 100644 index 00000000000..656918b1885 --- /dev/null +++ b/erpnext/shopping_cart/__init__.py @@ -0,0 +1,126 @@ +# Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import get_fullname, flt +from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import is_shopping_cart_enabled, get_default_territory + +# TODO +# validate stock of each item in Website Warehouse or have a list of possible warehouses in Shopping Cart Settings + +def get_quotation(user=None): + if not user: + user = frappe.session.user + if user == "Guest": + raise frappe.PermissionError + + is_shopping_cart_enabled() + party = get_party(user) + values = { + "order_type": "Shopping Cart", + party.doctype.lower(): party.name, + "docstatus": 0, + "contact_email": user + } + + try: + quotation = frappe.get_doc("Quotation", values) + except frappe.DoesNotExistError: + quotation = frappe.new_doc("Quotation") + quotation.update(values) + if party.doctype == "Customer": + quotation.contact_person = frappe.db.get_value("Contact", {"customer": party.name, "email_id": user}) + quotation.insert(ignore_permissions=True) + + return quotation + +def set_item_in_cart(item_code, qty, user=None): + validate_item(item_code) + quotation = get_quotation(user=user) + qty = flt(qty) + quotation_item = quotation.get("quotation_details", {"item_code": item_code}) + + if qty==0: + if quotation_item: + # remove + quotation.get("quotation_details").remove(quotation_item[0]) + else: + # add or update + if quotation_item: + quotation_item[0].qty = qty + else: + quotation.append("quotation_details", { + "doctype": "Quotation Item", + "item_code": item_code, + "qty": qty + }) + + quotation.save(ignore_permissions=True) + return quotation + +def set_address_in_cart(address_fieldname, address, user=None): + quotation = get_quotation(user=user) + validate_address(quotation, address_fieldname, address) + + if quotation.get(address_fieldname) != address: + quotation.set(address_fieldname, address) + if address_fieldname=="customer_address": + quotation.set("address_display", None) + else: + quotation.set("shipping_address", None) + + quotation.save(ignore_permissions=True) + + return quotation + +def validate_item(item_code): + item = frappe.db.get_value("Item", item_code, ["item_name", "show_in_website"], as_dict=True) + if not item.show_in_website: + frappe.throw(_("{0} cannot be purchased using Shopping Cart").format(item.item_name)) + +def validate_address(quotation, address_fieldname, address): + party = get_party(quotation.contact_email) + address_doc = frappe.get_doc(address) + if address_doc.get(party.doctype.lower()) != party.name: + if address_fieldname=="customer_address": + frappe.throw(_("Invalid Billing Address")) + else: + frappe.throw(_("Invalid Shipping Address")) + +def get_party(user): + def _get_party(user): + customer = frappe.db.get_value("Contact", {"email_id": user}, "customer") + if customer: + return frappe.get_doc("Customer", customer) + + lead = frappe.db.get_value("Lead", {"email_id": user}) + if lead: + return frappe.get_doc("Lead", lead) + + # create a lead + lead = frappe.new_doc("Lead") + lead.update({ + "email_id": user, + "lead_name": get_fullname(user), + "territory": guess_territory() + }) + lead.insert(ignore_permissions=True) + + return lead + + if not getattr(frappe.local, "shopping_cart_party", None): + frappe.local.shopping_cart_party = {} + + if not frappe.local.shopping_cart_party.get(user): + frappe.local.shopping_cart_party[user] = _get_party(user) + + return frappe.local.shopping_cart_party[user] + +def guess_territory(): + territory = None + if frappe.session.get("session_country"): + territory = frappe.db.get_value("Territory", frappe.session.get("session_country")) + + return territory or get_default_territory() diff --git a/erpnext/shopping_cart/cart.py b/erpnext/shopping_cart/cart.py new file mode 100644 index 00000000000..e39a54c0708 --- /dev/null +++ b/erpnext/shopping_cart/cart.py @@ -0,0 +1,434 @@ +# Copyright (c) 2013, Web Notes Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe +from frappe import throw, _ +import frappe.defaults +from frappe.utils import flt, get_fullname, fmt_money, cstr +from erpnext.utilities.doctype.address.address import get_address_display +from frappe.utils.nestedset import get_root_of + +class WebsitePriceListMissingError(frappe.ValidationError): pass + +def set_cart_count(quotation=None): + if not quotation: + quotation = _get_cart_quotation() + cart_count = cstr(len(quotation.get("quotation_details"))) + frappe.local.cookie_manager.set_cookie("cart_count", cart_count) + +@frappe.whitelist() +def get_cart_quotation(doc=None): + party = get_lead_or_customer() + + if not doc: + quotation = _get_cart_quotation(party) + doc = quotation + set_cart_count(quotation) + + return { + "doc": decorate_quotation_doc(doc), + "addresses": [{"name": address.name, "display": address.display} + for address in get_address_docs(party)], + "shipping_rules": get_applicable_shipping_rules(party) + } + +@frappe.whitelist() +def place_order(): + quotation = _get_cart_quotation() + quotation.company = frappe.db.get_value("Shopping Cart Settings", None, "company") + for fieldname in ["customer_address", "shipping_address_name"]: + if not quotation.get(fieldname): + throw(_("{0} is required").format(quotation.meta.get_label(fieldname))) + + quotation.ignore_permissions = True + quotation.submit() + + if quotation.lead: + # company used to create customer accounts + frappe.defaults.set_user_default("company", quotation.company) + + from erpnext.selling.doctype.quotation.quotation import _make_sales_order + sales_order = frappe.get_doc(_make_sales_order(quotation.name, ignore_permissions=True)) + for item in sales_order.get("sales_order_details"): + item.reserved_warehouse = frappe.db.get_value("Item", item.item_code, "website_warehouse") or None + + sales_order.ignore_permissions = True + sales_order.insert() + sales_order.submit() + frappe.local.cookie_manager.delete_cookie("cart_count") + + return sales_order.name + +@frappe.whitelist() +def update_cart(item_code, qty, with_doc): + quotation = _get_cart_quotation() + + qty = flt(qty) + if qty == 0: + quotation.set("quotation_details", quotation.get("quotation_details", {"item_code": ["!=", item_code]})) + if not quotation.get("quotation_details") and \ + not quotation.get("__islocal"): + quotation.__delete = True + + else: + quotation_items = quotation.get("quotation_details", {"item_code": item_code}) + if not quotation_items: + quotation.append("quotation_details", { + "doctype": "Quotation Item", + "item_code": item_code, + "qty": qty + }) + else: + quotation_items[0].qty = qty + + apply_cart_settings(quotation=quotation) + + if hasattr(quotation, "__delete"): + frappe.delete_doc("Quotation", quotation.name, ignore_permissions=True) + quotation = _get_cart_quotation() + else: + quotation.ignore_permissions = True + quotation.save() + + set_cart_count(quotation) + + if with_doc: + return get_cart_quotation(quotation) + else: + return quotation.name + +@frappe.whitelist() +def update_cart_address(address_fieldname, address_name): + quotation = _get_cart_quotation() + address_display = get_address_display(frappe.get_doc("Address", address_name).as_dict()) + + if address_fieldname == "shipping_address_name": + quotation.shipping_address_name = address_name + quotation.shipping_address = address_display + + if not quotation.customer_address: + address_fieldname == "customer_address" + + if address_fieldname == "customer_address": + quotation.customer_address = address_name + quotation.address_display = address_display + + + apply_cart_settings(quotation=quotation) + + quotation.ignore_permissions = True + quotation.save() + + return get_cart_quotation(quotation) + +def guess_territory(): + territory = None + geoip_country = frappe.session.get("session_country") + if geoip_country: + territory = frappe.db.get_value("Territory", geoip_country) + + return territory or \ + frappe.db.get_value("Shopping Cart Settings", None, "territory") or \ + get_root_of("Territory") + +def decorate_quotation_doc(quotation_doc): + doc = frappe._dict(quotation_doc.as_dict()) + for d in doc.get("quotation_details", []): + d.update(frappe.db.get_value("Item", d["item_code"], + ["website_image", "description", "page_name"], as_dict=True)) + d["formatted_rate"] = fmt_money(d.get("rate"), currency=doc.currency) + d["formatted_amount"] = fmt_money(d.get("amount"), currency=doc.currency) + + for d in doc.get("other_charges", []): + d["formatted_tax_amount"] = fmt_money(flt(d.get("tax_amount")) / doc.conversion_rate, + currency=doc.currency) + + doc.formatted_grand_total_export = fmt_money(doc.grand_total_export, + currency=doc.currency) + + return doc + +def _get_cart_quotation(party=None): + if not party: + party = get_lead_or_customer() + + quotation = frappe.db.get_value("Quotation", + {party.doctype.lower(): party.name, "order_type": "Shopping Cart", "docstatus": 0}) + + if quotation: + qdoc = frappe.get_doc("Quotation", quotation) + else: + qdoc = frappe.get_doc({ + "doctype": "Quotation", + "naming_series": frappe.defaults.get_user_default("shopping_cart_quotation_series") or "QTN-CART-", + "quotation_to": party.doctype, + "company": frappe.db.get_value("Shopping Cart Settings", None, "company"), + "order_type": "Shopping Cart", + "status": "Draft", + "docstatus": 0, + "__islocal": 1, + (party.doctype.lower()): party.name + }) + + if party.doctype == "Customer": + qdoc.contact_person = frappe.db.get_value("Contact", {"email_id": frappe.session.user, + "customer": party.name}) + + qdoc.ignore_permissions = True + qdoc.run_method("set_missing_values") + apply_cart_settings(party, qdoc) + + return qdoc + +def update_party(fullname, company_name=None, mobile_no=None, phone=None): + party = get_lead_or_customer() + + if party.doctype == "Lead": + party.company_name = company_name + party.lead_name = fullname + party.mobile_no = mobile_no + party.phone = phone + else: + party.customer_name = company_name or fullname + party.customer_type == "Company" if company_name else "Individual" + + contact_name = frappe.db.get_value("Contact", {"email_id": frappe.session.user, + "customer": party.name}) + contact = frappe.get_doc("Contact", contact_name) + contact.first_name = fullname + contact.last_name = None + contact.customer_name = party.customer_name + contact.mobile_no = mobile_no + contact.phone = phone + contact.ignore_permissions = True + contact.save() + + party_doc = frappe.get_doc(party.as_dict()) + party_doc.ignore_permissions = True + party_doc.save() + + qdoc = _get_cart_quotation(party) + if not qdoc.get("__islocal"): + qdoc.customer_name = company_name or fullname + qdoc.run_method("set_missing_lead_customer_details") + qdoc.ignore_permissions = True + qdoc.save() + +def apply_cart_settings(party=None, quotation=None): + if not party: + party = get_lead_or_customer() + if not quotation: + quotation = _get_cart_quotation(party) + + cart_settings = frappe.get_doc("Shopping Cart Settings") + + billing_territory = get_address_territory(quotation.customer_address) or \ + party.territory or get_root_of("Territory") + + set_price_list_and_rate(quotation, cart_settings, billing_territory) + + quotation.run_method("calculate_taxes_and_totals") + + set_taxes(quotation, cart_settings, billing_territory) + + _apply_shipping_rule(party, quotation, cart_settings) + +def set_price_list_and_rate(quotation, cart_settings, billing_territory): + """set price list based on billing territory""" + quotation.selling_price_list = cart_settings.get_price_list(billing_territory) + + # reset values + quotation.price_list_currency = quotation.currency = \ + quotation.plc_conversion_rate = quotation.conversion_rate = None + for item in quotation.get("quotation_details"): + item.price_list_rate = item.discount_percentage = item.rate = item.amount = None + + # refetch values + quotation.run_method("set_price_list_and_item_details") + + # set it in cookies for using in product page + frappe.local.cookie_manager.set_cookie("selling_price_list", quotation.selling_price_list) + +def set_taxes(quotation, cart_settings, billing_territory): + """set taxes based on billing territory""" + quotation.taxes_and_charges = cart_settings.get_tax_master(billing_territory) + + # clear table + quotation.set("other_charges", []) + + # append taxes + quotation.append_taxes_from_master("other_charges", "taxes_and_charges") + +def get_lead_or_customer(): + customer = frappe.db.get_value("Contact", {"email_id": frappe.session.user}, "customer") + if customer: + return frappe.get_doc("Customer", customer) + + lead = frappe.db.get_value("Lead", {"email_id": frappe.session.user}) + if lead: + return frappe.get_doc("Lead", lead) + else: + lead_doc = frappe.get_doc({ + "doctype": "Lead", + "email_id": frappe.session.user, + "lead_name": get_fullname(frappe.session.user), + "territory": guess_territory(), + "status": "Open" # TODO: set something better??? + }) + + if frappe.session.user not in ("Guest", "Administrator"): + lead_doc.ignore_permissions = True + lead_doc.insert() + + return lead_doc + +def get_address_docs(party=None): + if not party: + party = get_lead_or_customer() + + address_docs = frappe.db.sql("""select * from `tabAddress` + where `%s`=%s order by name""" % (party.doctype.lower(), "%s"), party.name, + as_dict=True, update={"doctype": "Address"}) + + for address in address_docs: + address.display = get_address_display(address) + address.display = (address.display).replace("\n", "%(description)s
\ +at %(formatted_rate)s
\ + = %(formatted_amount)s\ +%(description)s
') + + '%(formatted_tax_amount)s
\ +| Sr | +Item Name | +Description | +Qty | +UoM | +Basic Rate | +Amount | +
|---|---|---|---|---|---|---|
| {{ row.idx }} | +{{ row.item_name }} | +{{ row.description }} | +{{ row.qty }} | +{{ row.stock_uom }} | +{{ frappe.utils.fmt_money(row.rate, currency=doc.currency) }} | +{{ frappe.utils.fmt_money(row.amount, currency=doc.currency) }} | +
| Net Total | +{{ + frappe.utils.fmt_money(doc.net_total/doc.conversion_rate, currency=doc.currency) + }} | +
| {{ charge.description }} | +{{ frappe.utils.fmt_money(charge.tax_amount / doc.conversion_rate, currency=doc.currency) }} | +
| Grand Total | +{{ frappe.utils.fmt_money(doc.grand_total_export, currency=doc.currency) }} | +
| Rounded Total | +{{ frappe.utils.fmt_money(doc.rounded_total_export, currency=doc.currency) }} | +