Merge pull request #29549 from frappe/mergify/bp/develop/pr-29290
fix: billed amount in delivery note items (backport #29290)
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index 9031968..eab6d50 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -8,7 +8,10 @@
pip install frappe-bench
-git clone https://github.com/frappe/frappe --branch "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" --depth 1
+frappeuser=${FRAPPE_USER:-"frappe"}
+frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}}
+
+git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site
diff --git a/.github/workflows/server-tests-mariadb.yml b/.github/workflows/server-tests-mariadb.yml
index 7347a58..40f9365 100644
--- a/.github/workflows/server-tests-mariadb.yml
+++ b/.github/workflows/server-tests-mariadb.yml
@@ -6,12 +6,23 @@
- '**.js'
- '**.md'
- '**.html'
- workflow_dispatch:
push:
branches: [ develop ]
paths-ignore:
- '**.js'
- '**.md'
+ workflow_dispatch:
+ inputs:
+ user:
+ description: 'user'
+ required: true
+ default: 'frappe'
+ type: string
+ branch:
+ description: 'Branch name'
+ default: 'develop'
+ required: false
+ type: string
concurrency:
group: server-mariadb-develop-${{ github.event.number }}
@@ -95,6 +106,8 @@
env:
DB: mariadb
TYPE: server
+ FRAPPE_USER: ${{ github.event.inputs.user }}
+ FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage
diff --git a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
index 55ea571..9a35a24 100644
--- a/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
+++ b/erpnext/accounts/doctype/accounts_settings/accounts_settings.json
@@ -7,35 +7,30 @@
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "accounts_transactions_settings_section",
- "over_billing_allowance",
- "role_allowed_to_over_bill",
- "credit_controller",
- "make_payment_via_journal_entry",
- "column_break_11",
- "check_supplier_invoice_uniqueness",
+ "invoice_and_billing_tab",
+ "enable_features_section",
"unlink_payment_on_cancellation_of_invoice",
- "automatically_fetch_payment_terms",
- "delete_linked_ledger_entries",
- "book_asset_depreciation_entry_automatically",
"unlink_advance_payment_on_cancelation_of_order",
+ "column_break_13",
+ "delete_linked_ledger_entries",
+ "invoicing_features_section",
+ "check_supplier_invoice_uniqueness",
+ "automatically_fetch_payment_terms",
+ "column_break_17",
"enable_common_party_accounting",
- "post_change_gl_entries",
"enable_discount_accounting",
- "tax_settings_section",
- "determine_address_tax_category_from",
- "column_break_19",
- "add_taxes_from_item_tax_template",
- "period_closing_settings_section",
- "acc_frozen_upto",
- "frozen_accounts_modifier",
- "column_break_4",
+ "report_setting_section",
+ "use_custom_cash_flow",
"deferred_accounting_settings_section",
"book_deferred_entries_based_on",
"column_break_18",
"automatically_process_deferred_accounting_entry",
"book_deferred_entries_via_journal_entry",
"submit_journal_entries",
+ "tax_settings_section",
+ "determine_address_tax_category_from",
+ "column_break_19",
+ "add_taxes_from_item_tax_template",
"print_settings",
"show_inclusive_tax_in_print",
"column_break_12",
@@ -43,8 +38,25 @@
"currency_exchange_section",
"allow_stale",
"stale_days",
- "report_settings_sb",
- "use_custom_cash_flow"
+ "invoicing_settings_tab",
+ "accounts_transactions_settings_section",
+ "over_billing_allowance",
+ "column_break_11",
+ "role_allowed_to_over_bill",
+ "credit_controller",
+ "make_payment_via_journal_entry",
+ "pos_tab",
+ "pos_setting_section",
+ "post_change_gl_entries",
+ "assets_tab",
+ "asset_settings_section",
+ "book_asset_depreciation_entry_automatically",
+ "closing_settings_tab",
+ "period_closing_settings_section",
+ "acc_frozen_upto",
+ "column_break_25",
+ "frozen_accounts_modifier",
+ "report_settings_sb"
],
"fields": [
{
@@ -71,10 +83,6 @@
"options": "Billing Address\nShipping Address"
},
{
- "fieldname": "column_break_4",
- "fieldtype": "Column Break"
- },
- {
"fieldname": "credit_controller",
"fieldtype": "Link",
"in_list_view": 1,
@@ -83,6 +91,7 @@
},
{
"default": "0",
+ "description": "Enabling ensure each Sales Invoice has a unique value in Supplier Invoice No. field",
"fieldname": "check_supplier_invoice_uniqueness",
"fieldtype": "Check",
"label": "Check Supplier Invoice Number Uniqueness"
@@ -168,7 +177,7 @@
"description": "Only select this if you have set up the Cash Flow Mapper documents",
"fieldname": "use_custom_cash_flow",
"fieldtype": "Check",
- "label": "Use Custom Cash Flow Format"
+ "label": "Enable Custom Cash Flow Format"
},
{
"default": "0",
@@ -241,7 +250,7 @@
{
"fieldname": "accounts_transactions_settings_section",
"fieldtype": "Section Break",
- "label": "Transactions Settings"
+ "label": "Credit Limit Settings"
},
{
"fieldname": "column_break_11",
@@ -272,9 +281,72 @@
},
{
"default": "0",
+ "description": "Learn about <a href=\"https://docs.erpnext.com/docs/v13/user/manual/en/accounts/articles/common_party_accounting#:~:text=Common%20Party%20Accounting%20in%20ERPNext,Invoice%20against%20a%20primary%20Supplier.\">Common Party</a>",
"fieldname": "enable_common_party_accounting",
"fieldtype": "Check",
"label": "Enable Common Party Accounting"
+ },
+ {
+ "fieldname": "enable_features_section",
+ "fieldtype": "Section Break",
+ "label": "Invoice Cancellation"
+ },
+ {
+ "fieldname": "column_break_13",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_25",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "asset_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Asset Settings"
+ },
+ {
+ "fieldname": "invoicing_settings_tab",
+ "fieldtype": "Tab Break",
+ "label": "Credit Limits"
+ },
+ {
+ "fieldname": "assets_tab",
+ "fieldtype": "Tab Break",
+ "label": "Assets"
+ },
+ {
+ "fieldname": "closing_settings_tab",
+ "fieldtype": "Tab Break",
+ "label": "Accounts Closing"
+ },
+ {
+ "fieldname": "pos_setting_section",
+ "fieldtype": "Section Break",
+ "label": "POS Setting"
+ },
+ {
+ "fieldname": "invoice_and_billing_tab",
+ "fieldtype": "Tab Break",
+ "label": "Invoice and Billing"
+ },
+ {
+ "fieldname": "invoicing_features_section",
+ "fieldtype": "Section Break",
+ "label": "Invoicing Features"
+ },
+ {
+ "fieldname": "column_break_17",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "pos_tab",
+ "fieldtype": "Tab Break",
+ "label": "POS"
+ },
+ {
+ "fieldname": "report_setting_section",
+ "fieldtype": "Section Break",
+ "label": "Report Setting"
}
],
"icon": "icon-cog",
@@ -282,7 +354,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-10-11 17:42:36.427699",
+ "modified": "2022-02-04 12:32:36.805652",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Accounts Settings",
@@ -309,5 +381,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
index 4b871ae..7e50962 100644
--- a/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
+++ b/erpnext/accounts/doctype/cost_center_allocation_percentage/cost_center_allocation_percentage.json
@@ -1,7 +1,7 @@
{
"actions": [],
"allow_rename": 1,
- "creation": "2022-01-03 18:10:11.697198",
+ "creation": "2022-01-13 20:07:30.096306",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -20,7 +20,7 @@
},
{
"fieldname": "percentage",
- "fieldtype": "Int",
+ "fieldtype": "Percent",
"in_list_view": 1,
"label": "Percentage (%)",
"reqd": 1
@@ -29,7 +29,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2022-01-03 18:10:20.029821",
+ "modified": "2022-02-01 22:22:31.589523",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center Allocation Percentage",
diff --git a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py
index 5ba0691..ca482c8 100644
--- a/erpnext/accounts/doctype/coupon_code/test_coupon_code.py
+++ b/erpnext/accounts/doctype/coupon_code/test_coupon_code.py
@@ -39,9 +39,6 @@
"selling_cost_center": "Main - _TC",
"income_account": "Sales - _TC"
}],
- "show_in_website": 1,
- "route":"-test-tesla-car",
- "website_warehouse": "Stores - _TC"
})
item.insert()
# create test item price
diff --git a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json
index 77c9e95..b42d712 100644
--- a/erpnext/accounts/doctype/item_tax_template/item_tax_template.json
+++ b/erpnext/accounts/doctype/item_tax_template/item_tax_template.json
@@ -2,7 +2,7 @@
"actions": [],
"allow_import": 1,
"allow_rename": 1,
- "creation": "2018-11-22 22:45:00.370913",
+ "creation": "2022-01-19 01:09:13.297137",
"doctype": "DocType",
"document_type": "Setup",
"editable_grid": 1,
@@ -10,6 +10,9 @@
"field_order": [
"title",
"company",
+ "column_break_3",
+ "disabled",
+ "section_break_5",
"taxes"
],
"fields": [
@@ -36,10 +39,24 @@
"label": "Company",
"options": "Company",
"reqd": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
+ },
+ {
+ "fieldname": "section_break_5",
+ "fieldtype": "Section Break"
}
],
"links": [],
- "modified": "2021-03-08 19:50:21.416513",
+ "modified": "2022-01-18 21:11:23.105589",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Item Tax Template",
@@ -82,6 +99,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "title",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/journal_entry/journal_entry.js b/erpnext/accounts/doctype/journal_entry/journal_entry.js
index 617b376..3cc28a3 100644
--- a/erpnext/accounts/doctype/journal_entry/journal_entry.js
+++ b/erpnext/accounts/doctype/journal_entry/journal_entry.js
@@ -8,6 +8,7 @@
frappe.ui.form.on("Journal Entry", {
setup: function(frm) {
frm.add_fetch("bank_account", "account", "account");
+ frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice'];
},
refresh: function(frm) {
diff --git a/erpnext/accounts/doctype/payment_request/payment_request.py b/erpnext/accounts/doctype/payment_request/payment_request.py
index 6a84a65..d72d8f7 100644
--- a/erpnext/accounts/doctype/payment_request/payment_request.py
+++ b/erpnext/accounts/doctype/payment_request/payment_request.py
@@ -291,7 +291,7 @@
if not status:
return
- shopping_cart_settings = frappe.get_doc("Shopping Cart Settings")
+ shopping_cart_settings = frappe.get_doc("E Commerce Settings")
if status in ["Authorized", "Completed"]:
redirect_to = None
@@ -435,13 +435,13 @@
""", (ref_dt, ref_dn))
return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0
-def get_gateway_details(args):
+def get_gateway_details(args): # nosemgrep
"""return gateway and payment account of default payment gateway"""
if args.get("payment_gateway_account"):
return get_payment_gateway_account(args.get("payment_gateway_account"))
if args.order_type == "Shopping Cart":
- payment_gateway_account = frappe.get_doc("Shopping Cart Settings").payment_gateway_account
+ payment_gateway_account = frappe.get_doc("E Commerce Settings").payment_gateway_account
return get_payment_gateway_account(payment_gateway_account)
gateway_account = get_payment_gateway_account({"is_default": 1})
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index f66abdc..97d34e0 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -42,7 +42,6 @@
self.validate_serialised_or_batched_item()
self.validate_stock_availablility()
self.validate_return_items_qty()
- self.validate_non_stock_items()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
@@ -175,9 +174,11 @@
def validate_stock_availablility(self):
if self.is_return or self.docstatus != 1:
return
-
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
+ is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
+ if is_service_item:
+ return
if d.serial_no:
self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d)
@@ -188,7 +189,7 @@
if allow_negative_stock:
return
- available_stock = get_stock_availability(d.item_code, d.warehouse)
+ available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0:
@@ -259,14 +260,6 @@
.format(d.idx, bold_serial_no, bold_return_against)
)
- def validate_non_stock_items(self):
- for d in self.get("items"):
- is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
- if not is_stock_item:
- if not frappe.db.exists('Product Bundle', d.item_code):
- frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
- .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
-
def validate_mode_of_payment(self):
if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
@@ -506,12 +499,18 @@
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
+ is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
- return bin_qty - pos_sales_qty
+ return bin_qty - pos_sales_qty, is_stock_item
else:
+ is_stock_item = False
if frappe.db.exists('Product Bundle', item_code):
- return get_bundle_availability(item_code, warehouse)
+ return get_bundle_availability(item_code, warehouse), is_stock_item
+ else:
+ # Is a service item
+ return 0, is_stock_item
+
def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)
diff --git a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
index b364218..76d9cc7 100644
--- a/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/purchase_invoice.py
@@ -537,8 +537,11 @@
voucher_wise_stock_value = {}
if self.update_stock:
- for d in frappe.get_all('Stock Ledger Entry',
- fields = ["voucher_detail_no", "stock_value_difference", "warehouse"], filters={'voucher_no': self.name}):
+ stock_ledger_entries = frappe.get_all("Stock Ledger Entry",
+ fields = ["voucher_detail_no", "stock_value_difference", "warehouse"],
+ filters={"voucher_no": self.name, "voucher_type": self.doctype, "is_cancelled": 0}
+ )
+ for d in stock_ledger_entries:
voucher_wise_stock_value.setdefault((d.voucher_detail_no, d.warehouse), d.stock_value_difference)
valuation_tax_accounts = [d.account_head for d in self.get("taxes")
@@ -548,6 +551,10 @@
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
enable_discount_accounting = cint(frappe.db.get_single_value('Accounts Settings', 'enable_discount_accounting'))
+ provisional_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, \
+ 'enable_provisional_accounting_for_non_stock_items'))
+
+ purchase_receipt_doc_map = {}
for item in self.get("items"):
if flt(item.base_net_amount):
@@ -643,19 +650,23 @@
else:
amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount"))
- auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
-
- if auto_accounting_for_non_stock_items:
- service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
-
+ if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
+ provisional_account = self.get_company_default("default_provisional_account")
+ purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
+
+ if not purchase_receipt_doc:
+ purchase_receipt_doc = frappe.get_doc("Purchase Receipt", item.purchase_receipt)
+ purchase_receipt_doc_map[item.purchase_receipt] = purchase_receipt_doc
+
# Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt
expense_booked_in_pr = frappe.db.get_value('GL Entry', {'is_cancelled': 0,
'voucher_type': 'Purchase Receipt', 'voucher_no': item.purchase_receipt, 'voucher_detail_no': item.pr_detail,
- 'account':service_received_but_not_billed_account}, ['name'])
+ 'account':provisional_account}, ['name'])
if expense_booked_in_pr:
- expense_account = service_received_but_not_billed_account
+ # Intentionally passing purchase invoice item to handle partial billing
+ purchase_receipt_doc.add_provisional_gl_entry(item, gl_entries, self.posting_date, reverse=1)
if not self.is_internal_transfer():
gl_entries.append(self.get_gl_dict({
diff --git a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
index 21846bb..d51a008 100644
--- a/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
+++ b/erpnext/accounts/doctype/purchase_invoice/test_purchase_invoice.py
@@ -11,12 +11,17 @@
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
+from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
+from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.controllers.accounts_controller import get_payment_terms
from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
+ make_purchase_invoice as create_purchase_invoice_from_receipt,
+)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_taxes,
make_purchase_receipt,
@@ -1147,8 +1152,6 @@
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
- from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
- from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
# create a new supplier to test
supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier',
@@ -1221,6 +1224,45 @@
payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
+ def test_provisional_accounting_entry(self):
+ item = create_item("_Test Non Stock Item", is_stock_item=0)
+ provisional_account = create_account(account_name="Provision Account",
+ parent_account="Current Liabilities - _TC", company="_Test Company")
+
+ company = frappe.get_doc('Company', '_Test Company')
+ company.enable_provisional_accounting_for_non_stock_items = 1
+ company.default_provisional_account = provisional_account
+ company.save()
+
+ pr = make_purchase_receipt(item_code="_Test Non Stock Item", posting_date=add_days(nowdate(), -2))
+
+ pi = create_purchase_invoice_from_receipt(pr.name)
+ pi.set_posting_time = 1
+ pi.posting_date = add_days(pr.posting_date, -1)
+ pi.items[0].expense_account = 'Cost of Goods Sold - _TC'
+ pi.save()
+ pi.submit()
+
+ # Check GLE for Purchase Invoice
+ expected_gle = [
+ ['Cost of Goods Sold - _TC', 250, 0, add_days(pr.posting_date, -1)],
+ ['Creditors - _TC', 0, 250, add_days(pr.posting_date, -1)]
+ ]
+
+ check_gl_entries(self, pi.name, expected_gle, pi.posting_date)
+
+ expected_gle_for_purchase_receipt = [
+ ["Provision Account - _TC", 250, 0, pr.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 0, 250, pr.posting_date],
+ ["Provision Account - _TC", 0, 250, pi.posting_date],
+ ["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date]
+ ]
+
+ check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
+
+ company.enable_provisional_accounting_for_non_stock_items = 0
+ company.save()
+
def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql("""select account, debit, credit, posting_date
from `tabGL Entry`
diff --git a/erpnext/accounts/doctype/tax_category/tax_category.json b/erpnext/accounts/doctype/tax_category/tax_category.json
index f7145af..44a339f 100644
--- a/erpnext/accounts/doctype/tax_category/tax_category.json
+++ b/erpnext/accounts/doctype/tax_category/tax_category.json
@@ -2,12 +2,13 @@
"actions": [],
"allow_rename": 1,
"autoname": "field:title",
- "creation": "2018-11-22 23:38:39.668804",
+ "creation": "2022-01-19 01:09:28.920486",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
- "title"
+ "title",
+ "disabled"
],
"fields": [
{
@@ -18,14 +19,21 @@
"label": "Title",
"reqd": 1,
"unique": 1
+ },
+ {
+ "default": "0",
+ "fieldname": "disabled",
+ "fieldtype": "Check",
+ "label": "Disabled"
}
],
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2021-03-03 11:50:38.748872",
+ "modified": "2022-01-18 21:13:41.161017",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Tax Category",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -65,5 +73,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/tax_rule/tax_rule.py b/erpnext/accounts/doctype/tax_rule/tax_rule.py
index a16377c..2d94bc3 100644
--- a/erpnext/accounts/doctype/tax_rule/tax_rule.py
+++ b/erpnext/accounts/doctype/tax_rule/tax_rule.py
@@ -98,7 +98,7 @@
def validate_use_for_shopping_cart(self):
'''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one'''
if (not self.use_for_shopping_cart
- and cint(frappe.db.get_single_value('Shopping Cart Settings', 'enabled'))
+ and cint(frappe.db.get_single_value('E Commerce Settings', 'enabled'))
and not frappe.db.get_value('Tax Rule', {'use_for_shopping_cart': 1, 'name': ['!=', self.name]})):
self.use_for_shopping_cart = 1
diff --git a/erpnext/accounts/report/balance_sheet/balance_sheet.py b/erpnext/accounts/report/balance_sheet/balance_sheet.py
index dc1f7aa..f10a5ea 100644
--- a/erpnext/accounts/report/balance_sheet/balance_sheet.py
+++ b/erpnext/accounts/report/balance_sheet/balance_sheet.py
@@ -120,11 +120,11 @@
opening_balance = 0
float_precision = cint(frappe.db.get_default("float_precision")) or 2
if asset:
- opening_balance = flt(asset[0].get("opening_balance", 0), float_precision)
+ opening_balance = flt(asset[-1].get("opening_balance", 0), float_precision)
if liability:
- opening_balance -= flt(liability[0].get("opening_balance", 0), float_precision)
+ opening_balance -= flt(liability[-1].get("opening_balance", 0), float_precision)
if equity:
- opening_balance -= flt(equity[0].get("opening_balance", 0), float_precision)
+ opening_balance -= flt(equity[-1].get("opening_balance", 0), float_precision)
opening_balance = flt(opening_balance, float_precision)
if opening_balance:
diff --git a/erpnext/accounts/report/financial_statements.py b/erpnext/accounts/report/financial_statements.py
index 03ae0ae..db28cdf 100644
--- a/erpnext/accounts/report/financial_statements.py
+++ b/erpnext/accounts/report/financial_statements.py
@@ -282,7 +282,8 @@
total_row = {
"account_name": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
"account": _("Total {0} ({1})").format(_(root_type), _(balance_must_be)),
- "currency": company_currency
+ "currency": company_currency,
+ "opening_balance": 0.0
}
for row in out:
@@ -294,6 +295,7 @@
total_row.setdefault("total", 0.0)
total_row["total"] += flt(row["total"])
+ total_row["opening_balance"] += row["opening_balance"]
row["total"] = ""
if "total" in total_row:
diff --git a/erpnext/buying/doctype/buying_settings/buying_settings.json b/erpnext/buying/doctype/buying_settings/buying_settings.json
index b828a43..50321ba 100644
--- a/erpnext/buying/doctype/buying_settings/buying_settings.json
+++ b/erpnext/buying/doctype/buying_settings/buying_settings.json
@@ -6,14 +6,17 @@
"document_type": "Other",
"engine": "InnoDB",
"field_order": [
+ "supplier_and_price_defaults_section",
"supp_master_name",
"supplier_group",
+ "column_break_4",
"buying_price_list",
"maintain_same_rate_action",
"role_to_override_stop_action",
- "column_break_3",
+ "transaction_settings_section",
"po_required",
"pr_required",
+ "column_break_12",
"maintain_same_rate",
"allow_multiple_items",
"bill_for_rejected_quantity_in_purchase_invoice",
@@ -43,10 +46,6 @@
"options": "Price List"
},
{
- "fieldname": "column_break_3",
- "fieldtype": "Column Break"
- },
- {
"fieldname": "po_required",
"fieldtype": "Select",
"label": "Is Purchase Order Required for Purchase Invoice & Receipt Creation?",
@@ -73,7 +72,7 @@
{
"fieldname": "subcontract",
"fieldtype": "Section Break",
- "label": "Subcontract"
+ "label": "Subcontracting Settings"
},
{
"default": "Material Transferred for Subcontract",
@@ -116,6 +115,24 @@
"fieldname": "bill_for_rejected_quantity_in_purchase_invoice",
"fieldtype": "Check",
"label": "Bill for Rejected Quantity in Purchase Invoice"
+ },
+ {
+ "fieldname": "supplier_and_price_defaults_section",
+ "fieldtype": "Section Break",
+ "label": "Supplier and Price Defaults"
+ },
+ {
+ "fieldname": "column_break_4",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "transaction_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Transaction Settings"
+ },
+ {
+ "fieldname": "column_break_12",
+ "fieldtype": "Column Break"
}
],
"icon": "fa fa-cog",
@@ -123,7 +140,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2021-09-08 19:26:23.548837",
+ "modified": "2022-01-27 17:57:58.367048",
"modified_by": "Administrator",
"module": "Buying",
"name": "Buying Settings",
@@ -141,5 +158,6 @@
],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/buying/doctype/supplier/supplier.py b/erpnext/buying/doctype/supplier/supplier.py
index 14d2ccd..4f9ff43 100644
--- a/erpnext/buying/doctype/supplier/supplier.py
+++ b/erpnext/buying/doctype/supplier/supplier.py
@@ -131,28 +131,6 @@
if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name':
frappe.db.set(self, "supplier_name", newdn)
- 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')):
- supplier = args.get('supplier_name_' + str(i))
- if supplier:
- try:
- doc = frappe.get_doc({
- 'doctype': self.doctype,
- 'supplier_name': supplier,
- 'supplier_group': _('Local'),
- 'company': company
- }).insert()
-
- if args.get('supplier_email_' + str(i)):
- from erpnext.selling.doctype.customer.customer import create_contact
- create_contact(supplier, 'Supplier',
- doc.name, args.get('supplier_email_' + str(i)))
- except frappe.NameError:
- pass
-
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters):
diff --git a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json b/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json
deleted file mode 100644
index ce3d8cf..0000000
--- a/erpnext/buying/onboarding_slide/add_a_few_suppliers/add_a_few_suppliers.json
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "add_more_button": 1,
- "app": "ERPNext",
- "creation": "2019-11-15 14:45:32.626641",
- "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:18.452038",
- "modified_by": "Administrator",
- "name": "Add A Few Suppliers",
- "owner": "Administrator",
- "ref_doctype": "Supplier",
- "slide_desc": "",
- "slide_fields": [
- {
- "align": "",
- "fieldname": "supplier_name",
- "fieldtype": "Data",
- "label": "Supplier Name",
- "placeholder": "",
- "reqd": 1
- },
- {
- "align": "",
- "fieldtype": "Column Break",
- "reqd": 0
- },
- {
- "align": "",
- "fieldname": "supplier_email",
- "fieldtype": "Data",
- "label": "Supplier Email",
- "reqd": 1
- }
- ],
- "slide_order": 50,
- "slide_title": "Add A Few Suppliers",
- "slide_type": "Create"
-}
\ No newline at end of file
diff --git a/erpnext/controllers/item_variant.py b/erpnext/controllers/item_variant.py
index 2bad6f8..68ad702 100644
--- a/erpnext/controllers/item_variant.py
+++ b/erpnext/controllers/item_variant.py
@@ -132,7 +132,7 @@
conditions = " or ".join(conditions)
- from erpnext.portal.product_configurator.utils import get_item_codes_by_attributes
+ from erpnext.e_commerce.variant_selector.utils import get_item_codes_by_attributes
possible_variants = [i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code]
for variant in possible_variants:
@@ -262,9 +262,8 @@
def copy_attributes_to_variant(item, variant):
# copy non no-copy fields
- exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website",
- "show_variant_in_website", "opening_stock", "variant_of", "valuation_rate",
- "has_variants", "attributes"]
+ exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website",
+ "opening_stock", "variant_of", "valuation_rate"]
if item.variant_based_on=='Manufacturer':
# don't copy manufacturer values if based on part no
diff --git a/erpnext/controllers/queries.py b/erpnext/controllers/queries.py
index dc04dab..dd9b45c 100644
--- a/erpnext/controllers/queries.py
+++ b/erpnext/controllers/queries.py
@@ -249,6 +249,9 @@
del filters['customer']
else:
del filters['supplier']
+ else:
+ filters.pop('customer', None)
+ filters.pop('supplier', None)
description_cond = ''
@@ -707,6 +710,7 @@
item_doc = frappe.get_cached_doc('Item', filters.get('item_code'))
item_group = filters.get('item_group')
+ company = filters.get('company')
taxes = item_doc.taxes or []
while item_group:
@@ -715,7 +719,7 @@
item_group = item_group_doc.parent_item_group
if not taxes:
- return frappe.db.sql(""" SELECT name FROM `tabItem Tax Template` """)
+ return frappe.get_all('Item Tax Template', filters={'disabled': 0, 'company': company}, as_list=True)
else:
valid_from = filters.get('valid_from')
valid_from = valid_from[1] if isinstance(valid_from, list) else valid_from
@@ -724,7 +728,7 @@
'item_code': filters.get('item_code'),
'posting_date': valid_from,
'tax_category': filters.get('tax_category'),
- 'company': filters.get('company')
+ 'company': company
}
taxes = _get_item_tax_template(args, taxes, for_validate=True)
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 4ff851d..31b2209 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -74,7 +74,8 @@
doctype=self.doctype, company=self.company,
posting_date=self.get('posting_date'),
fetch_payment_terms_template=fetch_payment_terms_template,
- party_address=self.customer_address, shipping_address=self.shipping_address_name)
+ party_address=self.customer_address, shipping_address=self.shipping_address_name,
+ company_address=self.get('company_address'))
if not self.meta.get_field("sales_team"):
party_details.pop("sales_team")
self.update_if_missing(party_details)
@@ -204,7 +205,7 @@
valuation_rate_map = {}
for item in self.items:
- if not item.item_code:
+ if not item.item_code or item.is_free_item:
continue
last_purchase_rate, is_stock_item = frappe.get_cached_value(
@@ -251,7 +252,7 @@
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
for item in self.items:
- if not item.item_code:
+ if not item.item_code or item.is_free_item:
continue
last_valuation_rate = valuation_rate_map.get(
diff --git a/erpnext/controllers/status_updater.py b/erpnext/controllers/status_updater.py
index 76a7cda..affde4a 100644
--- a/erpnext/controllers/status_updater.py
+++ b/erpnext/controllers/status_updater.py
@@ -400,6 +400,16 @@
ref_doc = frappe.get_doc(ref_dt, ref_dn)
ref_doc.db_set("per_billed", per_billed)
+
+ # set billling status
+ if hasattr(ref_doc, 'billing_status'):
+ if ref_doc.per_billed < 0.001:
+ ref_doc.db_set("billing_status", "Not Billed")
+ elif ref_doc.per_billed > 99.999999:
+ ref_doc.db_set("billing_status", "Fully Billed")
+ else:
+ ref_doc.db_set("billing_status", "Partly Billed")
+
ref_doc.set_status(update=True)
def get_allowance_for(item_code, item_allowance=None, global_qty_allowance=None, global_amount_allowance=None, qty_or_amount="qty"):
diff --git a/erpnext/controllers/stock_controller.py b/erpnext/controllers/stock_controller.py
index 2912d3e..9be5c0d 100644
--- a/erpnext/controllers/stock_controller.py
+++ b/erpnext/controllers/stock_controller.py
@@ -3,6 +3,7 @@
import json
from collections import defaultdict
+from typing import List, Tuple
import frappe
from frappe import _
@@ -40,7 +41,10 @@
if self.docstatus == 2:
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
- if cint(erpnext.is_perpetual_inventory_enabled(self.company)):
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
+
+ if cint(erpnext.is_perpetual_inventory_enabled(self.company)) or provisional_accounting_for_non_stock_items:
warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus==1:
@@ -178,33 +182,28 @@
return details
- def get_items_and_warehouses(self):
- items, warehouses = [], []
+ def get_items_and_warehouses(self) -> Tuple[List[str], List[str]]:
+ """Get list of items and warehouses affected by a transaction"""
- if hasattr(self, "items"):
- item_doclist = self.get("items")
- elif self.doctype == "Stock Reconciliation":
- item_doclist = []
- data = json.loads(self.reconciliation_json)
- for row in data[data.index(self.head_row)+1:]:
- d = frappe._dict(zip(["item_code", "warehouse", "qty", "valuation_rate"], row))
- item_doclist.append(d)
+ if not (hasattr(self, "items") or hasattr(self, "packed_items")):
+ return [], []
- if item_doclist:
- for d in item_doclist:
- if d.item_code and d.item_code not in items:
- items.append(d.item_code)
+ item_rows = (self.get("items") or []) + (self.get("packed_items") or [])
- if d.get("warehouse") and d.warehouse not in warehouses:
- warehouses.append(d.warehouse)
+ items = {d.item_code for d in item_rows if d.item_code}
- if self.doctype == "Stock Entry":
- if d.get("s_warehouse") and d.s_warehouse not in warehouses:
- warehouses.append(d.s_warehouse)
- if d.get("t_warehouse") and d.t_warehouse not in warehouses:
- warehouses.append(d.t_warehouse)
+ warehouses = set()
+ for d in item_rows:
+ if d.get("warehouse"):
+ warehouses.add(d.warehouse)
- return items, warehouses
+ if self.doctype == "Stock Entry":
+ if d.get("s_warehouse"):
+ warehouses.add(d.s_warehouse)
+ if d.get("t_warehouse"):
+ warehouses.add(d.t_warehouse)
+
+ return list(items), list(warehouses)
def get_stock_ledger_details(self):
stock_ledger = {}
diff --git a/erpnext/controllers/tests/test_queries.py b/erpnext/controllers/tests/test_queries.py
index 908d78c..60d1733 100644
--- a/erpnext/controllers/tests/test_queries.py
+++ b/erpnext/controllers/tests/test_queries.py
@@ -56,6 +56,12 @@
bundled_stock_items = query(txt="_test product bundle item 5", filters={"is_stock_item": 1})
self.assertEqual(len(bundled_stock_items), 0)
+ # empty customer/supplier should be stripped of instead of failure
+ query(txt="", filters={"customer": None})
+ query(txt="", filters={"customer": ""})
+ query(txt="", filters={"supplier": None})
+ query(txt="", filters={"supplier": ""})
+
def test_bom_qury(self):
query = add_default_params(queries.bom, "BOM")
diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/e_commerce/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/e_commerce/__init__.py
diff --git a/erpnext/e_commerce/api.py b/erpnext/e_commerce/api.py
new file mode 100644
index 0000000..43cb36c
--- /dev/null
+++ b/erpnext/e_commerce/api.py
@@ -0,0 +1,86 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe.utils import cint
+
+from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder
+from erpnext.e_commerce.product_data_engine.query import ProductQuery
+from erpnext.setup.doctype.item_group.item_group import get_child_groups_for_website
+
+
+@frappe.whitelist(allow_guest=True)
+def get_product_filter_data(query_args=None):
+ """
+ Returns filtered products and discount filters.
+ :param query_args (dict): contains filters to get products list
+
+ Query Args filters:
+ search (str): Search Term.
+ field_filters (dict): Keys include item_group, brand, etc.
+ attribute_filters(dict): Keys include Color, Size, etc.
+ start (int): Offset items by
+ item_group (str): Valid Item Group
+ from_filters (bool): Set as True to jump to page 1
+ """
+ if isinstance(query_args, str):
+ query_args = json.loads(query_args)
+
+ query_args = frappe._dict(query_args)
+ if query_args:
+ search = query_args.get("search")
+ field_filters = query_args.get("field_filters", {})
+ attribute_filters = query_args.get("attribute_filters", {})
+ start = cint(query_args.start) if query_args.get("start") else 0
+ item_group = query_args.get("item_group")
+ from_filters = query_args.get("from_filters")
+ else:
+ search, attribute_filters, item_group, from_filters = None, None, None, None
+ field_filters = {}
+ start = 0
+
+ # if new filter is checked, reset start to show filtered items from page 1
+ if from_filters:
+ start = 0
+
+ sub_categories = []
+ if item_group:
+ field_filters['item_group'] = item_group
+ sub_categories = get_child_groups_for_website(item_group, immediate=True)
+
+ engine = ProductQuery()
+ try:
+ result = engine.query(
+ attribute_filters,
+ field_filters,
+ search_term=search,
+ start=start,
+ item_group=item_group
+ )
+ except Exception:
+ traceback = frappe.get_traceback()
+ frappe.log_error(traceback, frappe._("Product Engine Error"))
+ return {"exc": "Something went wrong!"}
+
+ # discount filter data
+ filters = {}
+ discounts = result["discounts"]
+
+ if discounts:
+ filter_engine = ProductFiltersBuilder()
+ filters["discount_filters"] = filter_engine.get_discount_filters(discounts)
+
+ return {
+ "items": result["items"] or [],
+ "filters": filters,
+ "settings": engine.settings,
+ "sub_categories": sub_categories,
+ "items_count": result["items_count"]
+ }
+
+@frappe.whitelist(allow_guest=True)
+def get_guest_redirect_on_action():
+ return frappe.db.get_single_value("E Commerce Settings", "redirect_on_action")
\ No newline at end of file
diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/e_commerce/doctype/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/e_commerce/doctype/__init__.py
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/e_commerce_settings/__init__.py
similarity index 100%
rename from erpnext/portal/doctype/products_settings/__init__.py
rename to erpnext/e_commerce/doctype/e_commerce_settings/__init__.py
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js
new file mode 100644
index 0000000..6302d26
--- /dev/null
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.js
@@ -0,0 +1,53 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on("E Commerce Settings", {
+ onload: function(frm) {
+ if(frm.doc.__onload && frm.doc.__onload.quotation_series) {
+ frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
+ frm.refresh_field("quotation_series");
+ }
+
+ frm.set_query('payment_gateway_account', function() {
+ return { 'filters': { 'payment_channel': "Email" } };
+ });
+ },
+ refresh: function(frm) {
+ if (frm.doc.enabled) {
+ frm.get_field('store_page_docs').$wrapper.removeClass('hide-control').html(
+ `<div>${__("Follow these steps to create a landing page for your store")}:
+ <a href="https://docs.erpnext.com/docs/user/manual/en/website/store-landing-page"
+ style="color: var(--gray-600)">
+ docs/store-landing-page
+ </a>
+ </div>`
+ );
+ }
+
+ frappe.model.with_doctype("Item", () => {
+ const web_item_meta = frappe.get_meta('Website Item');
+
+ const valid_fields = web_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
+ );
+ });
+ },
+ enabled: function(frm) {
+ if (frm.doc.enabled === 1) {
+ frm.set_value('enable_variants', 1);
+ }
+ else {
+ frm.set_value('company', '');
+ frm.set_value('price_list', '');
+ frm.set_value('default_customer_group', '');
+ frm.set_value('quotation_series', '');
+ }
+ }
+});
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json
new file mode 100644
index 0000000..d5fb969
--- /dev/null
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.json
@@ -0,0 +1,393 @@
+{
+ "actions": [],
+ "creation": "2021-02-10 17:13:39.139103",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "products_per_page",
+ "filter_categories_section",
+ "enable_field_filters",
+ "filter_fields",
+ "enable_attribute_filters",
+ "filter_attributes",
+ "display_settings_section",
+ "hide_variants",
+ "enable_variants",
+ "show_price",
+ "column_break_9",
+ "show_stock_availability",
+ "show_quantity_in_website",
+ "allow_items_not_in_stock",
+ "column_break_13",
+ "show_apply_coupon_code_in_website",
+ "show_contact_us_button",
+ "show_attachments",
+ "section_break_18",
+ "company",
+ "price_list",
+ "enabled",
+ "store_page_docs",
+ "column_break_21",
+ "default_customer_group",
+ "quotation_series",
+ "checkout_settings_section",
+ "enable_checkout",
+ "show_price_in_quotation",
+ "column_break_27",
+ "save_quotations_as_draft",
+ "payment_gateway_account",
+ "payment_success_url",
+ "add_ons_section",
+ "enable_wishlist",
+ "column_break_22",
+ "enable_reviews",
+ "column_break_23",
+ "enable_recommendations",
+ "item_search_settings_section",
+ "redisearch_warning",
+ "search_index_fields",
+ "show_categories_in_search_autocomplete",
+ "is_redisearch_loaded",
+ "shop_by_category_section",
+ "slideshow",
+ "guest_display_settings_section",
+ "hide_price_for_guest",
+ "redirect_on_action"
+ ],
+ "fields": [
+ {
+ "default": "6",
+ "fieldname": "products_per_page",
+ "fieldtype": "Int",
+ "label": "Products per Page"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "filter_categories_section",
+ "fieldtype": "Section Break",
+ "label": "Filters and Categories"
+ },
+ {
+ "default": "0",
+ "fieldname": "hide_variants",
+ "fieldtype": "Check",
+ "label": "Hide Variants"
+ },
+ {
+ "default": "0",
+ "description": "The field filters will also work as categories in the <b>Shop by Category</b> page.",
+ "fieldname": "enable_field_filters",
+ "fieldtype": "Check",
+ "label": "Enable Field Filters (Categories)"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_attribute_filters",
+ "fieldtype": "Check",
+ "label": "Enable Attribute Filters"
+ },
+ {
+ "depends_on": "enable_field_filters",
+ "fieldname": "filter_fields",
+ "fieldtype": "Table",
+ "label": "Website Item Fields",
+ "options": "Website Filter Field"
+ },
+ {
+ "depends_on": "enable_attribute_filters",
+ "fieldname": "filter_attributes",
+ "fieldtype": "Table",
+ "label": "Attributes",
+ "options": "Website Attribute"
+ },
+ {
+ "default": "0",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Enable Shopping Cart"
+ },
+ {
+ "depends_on": "doc.enabled",
+ "fieldname": "store_page_docs",
+ "fieldtype": "HTML"
+ },
+ {
+ "fieldname": "display_settings_section",
+ "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": "enable_variants",
+ "fieldtype": "Check",
+ "label": "Enable Variant Selection"
+ },
+ {
+ "fieldname": "column_break_13",
+ "fieldtype": "Column Break"
+ },
+ {
+ "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"
+ },
+ {
+ "fieldname": "section_break_18",
+ "fieldtype": "Section Break",
+ "label": "Shopping Cart"
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "company",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Company",
+ "mandatory_depends_on": "eval: doc.enabled === 1",
+ "options": "Company",
+ "remember_last_selected_value": 1
+ },
+ {
+ "depends_on": "enabled",
+ "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_21",
+ "fieldtype": "Column Break"
+ },
+ {
+ "depends_on": "enabled",
+ "fieldname": "default_customer_group",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Default Customer Group",
+ "mandatory_depends_on": "eval: doc.enabled === 1",
+ "options": "Customer Group"
+ },
+ {
+ "depends_on": "enabled",
+ "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": "checkout_settings_section",
+ "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_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_checkout == 0",
+ "fieldname": "save_quotations_as_draft",
+ "fieldtype": "Check",
+ "label": "Save Quotations as Draft"
+ },
+ {
+ "depends_on": "enable_checkout",
+ "fieldname": "payment_gateway_account",
+ "fieldtype": "Link",
+ "label": "Payment Gateway Account",
+ "mandatory_depends_on": "enable_checkout",
+ "options": "Payment Gateway Account"
+ },
+ {
+ "collapsible": 1,
+ "depends_on": "enable_field_filters",
+ "fieldname": "shop_by_category_section",
+ "fieldtype": "Section Break",
+ "label": "Shop by Category"
+ },
+ {
+ "fieldname": "slideshow",
+ "fieldtype": "Link",
+ "label": "Slideshow",
+ "options": "Website Slideshow"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "add_ons_section",
+ "fieldtype": "Section Break",
+ "label": "Add-ons"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_wishlist",
+ "fieldtype": "Check",
+ "label": "Enable Wishlist"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_reviews",
+ "fieldtype": "Check",
+ "label": "Enable Reviews and Ratings"
+ },
+ {
+ "fieldname": "search_index_fields",
+ "fieldtype": "Small Text",
+ "label": "Search Index Fields",
+ "read_only_depends_on": "eval:!doc.is_redisearch_loaded"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "item_search_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Item Search Settings"
+ },
+ {
+ "default": "1",
+ "fieldname": "show_categories_in_search_autocomplete",
+ "fieldtype": "Check",
+ "label": "Show Categories in Search Autocomplete",
+ "read_only_depends_on": "eval:!doc.is_redisearch_loaded"
+ },
+ {
+ "default": "0",
+ "fieldname": "is_redisearch_loaded",
+ "fieldtype": "Check",
+ "hidden": 1,
+ "label": "Is Redisearch Loaded"
+ },
+ {
+ "depends_on": "eval:!doc.is_redisearch_loaded",
+ "fieldname": "redisearch_warning",
+ "fieldtype": "HTML",
+ "label": "Redisearch Warning",
+ "options": "<p class=\"alert alert-warning\">Redisearch is not loaded. If you want to use the advanced product search feature, refer <a class=\"alert-link\" href=\"https://docs.erpnext.com/docs/v13/user/manual/en/setting-up/articles/installing-redisearch\" target=\"_blank\">here</a>.</p>"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval:doc.show_price",
+ "fieldname": "hide_price_for_guest",
+ "fieldtype": "Check",
+ "label": "Hide Price for Guest"
+ },
+ {
+ "fieldname": "column_break_9",
+ "fieldtype": "Column Break"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "guest_display_settings_section",
+ "fieldtype": "Section Break",
+ "label": "Guest Display Settings"
+ },
+ {
+ "description": "Link to redirect Guest on actions that need login such as add to cart, wishlist, etc. <b>E.g.: /login</b>",
+ "fieldname": "redirect_on_action",
+ "fieldtype": "Data",
+ "label": "Redirect on Action"
+ },
+ {
+ "fieldname": "column_break_22",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_23",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_recommendations",
+ "fieldtype": "Check",
+ "label": "Enable Recommendations"
+ },
+ {
+ "default": "0",
+ "depends_on": "eval: doc.enable_checkout == 0",
+ "fieldname": "show_price_in_quotation",
+ "fieldtype": "Check",
+ "label": "Show Price in Quotation"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2021-09-02 14:02:44.785824",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "E Commerce Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py
new file mode 100644
index 0000000..dd7b114
--- /dev/null
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/e_commerce_settings.py
@@ -0,0 +1,151 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, 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 comma_and, flt, unique
+
+from erpnext.e_commerce.redisearch_utils import (
+ create_website_items_index,
+ get_indexable_web_fields,
+ is_search_module_loaded,
+)
+
+
+class ShoppingCartSetupError(frappe.ValidationError): pass
+
+class ECommerceSettings(Document):
+ def onload(self):
+ self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
+ self.is_redisearch_loaded = is_search_module_loaded()
+
+ def validate(self):
+ self.validate_field_filters()
+ self.validate_attribute_filters()
+ self.validate_checkout()
+ self.validate_search_index_fields()
+
+ if self.enabled:
+ self.validate_price_list_exchange_rate()
+
+ frappe.clear_document_cache("E Commerce Settings", "E Commerce 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 <b>{1}</b> 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 validate_checkout(self):
+ if self.enable_checkout and not self.payment_gateway_account:
+ self.enable_checkout = 0
+
+ def validate_search_index_fields(self):
+ if not self.search_index_fields:
+ return
+
+ fields = self.search_index_fields.replace(' ', '')
+ fields = unique(fields.strip(',').split(',')) # Remove extra ',' and remove duplicates
+
+ # All fields should be indexable
+ allowed_indexable_fields = get_indexable_web_fields()
+
+ if not (set(fields).issubset(allowed_indexable_fields)):
+ invalid_fields = list(set(fields).difference(allowed_indexable_fields))
+ num_invalid_fields = len(invalid_fields)
+ invalid_fields = comma_and(invalid_fields)
+
+ if num_invalid_fields > 1:
+ frappe.throw(_("{0} are not valid options for Search Index Field.").format(frappe.bold(invalid_fields)))
+ else:
+ frappe.throw(_("{0} is not a valid option for Search Index Field.").format(frappe.bold(invalid_fields)))
+
+ self.search_index_fields = ','.join(fields)
+
+ def validate_price_list_exchange_rate(self):
+ "Check if exchange rate exists for Price List currency (to Company's currency)."
+ from erpnext.setup.utils import get_exchange_rate
+
+ if not self.enabled or not self.company or not self.price_list:
+ return # this function is also called from hooks, check values again
+
+ company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
+ price_list_currency = frappe.db.get_value("Price List", self.price_list, "currency")
+
+ if not company_currency:
+ msg = f"Please specify currency in Company {self.company}"
+ frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
+
+ if not price_list_currency:
+ msg = f"Please specify currency in Price List {frappe.bold(self.price_list)}"
+ frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
+
+ if price_list_currency != company_currency:
+ from_currency, to_currency = price_list_currency, company_currency
+
+ # Get exchange rate checks Currency Exchange Records too
+ exchange_rate = get_exchange_rate(from_currency, to_currency, args="for_selling")
+
+ if not flt(exchange_rate):
+ msg = f"Missing Currency Exchange Rates for {from_currency}-{to_currency}"
+ frappe.throw(_(msg), title=_("Missing"), exc=ShoppingCartSetupError)
+
+ def validate_tax_rule(self):
+ if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart" : 1}, "name"):
+ frappe.throw(frappe._("Set Tax Rule for shopping cart"), ShoppingCartSetupError)
+
+ def get_tax_master(self, billing_territory):
+ tax_master = self.get_name_from_territory(billing_territory, "sales_taxes_and_charges_masters",
+ "sales_taxes_and_charges_master")
+ return tax_master and tax_master[0] or None
+
+ def get_shipping_rules(self, shipping_territory):
+ return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule")
+
+ def on_change(self):
+ old_doc = self.get_doc_before_save()
+
+ if old_doc:
+ old_fields = old_doc.search_index_fields
+ new_fields = self.search_index_fields
+
+ # if search index fields get changed
+ if not (new_fields == old_fields):
+ create_website_items_index()
+
+def validate_cart_settings(doc=None, method=None):
+ frappe.get_doc("E Commerce Settings", "E Commerce Settings").run_method("validate")
+
+def get_shopping_cart_settings():
+ if not getattr(frappe.local, "shopping_cart_settings", None):
+ frappe.local.shopping_cart_settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings")
+
+ return frappe.local.shopping_cart_settings
+
+@frappe.whitelist(allow_guest=True)
+def is_cart_enabled():
+ return get_shopping_cart_settings().enabled
+
+def show_quantity_in_website():
+ return get_shopping_cart_settings().show_quantity_in_website
+
+def check_shopping_cart_enabled():
+ if not get_shopping_cart_settings().enabled:
+ frappe.throw(_("You need to enable Shopping Cart"), ShoppingCartSetupError)
+
+def show_attachments():
+ return get_shopping_cart_settings().show_attachments
diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py
similarity index 67%
rename from erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py
rename to erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py
index c3809b3..86cef30 100644
--- a/erpnext/shopping_cart/doctype/shopping_cart_settings/test_shopping_cart_settings.py
+++ b/erpnext/e_commerce/doctype/e_commerce_settings/test_e_commerce_settings.py
@@ -1,24 +1,21 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-# For license information, please see license.txt
-
-
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
import unittest
import frappe
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
ShoppingCartSetupError,
)
-class TestShoppingCartSettings(unittest.TestCase):
+class TestECommerceSettings(unittest.TestCase):
def setUp(self):
frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """)
def get_cart_settings(self):
- return frappe.get_doc({"doctype": "Shopping Cart Settings",
+ return frappe.get_doc({"doctype": "E Commerce Settings",
"company": "_Test Company"})
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
@@ -34,15 +31,17 @@
# cart_settings = self.get_cart_settings()
# cart_settings.price_list = "_Test Price List Rest of the World"
- # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_price_list_exchange_rate)
+ # self.assertRaises(ShoppingCartSetupError, cart_settings.validate_exchange_rates_exist)
- # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import test_records as \
- # currency_exchange_records
+ # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
+ # test_records as currency_exchange_records,
+ # )
# frappe.get_doc(currency_exchange_records[0]).insert()
- # cart_settings.validate_price_list_exchange_rate()
+ # cart_settings.validate_exchange_rates_exist()
def test_tax_rule_validation(self):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
+ frappe.db.commit() # nosemgrep
cart_settings = self.get_cart_settings()
cart_settings.enabled = 1
@@ -51,4 +50,13 @@
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1")
+def setup_e_commerce_settings(values_dict):
+ "Accepts a dict of values that updates E Commerce Settings."
+ if not values_dict:
+ return
+
+ doc = frappe.get_doc("E Commerce Settings", "E Commerce Settings")
+ doc.update(values_dict)
+ doc.save()
+
test_dependencies = ["Tax Rule"]
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/item_review/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/item_review/__init__.py
diff --git a/erpnext/e_commerce/doctype/item_review/item_review.js b/erpnext/e_commerce/doctype/item_review/item_review.js
new file mode 100644
index 0000000..a57c370
--- /dev/null
+++ b/erpnext/e_commerce/doctype/item_review/item_review.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Item Review', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/e_commerce/doctype/item_review/item_review.json b/erpnext/e_commerce/doctype/item_review/item_review.json
new file mode 100644
index 0000000..57f719f
--- /dev/null
+++ b/erpnext/e_commerce/doctype/item_review/item_review.json
@@ -0,0 +1,134 @@
+{
+ "actions": [],
+ "beta": 1,
+ "creation": "2021-03-23 16:47:26.542226",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "website_item",
+ "user",
+ "customer",
+ "column_break_3",
+ "item",
+ "published_on",
+ "reviews_section",
+ "review_title",
+ "rating",
+ "comment"
+ ],
+ "fields": [
+ {
+ "fieldname": "website_item",
+ "fieldtype": "Link",
+ "label": "Website Item",
+ "options": "Website Item",
+ "read_only": 1,
+ "reqd": 1
+ },
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "website_item.item_code",
+ "fieldname": "item",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item",
+ "options": "Item",
+ "read_only": 1
+ },
+ {
+ "fieldname": "reviews_section",
+ "fieldtype": "Section Break",
+ "label": "Reviews"
+ },
+ {
+ "fieldname": "rating",
+ "fieldtype": "Rating",
+ "in_list_view": 1,
+ "label": "Rating",
+ "read_only": 1
+ },
+ {
+ "fieldname": "comment",
+ "fieldtype": "Small Text",
+ "label": "Comment",
+ "read_only": 1
+ },
+ {
+ "fieldname": "review_title",
+ "fieldtype": "Data",
+ "label": "Review Title",
+ "read_only": 1
+ },
+ {
+ "fieldname": "customer",
+ "fieldtype": "Link",
+ "label": "Customer",
+ "options": "Customer",
+ "read_only": 1
+ },
+ {
+ "fieldname": "published_on",
+ "fieldtype": "Data",
+ "label": "Published on",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-08-10 12:08:58.119691",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Item Review",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Website Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "report": 1,
+ "role": "Customer",
+ "share": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/item_review/item_review.py b/erpnext/e_commerce/doctype/item_review/item_review.py
new file mode 100644
index 0000000..966ec35
--- /dev/null
+++ b/erpnext/e_commerce/doctype/item_review/item_review.py
@@ -0,0 +1,147 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+from datetime import datetime
+
+import frappe
+from frappe import _
+from frappe.contacts.doctype.contact.contact import get_contact_name
+from frappe.model.document import Document
+from frappe.utils import cint, flt
+
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
+ get_shopping_cart_settings,
+)
+
+
+class UnverifiedReviewer(frappe.ValidationError):
+ pass
+
+class ItemReview(Document):
+ def after_insert(self):
+ # regenerate cache on review creation
+ reviews_dict = get_queried_reviews(self.website_item)
+ set_reviews_in_cache(self.website_item, reviews_dict)
+
+ def after_delete(self):
+ # regenerate cache on review deletion
+ reviews_dict = get_queried_reviews(self.website_item)
+ set_reviews_in_cache(self.website_item, reviews_dict)
+
+
+@frappe.whitelist()
+def get_item_reviews(web_item, start=0, end=10, data=None):
+ "Get Website Item Review Data."
+ start, end = cint(start), cint(end)
+ settings = get_shopping_cart_settings()
+
+ # Get cached reviews for first page (start=0)
+ # avoid cache when page is different
+ from_cache = not bool(start)
+
+ if not data:
+ data = frappe._dict()
+
+ if settings and settings.get("enable_reviews"):
+ reviews_cache = frappe.cache().hget("item_reviews", web_item)
+ if from_cache and reviews_cache:
+ data = reviews_cache
+ else:
+ data = get_queried_reviews(web_item, start, end, data)
+ if from_cache:
+ set_reviews_in_cache(web_item, data)
+
+ return data
+
+def get_queried_reviews(web_item, start=0, end=10, data=None):
+ """
+ Query Website Item wise reviews and cache if needed.
+ Cache stores only first page of reviews i.e. 10 reviews maximum.
+ Returns:
+ dict: Containing reviews, average ratings, % of reviews per rating and total reviews.
+ """
+ if not data:
+ data = frappe._dict()
+
+ data.reviews = frappe.db.get_all(
+ "Item Review",
+ filters={"website_item": web_item},
+ fields=["*"],
+ limit_start=start,
+ limit_page_length=end
+ )
+
+ rating_data = frappe.db.get_all(
+ "Item Review",
+ filters={"website_item": web_item},
+ fields=["avg(rating) as average, count(*) as total"]
+ )[0]
+
+ data.average_rating = flt(rating_data.average, 1)
+ data.average_whole_rating = flt(data.average_rating, 0)
+
+ # get % of reviews per rating
+ reviews_per_rating = []
+ for i in range(1,6):
+ count = frappe.db.get_all(
+ "Item Review",
+ filters={"website_item": web_item, "rating": i},
+ fields=["count(*) as count"]
+ )[0].count
+
+ percent = flt((count / rating_data.total or 1) * 100, 0) if count else 0
+ reviews_per_rating.append(percent)
+
+ data.reviews_per_rating = reviews_per_rating
+ data.total_reviews = rating_data.total
+
+ return data
+
+def set_reviews_in_cache(web_item, reviews_dict):
+ frappe.cache().hset("item_reviews", web_item, reviews_dict)
+
+@frappe.whitelist()
+def add_item_review(web_item, title, rating, comment=None):
+ """ Add an Item Review by a user if non-existent. """
+ if frappe.session.user == "Guest":
+ # guest user should not reach here ideally in the case they do via an API, throw error
+ frappe.throw(_("You are not verified to write a review yet."), exc=UnverifiedReviewer)
+
+ if not frappe.db.exists("Item Review", {"user": frappe.session.user, "website_item": web_item}):
+ doc = frappe.get_doc({
+ "doctype": "Item Review",
+ "user": frappe.session.user,
+ "customer": get_customer(),
+ "website_item": web_item,
+ "item": frappe.db.get_value("Website Item", web_item, "item_code"),
+ "review_title": title,
+ "rating": rating,
+ "comment": comment
+ })
+ doc.published_on = datetime.today().strftime("%d %B %Y")
+ doc.insert()
+
+def get_customer(silent=False):
+ """
+ silent: Return customer if exists else return nothing. Dont throw error.
+ """
+ user = frappe.session.user
+ contact_name = get_contact_name(user)
+ customer = None
+
+ if contact_name:
+ contact = frappe.get_doc('Contact', contact_name)
+ for link in contact.links:
+ if link.link_doctype == "Customer":
+ customer = link.link_name
+ break
+
+ if customer:
+ return frappe.db.get_value("Customer", customer)
+ elif silent:
+ return None
+ else:
+ # should not reach here unless via an API
+ frappe.throw(_("You are not a verified customer yet. Please contact us to proceed."),
+ exc=UnverifiedReviewer)
diff --git a/erpnext/e_commerce/doctype/item_review/test_item_review.py b/erpnext/e_commerce/doctype/item_review/test_item_review.py
new file mode 100644
index 0000000..8a4befc
--- /dev/null
+++ b/erpnext/e_commerce/doctype/item_review/test_item_review.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+import unittest
+
+import frappe
+from frappe.core.doctype.user_permission.test_user_permission import create_user
+
+from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
+ setup_e_commerce_settings,
+)
+from erpnext.e_commerce.doctype.item_review.item_review import (
+ UnverifiedReviewer,
+ add_item_review,
+ get_item_reviews,
+)
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+from erpnext.e_commerce.shopping_cart.cart import get_party
+from erpnext.stock.doctype.item.test_item import make_item
+
+
+class TestItemReview(unittest.TestCase):
+ def setUp(self):
+ item = make_item("Test Mobile Phone")
+ if not frappe.db.exists("Website Item", {"item_code": "Test Mobile Phone"}):
+ make_website_item(item, save=True)
+
+ setup_e_commerce_settings({"enable_reviews": 1})
+ frappe.local.shopping_cart_settings = None
+
+ def tearDown(self):
+ frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete()
+ setup_e_commerce_settings({"enable_reviews": 0})
+
+ def test_add_and_get_item_reviews_from_customer(self):
+ "Add / Get Reviews from a User that is a valid customer (has added to cart or purchased in the past)"
+ # create user
+ web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"})
+ test_user = create_user("test_reviewer@example.com", "Customer")
+ frappe.set_user(test_user.name)
+
+ # create customer and contact against user
+ customer = get_party()
+
+ # post review on "Test Mobile Phone"
+ try:
+ add_item_review(web_item, "Great Product", 3, "Would recommend this product")
+ review_name = frappe.db.get_value("Item Review", {"website_item": web_item})
+ except Exception:
+ self.fail(f"Error while publishing review for {web_item}")
+
+ review_data = get_item_reviews(web_item, 0, 10)
+
+ self.assertEqual(len(review_data.reviews), 1)
+ self.assertEqual(review_data.average_rating, 3)
+ self.assertEqual(review_data.reviews_per_rating[2], 100)
+
+ # tear down
+ frappe.set_user("Administrator")
+ frappe.delete_doc("Item Review", review_name)
+ customer.delete()
+
+ def test_add_item_review_from_non_customer(self):
+ "Check if logged in user (who is not a customer yet) is blocked from posting reviews."
+ web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"})
+ test_user = create_user("test_reviewer@example.com", "Customer")
+ frappe.set_user(test_user.name)
+
+ with self.assertRaises(UnverifiedReviewer):
+ add_item_review(web_item, "Great Product", 3, "Would recommend this product")
+
+ # tear down
+ frappe.set_user("Administrator")
+
+ def test_add_item_reviews_from_guest_user(self):
+ "Check if Guest user is blocked from posting reviews."
+ web_item = frappe.db.get_value("Website Item", {"item_code": "Test Mobile Phone"})
+ frappe.set_user("Guest")
+
+ with self.assertRaises(UnverifiedReviewer):
+ add_item_review(web_item, "Great Product", 3, "Would recommend this product")
+
+ # tear down
+ frappe.set_user("Administrator")
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/recommended_items/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/recommended_items/__init__.py
diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.json b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json
new file mode 100644
index 0000000..06ac3dc
--- /dev/null
+++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.json
@@ -0,0 +1,87 @@
+{
+ "actions": [],
+ "creation": "2021-07-12 20:52:12.503470",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "website_item",
+ "website_item_name",
+ "column_break_2",
+ "item_code",
+ "more_information_section",
+ "route",
+ "column_break_6",
+ "website_item_image",
+ "website_item_thumbnail"
+ ],
+ "fields": [
+ {
+ "fieldname": "website_item",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Website Item",
+ "options": "Website Item"
+ },
+ {
+ "fetch_from": "website_item.web_item_name",
+ "fieldname": "website_item_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Website Item Name",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "more_information_section",
+ "fieldtype": "Section Break",
+ "label": "More Information"
+ },
+ {
+ "fetch_from": "website_item.route",
+ "fieldname": "route",
+ "fieldtype": "Small Text",
+ "label": "Route",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "website_item.image",
+ "fieldname": "website_item_image",
+ "fieldtype": "Attach",
+ "label": "Website Item Image",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_6",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "website_item.thumbnail",
+ "fieldname": "website_item_thumbnail",
+ "fieldtype": "Data",
+ "label": "Website Item Thumbnail",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "website_item.item_code",
+ "fieldname": "item_code",
+ "fieldtype": "Data",
+ "label": "Item Code"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-07-13 21:02:19.031652",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Recommended Items",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/recommended_items/recommended_items.py b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py
new file mode 100644
index 0000000..16b6e52
--- /dev/null
+++ b/erpnext/e_commerce/doctype/recommended_items/recommended_items.py
@@ -0,0 +1,9 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class RecommendedItems(Document):
+ pass
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/website_item/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/website_item/__init__.py
diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item.html b/erpnext/e_commerce/doctype/website_item/templates/website_item.html
new file mode 100644
index 0000000..db12309
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/templates/website_item.html
@@ -0,0 +1,7 @@
+{% extends "templates/web.html" %}
+
+{% block page_content %}
+<h1>{{ title }}</h1>
+{% endblock %}
+
+<!-- this is a sample default web page template -->
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html
new file mode 100644
index 0000000..d7014b4
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/templates/website_item_row.html
@@ -0,0 +1,4 @@
+<div>
+ <a href="{{ doc.route }}">{{ doc.title or doc.name }}</a>
+</div>
+<!-- this is a sample default list template -->
diff --git a/erpnext/e_commerce/doctype/website_item/test_website_item.py b/erpnext/e_commerce/doctype/website_item/test_website_item.py
new file mode 100644
index 0000000..b39e4df
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/test_website_item.py
@@ -0,0 +1,538 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+
+import unittest
+
+import frappe
+
+from erpnext.controllers.item_variant import create_variant
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
+ get_shopping_cart_settings,
+)
+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.shopping_cart.product_info import get_product_info_for_website
+from erpnext.stock.doctype.item.item import DataValidationError
+from erpnext.stock.doctype.item.test_item import make_item
+
+WEBITEM_DESK_TESTS = ("test_website_item_desk_item_sync", "test_publish_variant_and_template")
+WEBITEM_PRICE_TESTS = ('test_website_item_price_for_logged_in_user', 'test_website_item_price_for_guest_user')
+
+class TestWebsiteItem(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ setup_e_commerce_settings({
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India"
+ })
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
+ def setUp(self):
+ if self._testMethodName in WEBITEM_DESK_TESTS:
+ make_item("Test Web Item", {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [
+ {
+ "attribute": "Test Size"
+ }
+ ]
+ })
+ elif self._testMethodName in WEBITEM_PRICE_TESTS:
+ create_user_and_customer_if_not_exists("test_contact_customer@example.com", "_Test Contact For _Test Customer")
+ create_regular_web_item()
+ make_web_item_price(item_code="Test Mobile Phone")
+
+ # Note: When testing web item pricing rule logged-in user pricing rule must differ from guest pricing rule or test will falsely pass.
+ # This is because make_web_pricing_rule creates a pricing rule "selling": 1, without specifying "applicable_for". Therefor,
+ # when testing for logged-in user the test will get the previous pricing rule because "selling" is still true.
+ #
+ # I've attempted to mitigate this by setting applicable_for=Customer, and customer=Guest however, this only results in PermissionError failing the test.
+ make_web_pricing_rule(
+ title="Test Pricing Rule for Test Mobile Phone",
+ item_code="Test Mobile Phone",
+ selling=1)
+ make_web_pricing_rule(
+ title="Test Pricing Rule for Test Mobile Phone (Customer)",
+ item_code="Test Mobile Phone",
+ selling=1,
+ discount_percentage="25",
+ applicable_for="Customer",
+ customer="_Test Customer")
+
+ def test_index_creation(self):
+ "Check if index is getting created in db."
+ from erpnext.e_commerce.doctype.website_item.website_item import on_doctype_update
+ on_doctype_update()
+
+ indices = frappe.db.sql("show index from `tabWebsite Item`", as_dict=1)
+ expected_columns = {"route", "item_group", "brand"}
+ for index in indices:
+ expected_columns.discard(index.get("Column_name"))
+
+ if expected_columns:
+ self.fail(f"Expected db index on these columns: {', '.join(expected_columns)}")
+
+ def test_website_item_desk_item_sync(self):
+ "Check creation/updation/deletion of Website Item and its impact on Item master."
+ web_item = None
+ item = make_item("Test Web Item") # will return item if exists
+ try:
+ web_item = make_website_item(item, save=False)
+ web_item.save()
+ except Exception:
+ self.fail(f"Error while creating website item for {item}")
+
+ # check if website item was created
+ self.assertTrue(bool(web_item))
+ self.assertTrue(bool(web_item.route))
+
+ item.reload()
+ self.assertEqual(web_item.published, 1)
+ self.assertEqual(item.published_in_website, 1) # check if item was back updated
+ self.assertEqual(web_item.item_group, item.item_group)
+
+ # check if changing item data changes it in website item
+ item.item_name = "Test Web Item 1"
+ item.stock_uom = "Unit"
+ item.save()
+ web_item.reload()
+ self.assertEqual(web_item.item_name, item.item_name)
+ self.assertEqual(web_item.stock_uom, item.stock_uom)
+
+ # check if disabling item unpublished website item
+ item.disabled = 1
+ item.save()
+ web_item.reload()
+ self.assertEqual(web_item.published, 0)
+
+ # check if website item deletion, unpublishes desk item
+ web_item.delete()
+ item.reload()
+ self.assertEqual(item.published_in_website, 0)
+
+ item.delete()
+
+ def test_publish_variant_and_template(self):
+ "Check if template is published on publishing variant."
+ # template "Test Web Item" created on setUp
+ variant = create_variant("Test Web Item", {"Test Size": "Large"})
+ variant.save()
+
+ # check if template is not published
+ self.assertIsNone(frappe.db.exists("Website Item", {"item_code": variant.variant_of}))
+
+ variant_web_item = make_website_item(variant, save=False)
+ variant_web_item.save()
+
+ # check if template is published
+ try:
+ template_web_item = frappe.get_doc("Website Item", {"item_code": variant.variant_of})
+ except frappe.DoesNotExistError:
+ self.fail(f"Template of {variant.item_code}, {variant.variant_of} not published")
+
+ # teardown
+ variant_web_item.delete()
+ template_web_item.delete()
+ variant.delete()
+
+ def test_impact_on_merging_items(self):
+ "Check if merging items is blocked if old and new items both have website items"
+ first_item = make_item("Test First Item")
+ second_item = make_item("Test Second Item")
+
+ first_web_item = make_website_item(first_item, save=False)
+ first_web_item.save()
+ second_web_item = make_website_item(second_item, save=False)
+ second_web_item.save()
+
+ with self.assertRaises(DataValidationError):
+ frappe.rename_doc("Item", "Test First Item", "Test Second Item", merge=True)
+
+ # tear down
+ second_web_item.delete()
+ first_web_item.delete()
+ second_item.delete()
+ first_item.delete()
+
+ # Website Item Portal Tests Begin
+
+ def test_website_item_breadcrumbs(self):
+ "Check if breadcrumbs include homepage, product listing navigation page, parent item group(s) and item group."
+ from erpnext.setup.doctype.item_group.item_group import get_parent_item_groups
+
+ item_code = "Test Breadcrumb Item"
+ item = make_item(item_code, {
+ "item_group": "_Test Item Group B - 1",
+ })
+
+ if not frappe.db.exists("Website Item", {"item_code": item_code}):
+ web_item = make_website_item(item, save=False)
+ web_item.save()
+ else:
+ web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code})
+
+ frappe.db.set_value("Item Group", "_Test Item Group B - 1", "show_in_website", 1)
+ frappe.db.set_value("Item Group", "_Test Item Group B", "show_in_website", 1)
+
+ breadcrumbs = get_parent_item_groups(item.item_group)
+
+ self.assertEqual(breadcrumbs[0]["name"], "Home")
+ self.assertEqual(breadcrumbs[1]["name"], "Shop by Category")
+ self.assertEqual(breadcrumbs[2]["name"], "_Test Item Group B") # parent item group
+ self.assertEqual(breadcrumbs[3]["name"], "_Test Item Group B - 1")
+
+ # tear down
+ web_item.delete()
+ item.delete()
+
+ def test_website_item_price_for_logged_in_user(self):
+ "Check if price details are fetched correctly while logged in."
+ item_code = "Test Mobile Phone"
+
+ # show price in e commerce settings
+ setup_e_commerce_settings({"show_price": 1})
+
+ # price and pricing rule added via setUp
+
+ # login as customer with pricing rule
+ frappe.set_user("test_contact_customer@example.com")
+
+ # check if price and slashed price is fetched correctly
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertTrue(bool(data.product_info["price"]))
+
+ price_object = data.product_info["price"]
+ self.assertEqual(price_object.get("discount_percent"), 25)
+ self.assertEqual(price_object.get("price_list_rate"), 750)
+ self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00")
+ self.assertEqual(price_object.get("formatted_price"), "₹ 750.00")
+ self.assertEqual(price_object.get("formatted_discount_percent"), "25%")
+
+ # switch to admin and disable show price
+ frappe.set_user("Administrator")
+ setup_e_commerce_settings({"show_price": 0})
+
+ # price should not be fetched for logged in user.
+ frappe.set_user("test_contact_customer@example.com")
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertFalse(bool(data.product_info["price"]))
+
+ # tear down
+ frappe.set_user("Administrator")
+
+ def test_website_item_price_for_guest_user(self):
+ "Check if price details are fetched correctly for guest user."
+ item_code = "Test Mobile Phone"
+
+ # show price for guest user in e commerce settings
+ setup_e_commerce_settings({
+ "show_price": 1,
+ "hide_price_for_guest": 0
+ })
+
+ # price and pricing rule added via setUp
+
+ # switch to guest user
+ frappe.set_user("Guest")
+
+ # price should be fetched
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertTrue(bool(data.product_info["price"]))
+
+ price_object = data.product_info["price"]
+ self.assertEqual(price_object.get("discount_percent"), 10)
+ self.assertEqual(price_object.get("price_list_rate"), 900)
+
+ # hide price for guest user
+ frappe.set_user("Administrator")
+ setup_e_commerce_settings({"hide_price_for_guest": 1})
+ frappe.set_user("Guest")
+
+ # price should not be fetched
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertFalse(bool(data.product_info["price"]))
+
+ # tear down
+ frappe.set_user("Administrator")
+
+ def test_website_item_stock_when_out_of_stock(self):
+ """
+ Check if stock details are fetched correctly for empty inventory when:
+ 1) Showing stock availability enabled:
+ - Warehouse unset
+ - Warehouse set
+ 2) Showing stock availability disabled
+ """
+ item_code = "Test Mobile Phone"
+ create_regular_web_item()
+ setup_e_commerce_settings({"show_stock_availability": 1})
+
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+
+ # check if stock details are fetched and item not in stock without warehouse set
+ self.assertFalse(bool(data.product_info["in_stock"]))
+ self.assertFalse(bool(data.product_info["stock_qty"]))
+
+ # set warehouse
+ frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC")
+
+ # check if stock details are fetched and item not in stock with warehouse set
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertFalse(bool(data.product_info["in_stock"]))
+ self.assertEqual(data.product_info["stock_qty"][0][0], 0)
+
+ # disable show stock availability
+ setup_e_commerce_settings({"show_stock_availability": 0})
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+
+ # check if stock detail attributes are not fetched if stock availability is hidden
+ self.assertIsNone(data.product_info.get("in_stock"))
+ self.assertIsNone(data.product_info.get("stock_qty"))
+ self.assertIsNone(data.product_info.get("show_stock_qty"))
+
+ # tear down
+ frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete()
+
+ def test_website_item_stock_when_in_stock(self):
+ """
+ Check if stock details are fetched correctly for available inventory when:
+ 1) Showing stock availability enabled:
+ - Warehouse set
+ - Warehouse unset
+ 2) Showing stock availability disabled
+ """
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ item_code = "Test Mobile Phone"
+ create_regular_web_item()
+ setup_e_commerce_settings({"show_stock_availability": 1})
+ frappe.local.shopping_cart_settings = None
+
+ # set warehouse
+ frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "_Test Warehouse - _TC")
+
+ # stock up item
+ stock_entry = make_stock_entry(item_code=item_code, target="_Test Warehouse - _TC", qty=2, rate=100)
+
+ # check if stock details are fetched and item is in stock with warehouse set
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertTrue(bool(data.product_info["in_stock"]))
+ self.assertEqual(data.product_info["stock_qty"][0][0], 2)
+
+ # unset warehouse
+ frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "")
+
+ # check if stock details are fetched and item not in stock without warehouse set
+ # (even though it has stock in some warehouse)
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+ self.assertFalse(bool(data.product_info["in_stock"]))
+ self.assertFalse(bool(data.product_info["stock_qty"]))
+
+ # disable show stock availability
+ setup_e_commerce_settings({"show_stock_availability": 0})
+ frappe.local.shopping_cart_settings = None
+ data = get_product_info_for_website(item_code, skip_quotation_creation=True)
+
+ # check if stock detail attributes are not fetched if stock availability is hidden
+ self.assertIsNone(data.product_info.get("in_stock"))
+ self.assertIsNone(data.product_info.get("stock_qty"))
+ self.assertIsNone(data.product_info.get("show_stock_qty"))
+
+ # tear down
+ stock_entry.cancel()
+ frappe.get_cached_doc("Website Item", {"item_code": "Test Mobile Phone"}).delete()
+
+ def test_recommended_item(self):
+ "Check if added recommended items are fetched correctly."
+ item_code = "Test Mobile Phone"
+ web_item = create_regular_web_item(item_code)
+
+ setup_e_commerce_settings({
+ "enable_recommendations": 1,
+ "show_price": 1
+ })
+
+ # create recommended web item and price for it
+ recommended_web_item = create_regular_web_item("Test Mobile Phone 1")
+ make_web_item_price(item_code="Test Mobile Phone 1")
+
+ # add recommended item to first web item
+ web_item.append("recommended_items", {"website_item": recommended_web_item.name})
+ web_item.save()
+
+ frappe.local.shopping_cart_settings = None
+ e_commerce_settings = get_shopping_cart_settings()
+ recommended_items = web_item.get_recommended_items(e_commerce_settings)
+
+ # test results if show price is enabled
+ self.assertEqual(len(recommended_items), 1)
+ recomm_item = recommended_items[0]
+ self.assertEqual(recomm_item.get("website_item_name"), "Test Mobile Phone 1")
+ self.assertTrue(bool(recomm_item.get("price_info"))) # price fetched
+
+ price_info = recomm_item.get("price_info")
+ self.assertEqual(price_info.get("price_list_rate"), 1000)
+ self.assertEqual(price_info.get("formatted_price"), "₹ 1,000.00")
+
+ # test results if show price is disabled
+ setup_e_commerce_settings({"show_price": 0})
+
+ frappe.local.shopping_cart_settings = None
+ e_commerce_settings = get_shopping_cart_settings()
+ recommended_items = web_item.get_recommended_items(e_commerce_settings)
+
+ self.assertEqual(len(recommended_items), 1)
+ self.assertFalse(bool(recommended_items[0].get("price_info"))) # price not fetched
+
+ # tear down
+ web_item.delete()
+ recommended_web_item.delete()
+ frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete()
+
+ def test_recommended_item_for_guest_user(self):
+ "Check if added recommended items are fetched correctly for guest user."
+ item_code = "Test Mobile Phone"
+ web_item = create_regular_web_item(item_code)
+
+ # price visible to guests
+ setup_e_commerce_settings({
+ "enable_recommendations": 1,
+ "show_price": 1,
+ "hide_price_for_guest": 0
+ })
+
+ # create recommended web item and price for it
+ recommended_web_item = create_regular_web_item("Test Mobile Phone 1")
+ make_web_item_price(item_code="Test Mobile Phone 1")
+
+ # add recommended item to first web item
+ web_item.append("recommended_items", {"website_item": recommended_web_item.name})
+ web_item.save()
+
+ frappe.set_user("Guest")
+
+ frappe.local.shopping_cart_settings = None
+ e_commerce_settings = get_shopping_cart_settings()
+ recommended_items = web_item.get_recommended_items(e_commerce_settings)
+
+ # test results if show price is enabled
+ self.assertEqual(len(recommended_items), 1)
+ self.assertTrue(bool(recommended_items[0].get("price_info"))) # price fetched
+
+ # price hidden from guests
+ frappe.set_user("Administrator")
+ setup_e_commerce_settings({"hide_price_for_guest": 1})
+ frappe.set_user("Guest")
+
+ frappe.local.shopping_cart_settings = None
+ e_commerce_settings = get_shopping_cart_settings()
+ recommended_items = web_item.get_recommended_items(e_commerce_settings)
+
+ # test results if show price is enabled
+ self.assertEqual(len(recommended_items), 1)
+ self.assertFalse(bool(recommended_items[0].get("price_info"))) # price fetched
+
+ # tear down
+ frappe.set_user("Administrator")
+ web_item.delete()
+ recommended_web_item.delete()
+ frappe.get_cached_doc("Item", "Test Mobile Phone 1").delete()
+
+def create_regular_web_item(item_code=None, item_args=None, web_args=None):
+ "Create Regular Item and Website Item."
+ item_code = item_code or "Test Mobile Phone"
+ item = make_item(item_code, properties=item_args)
+
+ if not frappe.db.exists("Website Item", {"item_code": item_code}):
+ web_item = make_website_item(item, save=False)
+ if web_args:
+ web_item.update(web_args)
+ web_item.save()
+ else:
+ web_item = frappe.get_cached_doc("Website Item", {"item_code": item_code})
+
+ return web_item
+
+def make_web_item_price(**kwargs):
+ item_code = kwargs.get("item_code")
+ if not item_code:
+ return
+
+ if not frappe.db.exists("Item Price", {"item_code": item_code}):
+ item_price = frappe.get_doc({
+ "doctype": "Item Price",
+ "item_code": item_code,
+ "price_list": kwargs.get("price_list") or "_Test Price List India",
+ "price_list_rate": kwargs.get("price_list_rate") or 1000
+ })
+ item_price.insert()
+ else:
+ item_price = frappe.get_cached_doc("Item Price", {"item_code": item_code})
+
+ return item_price
+
+def make_web_pricing_rule(**kwargs):
+ title = kwargs.get("title")
+ if not title:
+ return
+
+ if not frappe.db.exists("Pricing Rule", title):
+ pricing_rule = frappe.get_doc({
+ "doctype": "Pricing Rule",
+ "title": title,
+ "apply_on": kwargs.get("apply_on") or "Item Code",
+ "items": [{
+ "item_code": kwargs.get("item_code")
+ }],
+ "selling": kwargs.get("selling") or 0,
+ "buying": kwargs.get("buying") or 0,
+ "rate_or_discount": kwargs.get("rate_or_discount") or "Discount Percentage",
+ "discount_percentage": kwargs.get("discount_percentage") or 10,
+ "company": kwargs.get("company") or "_Test Company",
+ "currency": kwargs.get("currency") or "INR",
+ "for_price_list": kwargs.get("price_list") or "_Test Price List India",
+ "applicable_for": kwargs.get("applicable_for") or "",
+ "customer": kwargs.get("customer") or "",
+ })
+ pricing_rule.insert()
+ else:
+ pricing_rule = frappe.get_doc("Pricing Rule", {"title": title})
+
+ return pricing_rule
+
+
+def create_user_and_customer_if_not_exists(email, first_name = None):
+ if frappe.db.exists("User", email):
+ return
+
+ frappe.get_doc({
+ "doctype": "User",
+ "user_type": "Website User",
+ "email": email,
+ "send_welcome_email": 0,
+ "first_name": first_name or email.split("@")[0]
+ }).insert(ignore_permissions=True)
+
+ contact = frappe.get_last_doc("Contact", filters={"email_id": email})
+ link = contact.append('links', {})
+ link.link_doctype = "Customer"
+ link.link_name = "_Test Customer"
+ link.link_title = "_Test Customer"
+ contact.save()
+
+test_dependencies = ["Price List", "Item Price", "Customer", "Contact", "Item"]
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.js b/erpnext/e_commerce/doctype/website_item/website_item.js
new file mode 100644
index 0000000..741e78f
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/website_item.js
@@ -0,0 +1,24 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Website Item', {
+ onload: function(frm) {
+ // should never check Private
+ frm.fields_dict["website_image"].df.is_private = 0;
+ },
+
+ image: function() {
+ refresh_field("image_view");
+ },
+
+ copy_from_item_group: function(frm) {
+ return frm.call({
+ doc: frm.doc,
+ method: "copy_specification_from_item_group"
+ });
+ },
+
+ set_meta_tags(frm) {
+ frappe.utils.set_meta_tag(frm.doc.route);
+ }
+});
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.json b/erpnext/e_commerce/doctype/website_item/website_item.json
new file mode 100644
index 0000000..245042a
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/website_item.json
@@ -0,0 +1,415 @@
+{
+ "actions": [],
+ "allow_guest_to_view": 1,
+ "allow_import": 1,
+ "autoname": "naming_series",
+ "creation": "2021-02-09 21:06:14.441698",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "naming_series",
+ "web_item_name",
+ "route",
+ "has_variants",
+ "variant_of",
+ "published",
+ "column_break_3",
+ "item_code",
+ "item_name",
+ "item_group",
+ "stock_uom",
+ "column_break_11",
+ "description",
+ "brand",
+ "image",
+ "display_section",
+ "website_image",
+ "website_image_alt",
+ "column_break_13",
+ "slideshow",
+ "thumbnail",
+ "stock_information_section",
+ "website_warehouse",
+ "column_break_24",
+ "on_backorder",
+ "section_break_17",
+ "short_description",
+ "web_long_description",
+ "column_break_27",
+ "website_specifications",
+ "copy_from_item_group",
+ "display_additional_information_section",
+ "show_tabbed_section",
+ "tabs",
+ "recommended_items_section",
+ "recommended_items",
+ "offers_section",
+ "offers",
+ "section_break_6",
+ "ranking",
+ "set_meta_tags",
+ "column_break_22",
+ "website_item_groups",
+ "advanced_display_section",
+ "website_content"
+ ],
+ "fields": [
+ {
+ "description": "Website display name",
+ "fetch_from": "item_code.item_name",
+ "fetch_if_empty": 1,
+ "fieldname": "web_item_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Website Item Name",
+ "reqd": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "label": "Item Code",
+ "options": "Item",
+ "read_only_depends_on": "eval:!doc.__islocal",
+ "reqd": 1
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "section_break_6",
+ "fieldtype": "Section Break",
+ "label": "Search and SEO"
+ },
+ {
+ "fieldname": "route",
+ "fieldtype": "Small Text",
+ "in_list_view": 1,
+ "label": "Route",
+ "no_copy": 1
+ },
+ {
+ "description": "Items with higher ranking will be shown higher",
+ "fieldname": "ranking",
+ "fieldtype": "Int",
+ "label": "Ranking"
+ },
+ {
+ "description": "Show a slideshow at the top of the page",
+ "fieldname": "slideshow",
+ "fieldtype": "Link",
+ "label": "Slideshow",
+ "options": "Website Slideshow"
+ },
+ {
+ "description": "Item Image (if not slideshow)",
+ "fieldname": "website_image",
+ "fieldtype": "Attach",
+ "label": "Website Image"
+ },
+ {
+ "description": "Image Alternative Text",
+ "fieldname": "website_image_alt",
+ "fieldtype": "Data",
+ "label": "Image Description"
+ },
+ {
+ "fieldname": "thumbnail",
+ "fieldtype": "Data",
+ "label": "Thumbnail",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_13",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "Show Stock availability based on this warehouse.",
+ "fieldname": "website_warehouse",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Website Warehouse",
+ "options": "Warehouse"
+ },
+ {
+ "description": "List this Item in multiple groups on the website.",
+ "fieldname": "website_item_groups",
+ "fieldtype": "Table",
+ "label": "Website Item Groups",
+ "options": "Website Item Group"
+ },
+ {
+ "fieldname": "set_meta_tags",
+ "fieldtype": "Button",
+ "label": "Set Meta Tags"
+ },
+ {
+ "fieldname": "section_break_17",
+ "fieldtype": "Section Break",
+ "label": "Display Information"
+ },
+ {
+ "fieldname": "copy_from_item_group",
+ "fieldtype": "Button",
+ "label": "Copy From Item Group"
+ },
+ {
+ "fieldname": "website_specifications",
+ "fieldtype": "Table",
+ "label": "Website Specifications",
+ "options": "Item Website Specification"
+ },
+ {
+ "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"
+ },
+ {
+ "fetch_from": "item_code.item_group",
+ "fieldname": "item_group",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Group",
+ "options": "Item Group",
+ "read_only": 1
+ },
+ {
+ "fieldname": "image",
+ "fieldtype": "Attach Image",
+ "hidden": 1,
+ "in_preview": 1,
+ "label": "Image",
+ "print_hide": 1
+ },
+ {
+ "default": "1",
+ "fieldname": "published",
+ "fieldtype": "Check",
+ "label": "Published"
+ },
+ {
+ "default": "0",
+ "depends_on": "has_variants",
+ "fetch_from": "item_code.has_variants",
+ "fieldname": "has_variants",
+ "fieldtype": "Check",
+ "in_standard_filter": 1,
+ "label": "Has Variants",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "depends_on": "variant_of",
+ "fetch_from": "item_code.variant_of",
+ "fieldname": "variant_of",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "in_standard_filter": 1,
+ "label": "Variant Of",
+ "options": "Item",
+ "read_only": 1,
+ "search_index": 1,
+ "set_only_once": 1
+ },
+ {
+ "fetch_from": "item_code.stock_uom",
+ "fieldname": "stock_uom",
+ "fieldtype": "Link",
+ "label": "Stock UOM",
+ "options": "UOM",
+ "read_only": 1
+ },
+ {
+ "depends_on": "brand",
+ "fetch_from": "item_code.brand",
+ "fieldname": "brand",
+ "fieldtype": "Link",
+ "label": "Brand",
+ "options": "Brand"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "advanced_display_section",
+ "fieldtype": "Section Break",
+ "label": "Advanced Display Content"
+ },
+ {
+ "fieldname": "display_section",
+ "fieldtype": "Section Break",
+ "label": "Display Images"
+ },
+ {
+ "fieldname": "column_break_27",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "column_break_22",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "item_code.description",
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Item Description",
+ "read_only": 1
+ },
+ {
+ "default": "WEB-ITM-.####",
+ "fieldname": "naming_series",
+ "fieldtype": "Select",
+ "hidden": 1,
+ "label": "Naming Series",
+ "no_copy": 1,
+ "options": "WEB-ITM-.####",
+ "print_hide": 1
+ },
+ {
+ "fieldname": "display_additional_information_section",
+ "fieldtype": "Section Break",
+ "label": "Display Additional Information"
+ },
+ {
+ "depends_on": "show_tabbed_section",
+ "fieldname": "tabs",
+ "fieldtype": "Table",
+ "label": "Tabs",
+ "options": "Website Item Tabbed Section"
+ },
+ {
+ "default": "0",
+ "fieldname": "show_tabbed_section",
+ "fieldtype": "Check",
+ "label": "Add Section with Tabs"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "offers_section",
+ "fieldtype": "Section Break",
+ "label": "Offers"
+ },
+ {
+ "fieldname": "offers",
+ "fieldtype": "Table",
+ "label": "Offers to Display",
+ "options": "Website Offer"
+ },
+ {
+ "fieldname": "column_break_11",
+ "fieldtype": "Column Break"
+ },
+ {
+ "description": "Short Description for List View",
+ "fieldname": "short_description",
+ "fieldtype": "Small Text",
+ "label": "Short Website Description"
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "recommended_items_section",
+ "fieldtype": "Section Break",
+ "label": "Recommended Items"
+ },
+ {
+ "fieldname": "recommended_items",
+ "fieldtype": "Table",
+ "label": "Recommended/Similar Items",
+ "options": "Recommended Items"
+ },
+ {
+ "fieldname": "stock_information_section",
+ "fieldtype": "Section Break",
+ "label": "Stock Information"
+ },
+ {
+ "fieldname": "column_break_24",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "description": "Indicate that Item is available on backorder and not usually pre-stocked",
+ "fieldname": "on_backorder",
+ "fieldtype": "Check",
+ "label": "On Backorder"
+ }
+ ],
+ "has_web_view": 1,
+ "image_field": "image",
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-09-02 13:08:41.942726",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Website Item",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Website Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock User",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Stock Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "search_fields": "web_item_name, item_code, item_group",
+ "show_name_in_global_search": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "title_field": "web_item_name",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/website_item/website_item.py b/erpnext/e_commerce/doctype/website_item/website_item.py
new file mode 100644
index 0000000..62f7f49
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/website_item.py
@@ -0,0 +1,441 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import json
+
+import frappe
+from frappe import _
+from frappe.utils import cint, cstr, flt, random_string
+from frappe.website.doctype.website_slideshow.website_slideshow import get_slideshow
+from frappe.website.website_generator import WebsiteGenerator
+
+from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews
+from erpnext.e_commerce.redisearch_utils import (
+ delete_item_from_index,
+ insert_item_to_index,
+ update_index_for_item,
+)
+from erpnext.e_commerce.shopping_cart.cart import _set_price_list
+from erpnext.setup.doctype.item_group.item_group import (
+ get_parent_item_groups,
+ invalidate_cache_for,
+)
+from erpnext.utilities.product import get_price
+
+
+class WebsiteItem(WebsiteGenerator):
+ website = frappe._dict(
+ page_title_field="web_item_name",
+ condition_field="published",
+ template="templates/generators/item/item.html",
+ no_cache=1
+ )
+
+ def autoname(self):
+ # use naming series to accomodate items with same name (different item code)
+ from frappe.model.naming import make_autoname
+
+ from erpnext.setup.doctype.naming_series.naming_series import get_default_naming_series
+
+ naming_series = get_default_naming_series("Website Item")
+ if not self.name and naming_series:
+ self.name = make_autoname(naming_series, doc=self)
+
+ def onload(self):
+ super(WebsiteItem, self).onload()
+
+ def validate(self):
+ super(WebsiteItem, self).validate()
+
+ if not self.item_code:
+ frappe.throw(_("Item Code is required"), title=_("Mandatory"))
+
+ self.validate_duplicate_website_item()
+ self.validate_website_image()
+ self.make_thumbnail()
+ self.publish_unpublish_desk_item(publish=True)
+
+ if not self.get("__islocal"):
+ wig = frappe.qb.DocType("Website Item Group")
+ query = (
+ frappe.qb.from_(wig)
+ .select(wig.item_group)
+ .where(
+ (wig.parentfield == "website_item_groups")
+ & (wig.parenttype == "Website Item")
+ & (wig.parent == self.name)
+ )
+ )
+ result = query.run(as_list=True)
+
+ self.old_website_item_groups = [x[0] for x in result]
+
+ def on_update(self):
+ invalidate_cache_for_web_item(self)
+ self.update_template_item()
+
+ def on_trash(self):
+ super(WebsiteItem, self).on_trash()
+ delete_item_from_index(self)
+ self.publish_unpublish_desk_item(publish=False)
+
+ def validate_duplicate_website_item(self):
+ existing_web_item = frappe.db.exists("Website Item", {"item_code": self.item_code})
+ if existing_web_item and existing_web_item != self.name:
+ message = _("Website Item already exists against Item {0}").format(frappe.bold(self.item_code))
+ frappe.throw(message, title=_("Already Published"))
+
+ def publish_unpublish_desk_item(self, publish=True):
+ if frappe.db.get_value("Item", self.item_code, "published_in_website") and publish:
+ return # if already published don't publish again
+ frappe.db.set_value("Item", self.item_code, "published_in_website", publish)
+
+ def make_route(self):
+ """Called from set_route in WebsiteGenerator."""
+ if not self.route:
+ return cstr(frappe.db.get_value('Item Group', self.item_group,
+ 'route')) + '/' + self.scrub((self.item_name if self.item_name else self.item_code) + '-' + random_string(5))
+
+ def update_template_item(self):
+ """Publish Template Item if Variant is published."""
+ if self.variant_of:
+ if self.published:
+ # show template
+ template_item = frappe.get_doc("Item", self.variant_of)
+
+ if not template_item.published_in_website:
+ template_item.flags.ignore_permissions = True
+ make_website_item(template_item)
+
+ def validate_website_image(self):
+ if frappe.flags.in_import:
+ return
+
+ """Validate if the website image is a public file"""
+ 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 or frappe.flags.in_migrate:
+ 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": "Website Item",
+ "attached_to_name": self.name
+ })
+ except frappe.DoesNotExistError:
+ pass
+ # 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": "Website 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 get_context(self, context):
+ context.show_search = True
+ context.search_link = "/search"
+ context.body_class = "product-page"
+
+ context.parents = get_parent_item_groups(self.item_group, from_item=True) # breadcumbs
+ self.attributes = frappe.get_all(
+ "Item Variant Attribute",
+ fields=["attribute", "attribute_value"],
+ filters={"parent": self.item_code}
+ )
+
+ if self.slideshow:
+ context.update(get_slideshow(self))
+
+ self.set_metatags(context)
+ self.set_shopping_cart_data(context)
+
+ settings = context.shopping_cart.cart_settings
+
+ self.get_product_details_section(context)
+
+ if settings.get("enable_reviews"):
+ reviews_data = get_item_reviews(self.name)
+ context.update(reviews_data)
+ context.reviews = context.reviews[:4]
+
+ context.wished = False
+ if frappe.db.exists("Wishlist Item", {"item_code": self.item_code, "parent": frappe.session.user}):
+ context.wished = True
+
+ context.user_is_customer = check_if_user_is_customer()
+
+ context.recommended_items = None
+ if settings and settings.enable_recommendations:
+ context.recommended_items = self.get_recommended_items(settings)
+
+ return context
+
+ def set_selected_attributes(self, variants, context, attribute_values_available):
+ for variant in variants:
+ variant.attributes = frappe.get_all(
+ "Item Variant Attribute",
+ filters={"parent": variant.name},
+ fields=["attribute", "attribute_value as value"])
+
+ # make an attribute-value map for easier access in templates
+ variant.attribute_map = frappe._dict(
+ {attr.attribute : attr.value for attr in variant.attributes}
+ )
+
+ for attr in variant.attributes:
+ values = attribute_values_available.setdefault(attr.attribute, [])
+ if attr.value not in values:
+ values.append(attr.value)
+
+ if variant.name == context.variant.name:
+ context.selected_attributes[attr.attribute] = attr.value
+
+ def set_attribute_values(self, attributes, context, attribute_values_available):
+ for attr in 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)
+
+ 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.web_item_name or 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.e_commerce.shopping_cart.product_info import get_product_info_for_website
+ context.shopping_cart = get_product_info_for_website(self.item_code, skip_quotation_creation=True)
+
+ 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 get_product_details_section(self, context):
+ """ Get section with tabs or website specifications. """
+ context.show_tabs = self.show_tabbed_section
+ if self.show_tabbed_section and (self.tabs or self.website_specifications):
+ context.tabs = self.get_tabs()
+ else:
+ context.website_specifications = self.website_specifications
+
+ def get_tabs(self):
+ tab_values = {}
+ tab_values["tab_1_title"] = "Product Details"
+ tab_values["tab_1_content"] = frappe.render_template(
+ "templates/generators/item/item_specifications.html",
+ {
+ "website_specifications": self.website_specifications,
+ "show_tabs": self.show_tabbed_section
+ })
+
+ for row in self.tabs:
+ tab_values[f"tab_{row.idx + 1}_title"] = _(row.label)
+ tab_values[f"tab_{row.idx + 1}_content"] = row.content
+
+ return tab_values
+
+ def get_recommended_items(self, settings):
+ ri = frappe.qb.DocType("Recommended Items")
+ wi = frappe.qb.DocType("Website Item")
+
+ query = (
+ frappe.qb.from_(ri)
+ .join(wi).on(ri.item_code == wi.item_code)
+ .select(
+ ri.item_code, ri.route,
+ ri.website_item_name,
+ ri.website_item_thumbnail
+ ).where(
+ (ri.parent == self.name)
+ & (wi.published == 1)
+ ).orderby(ri.idx)
+ )
+ items = query.run(as_dict=True)
+
+ if settings.show_price:
+ is_guest = frappe.session.user == "Guest"
+ # Show Price if logged in.
+ # If not logged in and price is hidden for guest, skip price fetch.
+ if is_guest and settings.hide_price_for_guest:
+ return items
+
+ selling_price_list = _set_price_list(settings, None)
+ for item in items:
+ item.price_info = get_price(
+ item.item_code,
+ selling_price_list,
+ settings.default_customer_group,
+ settings.company
+ )
+
+ return items
+
+def invalidate_cache_for_web_item(doc):
+ """Invalidate Website Item Group cache and rebuild ItemVariantsCacheManager."""
+ from erpnext.stock.doctype.item.item import invalidate_item_variants_cache_for_website
+
+ 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)
+
+ # Update Search Cache
+ update_index_for_item(doc)
+
+ invalidate_item_variants_cache_for_website(doc)
+
+def on_doctype_update():
+ # since route is a Text column, it needs a length for indexing
+ frappe.db.add_index("Website Item", ["route(500)"])
+
+ frappe.db.add_index("Website Item", ["item_group"])
+ frappe.db.add_index("Website Item", ["brand"])
+
+def check_if_user_is_customer(user=None):
+ from frappe.contacts.doctype.contact.contact import get_contact_name
+
+ if not user:
+ user = frappe.session.user
+
+ contact_name = get_contact_name(user)
+ customer = None
+
+ if contact_name:
+ contact = frappe.get_doc('Contact', contact_name)
+ for link in contact.links:
+ if link.link_doctype == "Customer":
+ customer = link.link_name
+ break
+
+ return True if customer else False
+
+@frappe.whitelist()
+def make_website_item(doc, save=True):
+ if not doc:
+ return
+
+ if isinstance(doc, str):
+ doc = json.loads(doc)
+
+ if frappe.db.exists("Website Item", {"item_code": doc.get("item_code")}):
+ message = _("Website Item already exists against {0}").format(frappe.bold(doc.get("item_code")))
+ frappe.throw(message, title=_("Already Published"))
+
+ website_item = frappe.new_doc("Website Item")
+ website_item.web_item_name = doc.get("item_name")
+
+ fields_to_map = ["item_code", "item_name", "item_group", "stock_uom", "brand", "image",
+ "has_variants", "variant_of", "description"]
+ for field in fields_to_map:
+ website_item.update({field: doc.get(field)})
+
+ if not save:
+ return website_item
+
+ website_item.save()
+
+ # Add to search cache
+ insert_item_to_index(website_item)
+
+ return [website_item.name, website_item.web_item_name]
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/website_item/website_item_list.js b/erpnext/e_commerce/doctype/website_item/website_item_list.js
new file mode 100644
index 0000000..21be942
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item/website_item_list.js
@@ -0,0 +1,20 @@
+frappe.listview_settings['Website Item'] = {
+ add_fields: ["item_name", "web_item_name", "published", "image", "has_variants", "variant_of"],
+ filters: [["published", "=", "1"]],
+
+ get_indicator: function(doc) {
+ if (doc.has_variants && doc.published) {
+ return [__("Template"), "orange", "has_variants,=,Yes|published,=,1"];
+ } else if (doc.has_variants && !doc.published) {
+ return [__("Template"), "grey", "has_variants,=,Yes|published,=,0"];
+ } else if (doc.variant_of && doc.published) {
+ return [__("Variant"), "blue", "published,=,1|variant_of,=," + doc.variant_of];
+ } else if (doc.variant_of && !doc.published) {
+ return [__("Variant"), "grey", "published,=,0|variant_of,=," + doc.variant_of];
+ } else if (doc.published) {
+ return [__("Published"), "green", "published,=,1"];
+ } else {
+ return [__("Not Published"), "grey", "published,=,0"];
+ }
+ }
+};
\ No newline at end of file
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/website_item_tabbed_section/__init__.py
diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json
new file mode 100644
index 0000000..6601dd8
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.json
@@ -0,0 +1,37 @@
+{
+ "actions": [],
+ "creation": "2021-03-18 20:32:15.321402",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "label",
+ "content"
+ ],
+ "fields": [
+ {
+ "fieldname": "label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Label"
+ },
+ {
+ "fieldname": "content",
+ "fieldtype": "HTML Editor",
+ "in_list_view": 1,
+ "label": "Content"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-03-18 20:35:26.991192",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Website Item Tabbed Section",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py
new file mode 100644
index 0000000..91148b8
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_item_tabbed_section/website_item_tabbed_section.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class WebsiteItemTabbedSection(Document):
+ pass
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/website_offer/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/website_offer/__init__.py
diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.json b/erpnext/e_commerce/doctype/website_offer/website_offer.json
new file mode 100644
index 0000000..627d548
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_offer/website_offer.json
@@ -0,0 +1,43 @@
+{
+ "actions": [],
+ "creation": "2021-04-21 13:37:14.162162",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "offer_title",
+ "offer_subtitle",
+ "offer_details"
+ ],
+ "fields": [
+ {
+ "fieldname": "offer_title",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Offer Title"
+ },
+ {
+ "fieldname": "offer_subtitle",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Offer Subtitle"
+ },
+ {
+ "fieldname": "offer_details",
+ "fieldtype": "Text Editor",
+ "label": "Offer Details"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-04-21 13:56:04.660331",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Website Offer",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/website_offer/website_offer.py b/erpnext/e_commerce/doctype/website_offer/website_offer.py
new file mode 100644
index 0000000..d73c132
--- /dev/null
+++ b/erpnext/e_commerce/doctype/website_offer/website_offer.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class WebsiteOffer(Document):
+ pass
+
+@frappe.whitelist(allow_guest=True)
+def get_offer_details(offer_id):
+ return frappe.db.get_value('Website Offer', {'name': offer_id}, ['offer_details'])
diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/e_commerce/doctype/wishlist/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/e_commerce/doctype/wishlist/__init__.py
diff --git a/erpnext/e_commerce/doctype/wishlist/test_wishlist.py b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py
new file mode 100644
index 0000000..504bb65
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist/test_wishlist.py
@@ -0,0 +1,102 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# See license.txt
+import unittest
+
+import frappe
+from frappe.core.doctype.user_permission.test_user_permission import create_user
+
+from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
+from erpnext.e_commerce.doctype.wishlist.wishlist import add_to_wishlist, remove_from_wishlist
+from erpnext.stock.doctype.item.test_item import make_item
+
+
+class TestWishlist(unittest.TestCase):
+ def setUp(self):
+ item = make_item("Test Phone Series X")
+ if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series X"}):
+ make_website_item(item, save=True)
+
+ item = make_item("Test Phone Series Y")
+ if not frappe.db.exists("Website Item", {"item_code": "Test Phone Series Y"}):
+ make_website_item(item, save=True)
+
+ def tearDown(self):
+ frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series X"}).delete()
+ frappe.get_cached_doc("Website Item", {"item_code": "Test Phone Series Y"}).delete()
+ frappe.get_cached_doc("Item", "Test Phone Series X").delete()
+ frappe.get_cached_doc("Item", "Test Phone Series Y").delete()
+
+ def test_add_remove_items_in_wishlist(self):
+ "Check if items are added and removed from user's wishlist."
+ # add first item
+ add_to_wishlist("Test Phone Series X")
+
+ # check if wishlist was created and item was added
+ self.assertTrue(frappe.db.exists("Wishlist", {"user": frappe.session.user}))
+ self.assertTrue(frappe.db.exists("Wishlist Item", {"item_code": "Test Phone Series X", "parent": frappe.session.user}))
+
+ # add second item to wishlist
+ add_to_wishlist("Test Phone Series Y")
+ wishlist_length = frappe.db.get_value(
+ "Wishlist Item",
+ {"parent": frappe.session.user},
+ "count(*)"
+ )
+ self.assertEqual(wishlist_length, 2)
+
+ remove_from_wishlist("Test Phone Series X")
+ remove_from_wishlist("Test Phone Series Y")
+
+ wishlist_length = frappe.db.get_value(
+ "Wishlist Item",
+ {"parent": frappe.session.user},
+ "count(*)"
+ )
+ self.assertIsNone(frappe.db.exists("Wishlist Item", {"parent": frappe.session.user}))
+ self.assertEqual(wishlist_length, 0)
+
+ # tear down
+ frappe.get_doc("Wishlist", {"user": frappe.session.user}).delete()
+
+ def test_add_remove_in_wishlist_multiple_users(self):
+ "Check if items are added and removed from the correct user's wishlist."
+ test_user = create_user("test_reviewer@example.com", "Customer")
+ test_user_1 = create_user("test_reviewer_1@example.com", "Customer")
+
+ # add to wishlist for first user
+ frappe.set_user(test_user.name)
+ add_to_wishlist("Test Phone Series X")
+
+ # add to wishlist for second user
+ frappe.set_user(test_user_1.name)
+ add_to_wishlist("Test Phone Series X")
+
+ # check wishlist and its content for users
+ self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user.name}))
+ self.assertTrue(frappe.db.exists("Wishlist Item",
+ {"item_code": "Test Phone Series X", "parent": test_user.name}))
+
+ self.assertTrue(frappe.db.exists("Wishlist", {"user": test_user_1.name}))
+ self.assertTrue(frappe.db.exists("Wishlist Item",
+ {"item_code": "Test Phone Series X", "parent": test_user_1.name}))
+
+ # remove item for second user
+ remove_from_wishlist("Test Phone Series X")
+
+ # make sure item was removed for second user and not first
+ self.assertFalse(frappe.db.exists("Wishlist Item",
+ {"item_code": "Test Phone Series X", "parent": test_user_1.name}))
+ self.assertTrue(frappe.db.exists("Wishlist Item",
+ {"item_code": "Test Phone Series X", "parent": test_user.name}))
+
+ # remove item for first user
+ frappe.set_user(test_user.name)
+ remove_from_wishlist("Test Phone Series X")
+ self.assertFalse(frappe.db.exists("Wishlist Item",
+ {"item_code": "Test Phone Series X", "parent": test_user.name}))
+
+ # tear down
+ frappe.set_user("Administrator")
+ frappe.get_doc("Wishlist", {"user": test_user.name}).delete()
+ frappe.get_doc("Wishlist", {"user": test_user_1.name}).delete()
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.js b/erpnext/e_commerce/doctype/wishlist/wishlist.js
new file mode 100644
index 0000000..d96e552
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist/wishlist.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+
+frappe.ui.form.on('Wishlist', {
+ // refresh: function(frm) {
+
+ // }
+});
diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.json b/erpnext/e_commerce/doctype/wishlist/wishlist.json
new file mode 100644
index 0000000..922924e
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist/wishlist.json
@@ -0,0 +1,65 @@
+{
+ "actions": [],
+ "autoname": "field:user",
+ "creation": "2021-03-10 18:52:28.769126",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "user",
+ "section_break_2",
+ "items"
+ ],
+ "fields": [
+ {
+ "fieldname": "user",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "User",
+ "options": "User",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "section_break_2",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "items",
+ "fieldtype": "Table",
+ "label": "Items",
+ "options": "Wishlist Item"
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2021-07-08 13:11:21.693956",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Wishlist",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1
+ },
+ {
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Website Manager",
+ "share": 1
+ }
+ ],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/wishlist/wishlist.py b/erpnext/e_commerce/doctype/wishlist/wishlist.py
new file mode 100644
index 0000000..50e3d3a
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist/wishlist.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+
+class Wishlist(Document):
+ pass
+
+@frappe.whitelist()
+def add_to_wishlist(item_code):
+ """Insert Item into wishlist."""
+
+ if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}):
+ return
+
+ web_item_data = frappe.db.get_value(
+ "Website Item",
+ {"item_code": item_code},
+ ["image", "website_warehouse", "name", "web_item_name", "item_name", "item_group", "route"],
+ as_dict=1)
+
+ wished_item_dict = {
+ "item_code": item_code,
+ "item_name": web_item_data.get("item_name"),
+ "item_group": web_item_data.get("item_group"),
+ "website_item": web_item_data.get("name"),
+ "web_item_name": web_item_data.get("web_item_name"),
+ "image": web_item_data.get("image"),
+ "warehouse": web_item_data.get("website_warehouse"),
+ "route": web_item_data.get("route")
+ }
+
+ if not frappe.db.exists("Wishlist", frappe.session.user):
+ # initialise wishlist
+ wishlist = frappe.get_doc({"doctype": "Wishlist"})
+ wishlist.user = frappe.session.user
+ wishlist.append("items", wished_item_dict)
+ wishlist.save(ignore_permissions=True)
+ else:
+ wishlist = frappe.get_doc("Wishlist", frappe.session.user)
+ item = wishlist.append('items', wished_item_dict)
+ item.db_insert()
+
+ if hasattr(frappe.local, "cookie_manager"):
+ frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist.items)))
+
+@frappe.whitelist()
+def remove_from_wishlist(item_code):
+ if frappe.db.exists("Wishlist Item", {"item_code": item_code, "parent": frappe.session.user}):
+ frappe.db.delete(
+ "Wishlist Item",
+ {
+ "item_code": item_code,
+ "parent": frappe.session.user
+ }
+ )
+ frappe.db.commit() # nosemgrep
+
+ wishlist_items = frappe.db.get_values(
+ "Wishlist Item",
+ filters={"parent": frappe.session.user}
+ )
+
+ if hasattr(frappe.local, "cookie_manager"):
+ frappe.local.cookie_manager.set_cookie("wish_count", str(len(wishlist_items)))
\ No newline at end of file
diff --git a/erpnext/portal/doctype/products_settings/__init__.py b/erpnext/e_commerce/doctype/wishlist_item/__init__.py
similarity index 100%
copy from erpnext/portal/doctype/products_settings/__init__.py
copy to erpnext/e_commerce/doctype/wishlist_item/__init__.py
diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json
new file mode 100644
index 0000000..c0414a7
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.json
@@ -0,0 +1,147 @@
+{
+ "actions": [],
+ "creation": "2021-03-10 19:03:00.662714",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "item_code",
+ "website_item",
+ "web_item_name",
+ "column_break_3",
+ "item_name",
+ "item_group",
+ "item_details_section",
+ "description",
+ "column_break_7",
+ "route",
+ "image",
+ "image_view",
+ "section_break_8",
+ "warehouse_section",
+ "warehouse"
+ ],
+ "fields": [
+ {
+ "fetch_from": "website_item.item_code",
+ "fetch_if_empty": 1,
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Item Code",
+ "options": "Item",
+ "reqd": 1
+ },
+ {
+ "fieldname": "website_item",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Website Item",
+ "options": "Website Item",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_3",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "item_code.item_name",
+ "fetch_if_empty": 1,
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "label": "Item Name",
+ "read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "item_details_section",
+ "fieldtype": "Section Break",
+ "label": "Item Details",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "item_code.description",
+ "fetch_if_empty": 1,
+ "fieldname": "description",
+ "fieldtype": "Text Editor",
+ "label": "Description",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_7",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "item_code.image",
+ "fetch_if_empty": 1,
+ "fieldname": "image",
+ "fieldtype": "Attach",
+ "hidden": 1,
+ "label": "Image"
+ },
+ {
+ "fetch_from": "item_code.image",
+ "fetch_if_empty": 1,
+ "fieldname": "image_view",
+ "fieldtype": "Image",
+ "hidden": 1,
+ "label": "Image View",
+ "options": "image",
+ "print_hide": 1
+ },
+ {
+ "fieldname": "warehouse_section",
+ "fieldtype": "Section Break",
+ "label": "Warehouse"
+ },
+ {
+ "fieldname": "warehouse",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Warehouse",
+ "options": "Warehouse",
+ "read_only": 1
+ },
+ {
+ "fieldname": "section_break_8",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fetch_from": "item_code.item_group",
+ "fetch_if_empty": 1,
+ "fieldname": "item_group",
+ "fieldtype": "Link",
+ "label": "Item Group",
+ "options": "Item Group",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "website_item.route",
+ "fetch_if_empty": 1,
+ "fieldname": "route",
+ "fieldtype": "Small Text",
+ "label": "Route",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "website_item.web_item_name",
+ "fetch_if_empty": 1,
+ "fieldname": "web_item_name",
+ "fieldtype": "Data",
+ "label": "Website Item Name",
+ "read_only": 1
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2021-08-09 10:30:41.964802",
+ "modified_by": "Administrator",
+ "module": "E-commerce",
+ "name": "Wishlist Item",
+ "owner": "Administrator",
+ "permissions": [],
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "track_changes": 1
+}
\ No newline at end of file
diff --git a/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py
new file mode 100644
index 0000000..75ebccb
--- /dev/null
+++ b/erpnext/e_commerce/doctype/wishlist_item/wishlist_item.py
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class WishlistItem(Document):
+ pass
diff --git a/erpnext/shopping_cart/search.py b/erpnext/e_commerce/legacy_search.py
similarity index 95%
rename from erpnext/shopping_cart/search.py
rename to erpnext/e_commerce/legacy_search.py
index 5d2de78..752c33e 100644
--- a/erpnext/shopping_cart/search.py
+++ b/erpnext/e_commerce/legacy_search.py
@@ -6,6 +6,7 @@
from whoosh.qparser import FieldsPlugin, MultifieldParser, WildcardPlugin
from whoosh.query import Prefix
+# TODO: Make obsolete
INDEX_NAME = "products"
class ProductSearch(FullTextSearch):
@@ -111,7 +112,7 @@
)
def get_all_published_items():
- return frappe.get_all("Item", filters={"variant_of": "", "show_in_website": 1},pluck="name")
+ return frappe.get_all("Website Item", filters={"variant_of": "", "published": 1}, pluck="item_code")
def update_index_for_path(path):
search = ProductSearch(INDEX_NAME)
diff --git a/erpnext/e_commerce/product_data_engine/filters.py b/erpnext/e_commerce/product_data_engine/filters.py
new file mode 100644
index 0000000..c4a3cb9
--- /dev/null
+++ b/erpnext/e_commerce/product_data_engine/filters.py
@@ -0,0 +1,139 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+import frappe
+from frappe.utils import floor
+
+
+class ProductFiltersBuilder:
+ def __init__(self, item_group=None):
+ if not item_group:
+ self.doc = frappe.get_doc("E Commerce Settings")
+ else:
+ self.doc = frappe.get_doc("Item Group", item_group)
+
+ self.item_group = item_group
+
+ def get_field_filters(self):
+ if not self.item_group and not self.doc.enable_field_filters:
+ return
+
+ fields, filter_data = [], []
+ filter_fields = [row.fieldname for row in self.doc.filter_fields] # fields in settings
+
+ # filter valid field filters i.e. those that exist in Item
+ item_meta = frappe.get_meta('Item', cached=True)
+ fields = [item_meta.get_field(field) for field in filter_fields if item_meta.has_field(field)]
+
+ for df in fields:
+ item_filters, item_or_filters = {}, []
+ link_doctype_values = self.get_filtered_link_doctype_records(df)
+
+ if df.fieldtype == "Link":
+ if self.item_group:
+ item_or_filters.extend([
+ ["item_group", "=", self.item_group],
+ ["Website Item Group", "item_group", "=", self.item_group] # consider website item groups
+ ])
+
+ # Get link field values attached to published items
+ item_filters['published_in_website'] = 1
+ item_values = frappe.get_all(
+ "Item",
+ fields=[df.fieldname],
+ filters=item_filters,
+ or_filters=item_or_filters,
+ distinct="True",
+ pluck=df.fieldname
+ )
+
+ values = list(set(item_values) & link_doctype_values) # intersection of both
+ else:
+ # table multiselect
+ values = list(link_doctype_values)
+
+ # Remove None
+ if None in values:
+ values.remove(None)
+
+ if values:
+ filter_data.append([df, values])
+
+ return filter_data
+
+ def get_filtered_link_doctype_records(self, field):
+ """
+ Get valid link doctype records depending on filters.
+ Apply enable/disable/show_in_website filter.
+ Returns:
+ set: A set containing valid record names
+ """
+ link_doctype = field.get_link_doctype()
+ meta = frappe.get_meta(link_doctype, cached=True) if link_doctype else None
+ if meta:
+ filters = self.get_link_doctype_filters(meta)
+ link_doctype_values = set(d.name for d in frappe.get_all(link_doctype, filters))
+
+ return link_doctype_values if meta else set()
+
+ def get_link_doctype_filters(self, meta):
+ "Filters for Link Doctype eg. 'show_in_website'."
+ filters = {}
+ if not meta:
+ return 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
+
+ return filters
+
+ def get_attribute_filters(self):
+ if not self.item_group and not self.doc.enable_attribute_filters:
+ return
+
+ attributes = [row.attribute for row in self.doc.filter_attributes]
+
+ if not attributes:
+ return []
+
+ result = frappe.get_all(
+ "Item Variant Attribute",
+ filters={
+ "attribute": ["in", attributes],
+ "attribute_value": ["is", "set"]
+ },
+ fields=["attribute", "attribute_value"],
+ distinct=True
+ )
+
+ 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
+
+ def get_discount_filters(self, discounts):
+ discount_filters = []
+
+ # [25.89, 60.5] min max
+ min_discount, max_discount = discounts[0], discounts[1]
+ # [25, 60] rounded min max
+ min_range_absolute, max_range_absolute = floor(min_discount), floor(max_discount)
+
+ min_range = int(min_discount - (min_range_absolute % 10)) # 20
+ max_range = int(max_discount - (max_range_absolute % 10)) # 60
+
+ min_range = (min_range + 10) if min_range != min_range_absolute else min_range # 30 (upper limit of 25.89 in range of 10)
+ max_range = (max_range + 10) if max_range != max_range_absolute else max_range # 60
+
+ for discount in range(min_range, (max_range + 1), 10):
+ label = f"{discount}% and below"
+ discount_filters.append([discount, label])
+
+ return discount_filters
diff --git a/erpnext/e_commerce/product_data_engine/query.py b/erpnext/e_commerce/product_data_engine/query.py
new file mode 100644
index 0000000..007bf8b
--- /dev/null
+++ b/erpnext/e_commerce/product_data_engine/query.py
@@ -0,0 +1,301 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+from frappe.utils import flt
+
+from erpnext.e_commerce.doctype.item_review.item_review import get_customer
+from erpnext.e_commerce.shopping_cart.product_info import get_product_info_for_website
+from erpnext.utilities.product import get_non_stock_item_status
+
+
+class ProductQuery:
+ """Query engine for product listing
+
+ Attributes:
+ fields (list): Fields to fetch in query
+ conditions (string): Conditions for query building
+ or_conditions (string): Search conditions
+ page_length (Int): Length of page for the query
+ settings (Document): E Commerce Settings DocType
+ """
+ def __init__(self):
+ self.settings = frappe.get_doc("E Commerce Settings")
+ self.page_length = self.settings.products_per_page or 20
+
+ self.or_filters = []
+ self.filters = [["published", "=", 1]]
+ self.fields = [
+ "web_item_name", "name", "item_name", "item_code", "website_image",
+ "variant_of", "has_variants", "item_group", "image", "web_long_description",
+ "short_description", "route", "website_warehouse", "ranking", "on_backorder"
+ ]
+
+ def query(self, attributes=None, fields=None, search_term=None, start=0, item_group=None):
+ """
+ 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:
+ dict: Dict containing items, item count & discount range
+ """
+ # track if discounts included in field filters
+ self.filter_with_discount = bool(fields.get("discount"))
+ result, discount_list, website_item_groups, cart_items, count = [], [], [], [], 0
+
+ website_item_groups = self.get_website_item_group_results(item_group, website_item_groups)
+
+ if fields:
+ self.build_fields_filters(fields)
+ if search_term:
+ self.build_search_filters(search_term)
+ if self.settings.hide_variants:
+ self.filters.append(["variant_of", "is", "not set"])
+
+ # query results
+ if attributes:
+ result, count = self.query_items_with_attributes(attributes, start)
+ else:
+ result, count = self.query_items(start=start)
+
+ result = self.combine_web_item_group_results(item_group, result, website_item_groups)
+
+ # sort combined results by ranking
+ result = sorted(result, key=lambda x: x.get("ranking"), reverse=True)
+
+ if self.settings.enabled:
+ cart_items = self.get_cart_items()
+
+ result, discount_list = self.add_display_details(result, discount_list, cart_items)
+
+ discounts = []
+ if discount_list:
+ discounts = [min(discount_list), max(discount_list)]
+
+ result = self.filter_results_by_discount(fields, result)
+
+ return {
+ "items": result,
+ "items_count": count,
+ "discounts": discounts
+ }
+
+ def query_items(self, start=0):
+ """Build a query to fetch Website Items based on field filters."""
+ # MySQL does not support offset without limit,
+ # frappe does not accept two parameters for limit
+ # https://dev.mysql.com/doc/refman/8.0/en/select.html#id4651989
+ count_items = frappe.db.get_all(
+ "Website Item",
+ filters=self.filters,
+ or_filters=self.or_filters,
+ limit_page_length=184467440737095516,
+ limit_start=start, # get all items from this offset for total count ahead
+ order_by="ranking desc")
+ count = len(count_items)
+
+ # If discounts included, return all rows.
+ # Slice after filtering rows with discount (See `filter_results_by_discount`).
+ # Slicing before hand will miss discounted items on the 3rd or 4th page.
+ # Discounts are fetched on computing Pricing Rules so we cannot query them directly.
+ page_length = 184467440737095516 if self.filter_with_discount else self.page_length
+
+ items = frappe.db.get_all(
+ "Website Item",
+ fields=self.fields,
+ filters=self.filters,
+ or_filters=self.or_filters,
+ limit_page_length=page_length,
+ limit_start=start,
+ order_by="ranking desc")
+
+ return items, count
+
+ def query_items_with_attributes(self, attributes, start=0):
+ """Build a query to fetch Website Items based on field & attribute filters."""
+ item_codes = []
+
+ for attribute, values in attributes.items():
+ if not isinstance(values, list):
+ values = [values]
+
+ # get items that have selected attribute & value
+ item_code_list = frappe.db.get_all(
+ "Item",
+ fields=["item_code"],
+ filters=[
+ ["published_in_website", "=", 1],
+ ["Item Variant Attribute", "attribute", "=", attribute],
+ ["Item Variant Attribute", "attribute_value", "in", values]
+ ])
+ item_codes.append({x.item_code for x in item_code_list})
+
+ if item_codes:
+ item_codes = list(set.intersection(*item_codes))
+ self.filters.append(["item_code", "in", item_codes])
+
+ items, count = self.query_items(start=start)
+
+ return items, count
+
+ def build_fields_filters(self, filters):
+ """Build filters for field values
+
+ Args:
+ filters (dict): Filters
+ """
+ for field, values in filters.items():
+ if not values or field == "discount":
+ continue
+
+ # handle multiselect fields in filter addition
+ meta = frappe.get_meta('Website 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 = {'item_code', 'item_name', 'web_long_description', 'item_group'}
+
+ # Get meta search fields
+ meta = frappe.get_meta("Website Item")
+ meta_fields = set(meta.get_search_fields())
+
+ # Join the meta fields and default fields set
+ search_fields = default_fields.union(meta_fields)
+ if frappe.db.count('Website Item', cache=True) > 50000:
+ search_fields.discard('web_long_description')
+
+ # Build or filters for query
+ search = '%{}%'.format(search_term)
+ for field in search_fields:
+ self.or_filters.append([field, "like", search])
+
+ def get_website_item_group_results(self, item_group, website_item_groups):
+ """Get Web Items for Item Group Page via Website Item Groups."""
+ if item_group:
+ website_item_groups = frappe.db.get_all(
+ "Website Item",
+ fields=self.fields + ["`tabWebsite Item Group`.parent as wig_parent"],
+ filters=[
+ ["Website Item Group", "item_group", "=", item_group],
+ ["published", "=", 1]
+ ]
+ )
+ return website_item_groups
+
+ def add_display_details(self, result, discount_list, cart_items):
+ """Add price and availability details in result."""
+ for item in result:
+ product_info = get_product_info_for_website(item.item_code, skip_quotation_creation=True).get('product_info')
+
+ if product_info and product_info['price']:
+ # update/mutate item and discount_list objects
+ self.get_price_discount_info(item, product_info['price'], discount_list)
+
+ if self.settings.show_stock_availability:
+ self.get_stock_availability(item)
+
+ item.in_cart = item.item_code in cart_items
+
+ item.wished = False
+ if frappe.db.exists("Wishlist Item", {"item_code": item.item_code, "parent": frappe.session.user}):
+ item.wished = True
+
+ return result, discount_list
+
+ def get_price_discount_info(self, item, price_object, discount_list):
+ """Modify item object and add price details."""
+ fields = ["formatted_mrp", "formatted_price", "price_list_rate"]
+ for field in fields:
+ item[field] = price_object.get(field)
+
+ if price_object.get('discount_percent'):
+ item.discount_percent = flt(price_object.discount_percent)
+ discount_list.append(price_object.discount_percent)
+
+ if item.formatted_mrp:
+ item.discount = price_object.get('formatted_discount_percent') or \
+ price_object.get('formatted_discount_rate')
+
+ def get_stock_availability(self, item):
+ """Modify item object and add stock details."""
+ item.in_stock = False
+ warehouse = item.get("website_warehouse")
+ is_stock_item = frappe.get_cached_value("Item", item.item_code, "is_stock_item")
+
+ if item.get("on_backorder"):
+ return
+
+ if not is_stock_item:
+ if warehouse:
+ # product bundle case
+ item.in_stock = get_non_stock_item_status(item.item_code, "website_warehouse")
+ else:
+ item.in_stock = True
+ elif warehouse:
+ # stock item and has warehouse
+ actual_qty = frappe.db.get_value(
+ "Bin",
+ {"item_code": item.item_code,"warehouse": item.get("website_warehouse")},
+ "actual_qty")
+ item.in_stock = bool(flt(actual_qty))
+
+ def get_cart_items(self):
+ customer = get_customer(silent=True)
+ if customer:
+ quotation = frappe.get_all("Quotation", fields=["name"], filters=
+ {"party_name": customer, "order_type": "Shopping Cart", "docstatus": 0},
+ order_by="modified desc", limit_page_length=1)
+ if quotation:
+ items = frappe.get_all(
+ "Quotation Item",
+ fields=["item_code"],
+ filters={
+ "parent": quotation[0].get("name")
+ })
+ items = [row.item_code for row in items]
+ return items
+
+ return []
+
+ def combine_web_item_group_results(self, item_group, result, website_item_groups):
+ """Combine results with 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)
+
+ return result
+
+ def filter_results_by_discount(self, fields, result):
+ if fields and fields.get("discount"):
+ discount_percent = frappe.utils.flt(fields["discount"][0])
+ result = [row for row in result if row.get("discount_percent") and row.discount_percent <= discount_percent]
+
+ if self.filter_with_discount:
+ # no limit was added to results while querying
+ # slice results manually
+ result[:self.page_length]
+
+ return result
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py
new file mode 100644
index 0000000..f0f7918
--- /dev/null
+++ b/erpnext/e_commerce/product_data_engine/test_item_group_product_data_engine.py
@@ -0,0 +1,117 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import unittest
+
+import frappe
+
+from erpnext.e_commerce.api import get_product_filter_data
+from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item
+
+test_dependencies = ["Item", "Item Group"]
+
+class TestItemGroupProductDataEngine(unittest.TestCase):
+ "Test Products & Sub-Category Querying for Product Listing on Item Group Page."
+
+ @classmethod
+ def setUpClass(cls):
+ item_codes = [
+ ("Test Mobile A", "_Test Item Group B"),
+ ("Test Mobile B", "_Test Item Group B"),
+ ("Test Mobile C", "_Test Item Group B - 1"),
+ ("Test Mobile D", "_Test Item Group B - 1"),
+ ("Test Mobile E", "_Test Item Group B - 2")
+ ]
+ for item in item_codes:
+ item_code = item[0]
+ item_args = {"item_group": item[1]}
+ if not frappe.db.exists("Website Item", {"item_code": item_code}):
+ create_regular_web_item(item_code, item_args=item_args)
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
+ def test_product_listing_in_item_group(self):
+ "Test if only products belonging to the Item Group are fetched."
+ result = get_product_filter_data(query_args={
+ "field_filters": {},
+ "attribute_filters": {},
+ "start": 0,
+ "item_group": "_Test Item Group B"
+ })
+
+ items = result.get("items")
+ item_codes = [item.get("item_code") for item in items]
+
+ self.assertEqual(len(items), 2)
+ self.assertIn("Test Mobile A", item_codes)
+ self.assertNotIn("Test Mobile C", item_codes)
+
+ def test_products_in_multiple_item_groups(self):
+ """Test if product is visible on multiple item group pages barring its own."""
+ website_item = frappe.get_doc("Website Item", {"item_code": "Test Mobile E"})
+
+ # show item belonging to '_Test Item Group B - 2' in '_Test Item Group B - 1' as well
+ website_item.append("website_item_groups", {
+ "item_group": "_Test Item Group B - 1"
+ })
+ website_item.save()
+
+ result = get_product_filter_data(query_args={
+ "field_filters": {},
+ "attribute_filters": {},
+ "start": 0,
+ "item_group": "_Test Item Group B - 1"
+ })
+
+ items = result.get("items")
+ item_codes = [item.get("item_code") for item in items]
+
+ self.assertEqual(len(items), 3)
+ self.assertIn("Test Mobile E", item_codes) # visible in other item groups
+ self.assertIn("Test Mobile C", item_codes)
+ self.assertIn("Test Mobile D", item_codes)
+
+ result = get_product_filter_data(query_args={
+ "field_filters": {},
+ "attribute_filters": {},
+ "start": 0,
+ "item_group": "_Test Item Group B - 2"
+ })
+
+ items = result.get("items")
+
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0].get("item_code"), "Test Mobile E") # visible in own item group
+
+ def test_item_group_with_sub_groups(self):
+ "Test Valid Sub Item Groups in Item Group Page."
+ frappe.db.set_value("Item Group", "_Test Item Group B - 1", "show_in_website", 1)
+ frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 0)
+
+ result = get_product_filter_data(query_args={
+ "field_filters": {},
+ "attribute_filters": {},
+ "start": 0,
+ "item_group": "_Test Item Group B"
+ })
+
+ self.assertTrue(bool(result.get("sub_categories")))
+
+ child_groups = [d.name for d in result.get("sub_categories")]
+ # check if child group is fetched if shown in website
+ self.assertIn("_Test Item Group B - 1", child_groups)
+
+ frappe.db.set_value("Item Group", "_Test Item Group B - 2", "show_in_website", 1)
+ result = get_product_filter_data(query_args={
+ "field_filters": {},
+ "attribute_filters": {},
+ "start": 0,
+ "item_group": "_Test Item Group B"
+ })
+ child_groups = [d.name for d in result.get("sub_categories")]
+
+ # check if child group is fetched if shown in website
+ self.assertIn("_Test Item Group B - 1", child_groups)
+ self.assertIn("_Test Item Group B - 2", child_groups)
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_data_engine/test_product_data_engine.py b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py
new file mode 100644
index 0000000..9ec336d
--- /dev/null
+++ b/erpnext/e_commerce/product_data_engine/test_product_data_engine.py
@@ -0,0 +1,350 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
+# For license information, please see license.txt
+
+import unittest
+
+import frappe
+
+from erpnext.e_commerce.doctype.e_commerce_settings.test_e_commerce_settings import (
+ setup_e_commerce_settings,
+)
+from erpnext.e_commerce.doctype.website_item.test_website_item import create_regular_web_item
+from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder
+from erpnext.e_commerce.product_data_engine.query import ProductQuery
+
+test_dependencies = ["Item", "Item Group"]
+
+class TestProductDataEngine(unittest.TestCase):
+ "Test Products Querying and Filters for Product Listing."
+
+ @classmethod
+ def setUpClass(cls):
+ item_codes = [
+ ("Test 11I Laptop", "Products"), # rank 1
+ ("Test 12I Laptop", "Products"), # rank 2
+ ("Test 13I Laptop", "Products"), # rank 3
+ ("Test 14I Laptop", "Raw Material"), # rank 4
+ ("Test 15I Laptop", "Raw Material"), # rank 5
+ ("Test 16I Laptop", "Raw Material"), # rank 6
+ ("Test 17I Laptop", "Products") # rank 7
+ ]
+ for index, item in enumerate(item_codes, start=1):
+ item_code = item[0]
+ item_args = {"item_group": item[1]}
+ web_args = {"ranking": index}
+ if not frappe.db.exists("Website Item", {"item_code": item_code}):
+ create_regular_web_item(item_code, item_args=item_args, web_args=web_args)
+
+ setup_e_commerce_settings({
+ "products_per_page": 4,
+ "enable_field_filters": 1,
+ "filter_fields": [{"fieldname": "item_group"}],
+ "enable_attribute_filters": 1,
+ "filter_attributes": [{"attribute": "Test Size"}],
+ "company": "_Test Company",
+ "enabled": 1,
+ "default_customer_group": "_Test Customer Group",
+ "price_list": "_Test Price List India"
+ })
+ frappe.local.shopping_cart_settings = None
+
+ @classmethod
+ def tearDownClass(cls):
+ frappe.db.rollback()
+
+ def test_product_list_ordering_and_paging(self):
+ "Test if website items appear by ranking on different pages."
+ engine = ProductQuery()
+ result = engine.query(
+ attributes={},
+ fields={},
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ self.assertIsNotNone(items)
+ self.assertEqual(len(items), 4)
+ self.assertGreater(result.get("items_count"), 4)
+
+ # check if items appear as per ranking set in setUpClass
+ self.assertEqual(items[0].get("item_code"), "Test 17I Laptop")
+ self.assertEqual(items[1].get("item_code"), "Test 16I Laptop")
+ self.assertEqual(items[2].get("item_code"), "Test 15I Laptop")
+ self.assertEqual(items[3].get("item_code"), "Test 14I Laptop")
+
+ # check next page
+ result = engine.query(
+ attributes={},
+ fields={},
+ search_term=None,
+ start=4,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if items appear as per ranking set in setUpClass on next page
+ self.assertEqual(items[0].get("item_code"), "Test 13I Laptop")
+ self.assertEqual(items[1].get("item_code"), "Test 12I Laptop")
+ self.assertEqual(items[2].get("item_code"), "Test 11I Laptop")
+
+ def test_change_product_ranking(self):
+ "Test if item on second page appear on first if ranking is changed."
+ item_code = "Test 12I Laptop"
+ old_ranking = frappe.db.get_value("Website Item", {"item_code": item_code}, "ranking")
+
+ # low rank, appears on second page
+ self.assertEqual(old_ranking, 2)
+
+ # set ranking as highest rank
+ frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", 10)
+
+ engine = ProductQuery()
+ result = engine.query(
+ attributes={},
+ fields={},
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if item is the first item on the first page
+ self.assertEqual(items[0].get("item_code"), item_code)
+ self.assertEqual(items[1].get("item_code"), "Test 17I Laptop")
+
+ # tear down
+ frappe.db.set_value("Website Item", {"item_code": item_code}, "ranking", old_ranking)
+
+ def test_product_list_field_filter_builder(self):
+ "Test if field filters are fetched correctly."
+ frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 0)
+
+ filter_engine = ProductFiltersBuilder()
+ field_filters = filter_engine.get_field_filters()
+
+ # Web Items belonging to 'Products' and 'Raw Material' are available
+ # but only 'Products' has 'show_in_website' enabled
+ item_group_filters = field_filters[0]
+ docfield = item_group_filters[0]
+ valid_item_groups = item_group_filters[1]
+
+ self.assertEqual(docfield.options, "Item Group")
+ self.assertIn("Products", valid_item_groups)
+ self.assertNotIn("Raw Material", valid_item_groups)
+
+ frappe.db.set_value("Item Group", "Raw Material", "show_in_website", 1)
+ field_filters = filter_engine.get_field_filters()
+
+ #'Products' and 'Raw Materials' both have 'show_in_website' enabled
+ item_group_filters = field_filters[0]
+ docfield = item_group_filters[0]
+ valid_item_groups = item_group_filters[1]
+
+ self.assertEqual(docfield.options, "Item Group")
+ self.assertIn("Products", valid_item_groups)
+ self.assertIn("Raw Material", valid_item_groups)
+
+ def test_product_list_with_field_filter(self):
+ "Test if field filters are applied correctly."
+ field_filters = {"item_group": "Raw Material"}
+
+ engine = ProductQuery()
+ result = engine.query(
+ attributes={},
+ fields=field_filters,
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if only 'Raw Material' are fetched in the right order
+ self.assertEqual(len(items), 3)
+ self.assertEqual(items[0].get("item_code"), "Test 16I Laptop")
+ self.assertEqual(items[1].get("item_code"), "Test 15I Laptop")
+
+ # def test_product_list_with_field_filter_table_multiselect(self):
+ # TODO
+ # pass
+
+ def test_product_list_attribute_filter_builder(self):
+ "Test if attribute filters are fetched correctly."
+ create_variant_web_item()
+
+ filter_engine = ProductFiltersBuilder()
+ attribute_filter = filter_engine.get_attribute_filters()[0]
+ attribute_values = attribute_filter.item_attribute_values
+
+ self.assertEqual(attribute_filter.name, "Test Size")
+ self.assertGreater(len(attribute_values), 0)
+ self.assertIn("Large", attribute_values)
+
+ def test_product_list_with_attribute_filter(self):
+ "Test if attribute filters are applied correctly."
+ create_variant_web_item()
+
+ attribute_filters = {"Test Size": ["Large"]}
+ engine = ProductQuery()
+ result = engine.query(
+ attributes=attribute_filters,
+ fields={},
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if only items with Test Size 'Large' are fetched
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0].get("item_code"), "Test Web Item-L")
+
+ def test_product_list_discount_filter_builder(self):
+ "Test if discount filters are fetched correctly."
+ from erpnext.e_commerce.doctype.website_item.test_website_item import (
+ make_web_item_price,
+ make_web_pricing_rule,
+ )
+
+ item_code = "Test 12I Laptop"
+ make_web_item_price(item_code=item_code)
+ make_web_pricing_rule(
+ title=f"Test Pricing Rule for {item_code}",
+ item_code=item_code,
+ selling=1
+ )
+
+ setup_e_commerce_settings({"show_price": 1})
+ frappe.local.shopping_cart_settings = None
+
+
+ engine = ProductQuery()
+ result = engine.query(
+ attributes={},
+ fields={},
+ search_term=None,
+ start=4,
+ item_group=None
+ )
+ self.assertTrue(bool(result.get("discounts")))
+
+ filter_engine = ProductFiltersBuilder()
+ discount_filters = filter_engine.get_discount_filters(result["discounts"])
+
+ self.assertEqual(len(discount_filters[0]), 2)
+ self.assertEqual(discount_filters[0][0], 10)
+ self.assertEqual(discount_filters[0][1], "10% and below")
+
+ def test_product_list_with_discount_filters(self):
+ "Test if discount filters are applied correctly."
+ from erpnext.e_commerce.doctype.website_item.test_website_item import (
+ make_web_item_price,
+ make_web_pricing_rule,
+ )
+
+ field_filters = {"discount": [10]}
+
+ make_web_item_price(item_code="Test 12I Laptop")
+ make_web_pricing_rule(
+ title="Test Pricing Rule for Test 12I Laptop", # 10% discount
+ item_code="Test 12I Laptop",
+ selling=1
+ )
+ make_web_item_price(item_code="Test 13I Laptop")
+ make_web_pricing_rule(
+ title="Test Pricing Rule for Test 13I Laptop", # 15% discount
+ item_code="Test 13I Laptop",
+ discount_percentage=15,
+ selling=1
+ )
+
+ setup_e_commerce_settings({"show_price": 1})
+ frappe.local.shopping_cart_settings = None
+
+ engine = ProductQuery()
+ result = engine.query(
+ attributes={},
+ fields=field_filters,
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if only product with 10% and below discount are fetched
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0].get("item_code"), "Test 12I Laptop")
+
+ def test_product_list_with_api(self):
+ "Test products listing using API."
+ from erpnext.e_commerce.api import get_product_filter_data
+
+ create_variant_web_item()
+
+ result = get_product_filter_data(query_args={
+ "field_filters": {
+ "item_group": "Products"
+ },
+ "attribute_filters": {
+ "Test Size": ["Large"]
+ },
+ "start": 0
+ })
+
+ items = result.get("items")
+
+ self.assertEqual(len(items), 1)
+ self.assertEqual(items[0].get("item_code"), "Test Web Item-L")
+
+ def test_product_list_with_variants(self):
+ "Test if variants are hideen on hiding variants in settings."
+ create_variant_web_item()
+
+ setup_e_commerce_settings({
+ "enable_attribute_filters": 0,
+ "hide_variants": 1
+ })
+ frappe.local.shopping_cart_settings = None
+
+ attribute_filters = {"Test Size": ["Large"]}
+ engine = ProductQuery()
+ result = engine.query(
+ attributes=attribute_filters,
+ fields={},
+ search_term=None,
+ start=0,
+ item_group=None
+ )
+ items = result.get("items")
+
+ # check if any variants are fetched even though published variant exists
+ self.assertEqual(len(items), 0)
+
+ # tear down
+ setup_e_commerce_settings({
+ "enable_attribute_filters": 1,
+ "hide_variants": 0
+ })
+
+def create_variant_web_item():
+ "Create Variant and Template Website Items."
+ 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
+
+ make_item("Test Web Item", {
+ "has_variant": 1,
+ "variant_based_on": "Item Attribute",
+ "attributes": [
+ {
+ "attribute": "Test Size"
+ }
+ ]
+ })
+ if not frappe.db.exists("Item", "Test Web Item-L"):
+ variant = create_variant("Test Web Item", {"Test Size": "Large"})
+ variant.save()
+
+ if not frappe.db.exists("Website Item", {"variant_of": "Test Web Item"}):
+ make_website_item(variant, save=True)
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_ui/grid.js b/erpnext/e_commerce/product_ui/grid.js
new file mode 100644
index 0000000..9eb1d45
--- /dev/null
+++ b/erpnext/e_commerce/product_ui/grid.js
@@ -0,0 +1,201 @@
+erpnext.ProductGrid = class {
+ /* Options:
+ - items: Items
+ - settings: E Commerce Settings
+ - products_section: Products Wrapper
+ - preference: If preference is not grid view, render but hide
+ */
+ constructor(options) {
+ Object.assign(this, options);
+
+ if (this.preference !== "Grid View") {
+ this.products_section.addClass("hidden");
+ }
+
+ this.products_section.empty();
+ this.make();
+ }
+
+ make() {
+ let me = this;
+ let html = ``;
+
+ this.items.forEach(item => {
+ let title = item.web_item_name || item.item_name || item.item_code || "";
+ title = title.length > 90 ? title.substr(0, 90) + "..." : title;
+
+ html += `<div class="col-sm-4 item-card"><div class="card text-left">`;
+ html += me.get_image_html(item, title);
+ html += me.get_card_body_html(item, title, me.settings);
+ html += `</div></div>`;
+ });
+
+ let $product_wrapper = this.products_section;
+ $product_wrapper.append(html);
+ }
+
+ get_image_html(item, title) {
+ let image = item.website_image || item.image;
+
+ if (image) {
+ return `
+ <div class="card-img-container">
+ <a href="/${ item.route || '#' }" style="text-decoration: none;">
+ <img class="card-img" src="${ image }" alt="${ title }">
+ </a>
+ </div>
+ `;
+ } else {
+ return `
+ <div class="card-img-container">
+ <a href="/${ item.route || '#' }" style="text-decoration: none;">
+ <div class="card-img-top no-image">
+ ${ frappe.get_abbr(title) }
+ </div>
+ </a>
+ </div>
+ `;
+ }
+ }
+
+ get_card_body_html(item, title, settings) {
+ let body_html = `
+ <div class="card-body text-left card-body-flex" style="width:100%">
+ <div style="margin-top: 1rem; display: flex;">
+ `;
+ body_html += this.get_title(item, title);
+
+ // get floating elements
+ if (!item.has_variants) {
+ if (settings.enable_wishlist) {
+ body_html += this.get_wishlist_icon(item);
+ }
+ if (settings.enabled) {
+ body_html += this.get_cart_indicator(item);
+ }
+
+ }
+
+ body_html += `</div>`;
+ body_html += `<div class="product-category">${ item.item_group || '' }</div>`;
+
+ if (item.formatted_price) {
+ body_html += this.get_price_html(item);
+ }
+
+ body_html += this.get_stock_availability(item, settings);
+ body_html += this.get_primary_button(item, settings);
+ body_html += `</div>`; // close div on line 49
+
+ return body_html;
+ }
+
+ get_title(item, title) {
+ let title_html = `
+ <a href="/${ item.route || '#' }">
+ <div class="product-title">
+ ${ title || '' }
+ </div>
+ </a>
+ `;
+ return title_html;
+ }
+
+ get_wishlist_icon(item) {
+ let icon_class = item.wished ? "wished" : "not-wished";
+ return `
+ <div class="like-action ${ item.wished ? "like-action-wished" : ''}"
+ data-item-code="${ item.item_code }">
+ <svg class="icon sm">
+ <use class="${ icon_class } wish-icon" href="#icon-heart"></use>
+ </svg>
+ </div>
+ `;
+ }
+
+ get_cart_indicator(item) {
+ return `
+ <div class="cart-indicator ${item.in_cart ? '' : 'hidden'}" data-item-code="${ item.item_code }">
+ 1
+ </div>
+ `;
+ }
+
+ get_price_html(item) {
+ let price_html = `
+ <div class="product-price">
+ ${ item.formatted_price || '' }
+ `;
+
+ if (item.formatted_mrp) {
+ price_html += `
+ <small class="striked-price">
+ <s>${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" }</s>
+ </small>
+ <small class="ml-1 product-info-green">
+ ${ item.discount } OFF
+ </small>
+ `;
+ }
+ price_html += `</div>`;
+ return price_html;
+ }
+
+ get_stock_availability(item, settings) {
+ if (settings.show_stock_availability && !item.has_variants) {
+ if (item.on_backorder) {
+ return `
+ <span class="out-of-stock mb-2 mt-1" style="color: var(--primary-color)">
+ ${ __("Available on backorder") }
+ </span>
+ `;
+ } else if (!item.in_stock) {
+ return `
+ <span class="out-of-stock mb-2 mt-1">
+ ${ __("Out of stock") }
+ </span>
+ `;
+ }
+ }
+
+ return ``;
+ }
+
+ get_primary_button(item, settings) {
+ if (item.has_variants) {
+ return `
+ <a href="/${ item.route || '#' }">
+ <div class="btn btn-sm btn-explore-variants w-100 mt-4">
+ ${ __('Explore') }
+ </div>
+ </a>
+ `;
+ } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) {
+ return `
+ <div id="${ item.name }" class="btn
+ btn-sm btn-primary btn-add-to-cart-list
+ w-100 mt-2 ${ item.in_cart ? 'hidden' : '' }"
+ data-item-code="${ item.item_code }">
+ <span class="mr-2">
+ <svg class="icon icon-md">
+ <use href="#icon-assets"></use>
+ </svg>
+ </span>
+ ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') }
+ </div>
+
+ <a href="/cart">
+ <div id="${ item.name }" class="btn
+ btn-sm btn-primary btn-add-to-cart-list
+ w-100 mt-4 go-to-cart-grid
+ ${ item.in_cart ? '' : 'hidden' }"
+ data-item-code="${ item.item_code }">
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') }
+ </div>
+ </a>
+ `;
+ } else {
+ return ``;
+ }
+ }
+};
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_ui/list.js b/erpnext/e_commerce/product_ui/list.js
new file mode 100644
index 0000000..691cd4d
--- /dev/null
+++ b/erpnext/e_commerce/product_ui/list.js
@@ -0,0 +1,204 @@
+erpnext.ProductList = class {
+ /* Options:
+ - items: Items
+ - settings: E Commerce Settings
+ - products_section: Products Wrapper
+ - preference: If preference is not list view, render but hide
+ */
+ constructor(options) {
+ Object.assign(this, options);
+
+ if (this.preference !== "List View") {
+ this.products_section.addClass("hidden");
+ }
+
+ this.products_section.empty();
+ this.make();
+ }
+
+ make() {
+ let me = this;
+ let html = `<br><br>`;
+
+ this.items.forEach(item => {
+ let title = item.web_item_name || item.item_name || item.item_code || "";
+ title = title.length > 200 ? title.substr(0, 200) + "..." : title;
+
+ html += `<div class='row list-row w-100 mb-4'>`;
+ html += me.get_image_html(item, title, me.settings);
+ html += me.get_row_body_html(item, title, me.settings);
+ html += `</div>`;
+ });
+
+ let $product_wrapper = this.products_section;
+ $product_wrapper.append(html);
+ }
+
+ get_image_html(item, title, settings) {
+ let image = item.website_image || item.image;
+ let wishlist_enabled = !item.has_variants && settings.enable_wishlist;
+ let image_html = ``;
+
+ if (image) {
+ image_html += `
+ <div class="col-2 border text-center rounded list-image">
+ <a class="product-link product-list-link" href="/${ item.route || '#' }">
+ <img itemprop="image" class="website-image h-100 w-100" alt="${ title }"
+ src="${ image }">
+ </a>
+ ${ wishlist_enabled ? this.get_wishlist_icon(item): '' }
+ </div>
+ `;
+ } else {
+ image_html += `
+ <div class="col-2 border text-center rounded list-image">
+ <a class="product-link product-list-link" href="/${ item.route || '#' }"
+ style="text-decoration: none">
+ <div class="card-img-top no-image-list">
+ ${ frappe.get_abbr(title) }
+ </div>
+ </a>
+ ${ wishlist_enabled ? this.get_wishlist_icon(item): '' }
+ </div>
+ `;
+ }
+
+ return image_html;
+ }
+
+ get_row_body_html(item, title, settings) {
+ let body_html = `<div class='col-10 text-left'>`;
+ body_html += this.get_title_html(item, title, settings);
+ body_html += this.get_item_details(item, settings);
+ body_html += `</div>`;
+ return body_html;
+ }
+
+ get_title_html(item, title, settings) {
+ let title_html = `<div style="display: flex; margin-left: -15px;">`;
+ title_html += `
+ <div class="col-8" style="margin-right: -15px;">
+ <a class="" href="/${ item.route || '#' }"
+ style="color: var(--gray-800); font-weight: 500;">
+ ${ title }
+ </a>
+ </div>
+ `;
+
+ if (settings.enabled) {
+ title_html += `<div class="col-4 cart-action-container ${item.in_cart ? 'd-flex' : ''}">`;
+ title_html += this.get_primary_button(item, settings);
+ title_html += `</div>`;
+ }
+ title_html += `</div>`;
+
+ return title_html;
+ }
+
+ get_item_details(item, settings) {
+ let details = `
+ <p class="product-code">
+ ${ item.item_group } | Item Code : ${ item.item_code }
+ </p>
+ <div class="mt-2" style="color: var(--gray-600) !important; font-size: 13px;">
+ ${ item.short_description || '' }
+ </div>
+ <div class="product-price">
+ ${ item.formatted_price || '' }
+ `;
+
+ if (item.formatted_mrp) {
+ details += `
+ <small class="striked-price">
+ <s>${ item.formatted_mrp ? item.formatted_mrp.replace(/ +/g, "") : "" }</s>
+ </small>
+ <small class="ml-1 product-info-green">
+ ${ item.discount } OFF
+ </small>
+ `;
+ }
+
+ details += this.get_stock_availability(item, settings);
+ details += `</div>`;
+
+ return details;
+ }
+
+ get_stock_availability(item, settings) {
+ if (settings.show_stock_availability && !item.has_variants) {
+ if (item.on_backorder) {
+ return `
+ <br>
+ <span class="out-of-stock mt-2" style="color: var(--primary-color)">
+ ${ __("Available on backorder") }
+ </span>
+ `;
+ } else if (!item.in_stock) {
+ return `
+ <br>
+ <span class="out-of-stock mt-2">${ __("Out of stock") }</span>
+ `;
+ }
+ }
+ return ``;
+ }
+
+ get_wishlist_icon(item) {
+ let icon_class = item.wished ? "wished" : "not-wished";
+
+ return `
+ <div class="like-action-list ${ item.wished ? "like-action-wished" : ''}"
+ data-item-code="${ item.item_code }">
+ <svg class="icon sm">
+ <use class="${ icon_class } wish-icon" href="#icon-heart"></use>
+ </svg>
+ </div>
+ `;
+ }
+
+ get_primary_button(item, settings) {
+ if (item.has_variants) {
+ return `
+ <a href="/${ item.route || '#' }">
+ <div class="btn btn-sm btn-explore-variants btn mb-0 mt-0">
+ ${ __('Explore') }
+ </div>
+ </a>
+ `;
+ } else if (settings.enabled && (settings.allow_items_not_in_stock || item.in_stock)) {
+ return `
+ <div id="${ item.name }" class="btn
+ btn-sm btn-primary btn-add-to-cart-list mb-0
+ ${ item.in_cart ? 'hidden' : '' }"
+ data-item-code="${ item.item_code }"
+ style="margin-top: 0px !important; max-height: 30px; float: right;
+ padding: 0.25rem 1rem; min-width: 135px;">
+ <span class="mr-2">
+ <svg class="icon icon-md">
+ <use href="#icon-assets"></use>
+ </svg>
+ </span>
+ ${ settings.enable_checkout ? __('Add to Cart') : __('Add to Quote') }
+ </div>
+
+ <div class="cart-indicator list-indicator ${item.in_cart ? '' : 'hidden'}">
+ 1
+ </div>
+
+ <a href="/cart">
+ <div id="${ item.name }" class="btn
+ btn-sm btn-primary btn-add-to-cart-list
+ ml-4 go-to-cart mb-0 mt-0
+ ${ item.in_cart ? '' : 'hidden' }"
+ data-item-code="${ item.item_code }"
+ style="padding: 0.25rem 1rem; min-width: 135px;">
+ ${ settings.enable_checkout ? __('Go to Cart') : __('Go to Quote') }
+ </div>
+ </a>
+ `;
+ } else {
+ return ``;
+ }
+ }
+
+};
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_ui/search.js b/erpnext/e_commerce/product_ui/search.js
new file mode 100644
index 0000000..6192245
--- /dev/null
+++ b/erpnext/e_commerce/product_ui/search.js
@@ -0,0 +1,244 @@
+erpnext.ProductSearch = class {
+ constructor(opts) {
+ /* Options: search_box_id (for custom search box) */
+ $.extend(this, opts);
+ this.MAX_RECENT_SEARCHES = 4;
+ this.search_box_id = this.search_box_id || "#search-box";
+ this.searchBox = $(this.search_box_id);
+
+ this.setupSearchDropDown();
+ this.bindSearchAction();
+ }
+
+ setupSearchDropDown() {
+ this.search_area = $("#dropdownMenuSearch");
+ this.setupSearchResultContainer();
+ this.populateRecentSearches();
+ }
+
+ bindSearchAction() {
+ let me = this;
+
+ // Show Search dropdown
+ this.searchBox.on("focus", () => {
+ this.search_dropdown.removeClass("hidden");
+ });
+
+ // If click occurs outside search input/results, hide results.
+ // Click can happen anywhere on the page
+ $("body").on("click", (e) => {
+ let searchEvent = $(e.target).closest(this.search_box_id).length;
+ let resultsEvent = $(e.target).closest('#search-results-container').length;
+ let isResultHidden = this.search_dropdown.hasClass("hidden");
+
+ if (!searchEvent && !resultsEvent && !isResultHidden) {
+ this.search_dropdown.addClass("hidden");
+ }
+ });
+
+ // Process search input
+ this.searchBox.on("input", (e) => {
+ let query = e.target.value;
+
+ if (query.length == 0) {
+ me.populateResults(null);
+ me.populateCategoriesList(null);
+ }
+
+ if (query.length < 3 || !query.length) return;
+
+ frappe.call({
+ method: "erpnext.templates.pages.product_search.search",
+ args: {
+ query: query
+ },
+ callback: (data) => {
+ let product_results = null, category_results = null;
+
+ // Populate product results
+ product_results = data.message ? data.message.product_results : null;
+ me.populateResults(product_results);
+
+ // Populate categories
+ if (me.category_container) {
+ category_results = data.message ? data.message.category_results : null;
+ me.populateCategoriesList(category_results);
+ }
+
+ // Populate recent search chips only on successful queries
+ if (!$.isEmptyObject(product_results) || !$.isEmptyObject(category_results)) {
+ me.setRecentSearches(query);
+ }
+ }
+ });
+
+ this.search_dropdown.removeClass("hidden");
+ });
+ }
+
+ setupSearchResultContainer() {
+ this.search_dropdown = this.search_area.append(`
+ <div class="overflow-hidden shadow dropdown-menu w-100 hidden"
+ id="search-results-container"
+ aria-labelledby="dropdownMenuSearch"
+ style="display: flex; flex-direction: column;">
+ </div>
+ `).find("#search-results-container");
+
+ this.setupCategoryContainer();
+ this.setupProductsContainer();
+ this.setupRecentsContainer();
+ }
+
+ setupProductsContainer() {
+ this.products_container = this.search_dropdown.append(`
+ <div id="product-results mt-2">
+ <div id="product-scroll" style="overflow: scroll; max-height: 300px">
+ </div>
+ </div>
+ `).find("#product-scroll");
+ }
+
+ setupCategoryContainer() {
+ this.category_container = this.search_dropdown.append(`
+ <div class="category-container mt-2 mb-1">
+ <div class="category-chips">
+ </div>
+ </div>
+ `).find(".category-chips");
+ }
+
+ setupRecentsContainer() {
+ let $recents_section = this.search_dropdown.append(`
+ <div class="mb-2 mt-2 recent-searches">
+ <div>
+ <b>${ __("Recent") }</b>
+ </div>
+ </div>
+ `).find(".recent-searches");
+
+ this.recents_container = $recents_section.append(`
+ <div id="recents" style="padding: .25rem 0 1rem 0;">
+ </div>
+ `).find("#recents");
+ }
+
+ getRecentSearches() {
+ return JSON.parse(localStorage.getItem("recent_searches") || "[]");
+ }
+
+ attachEventListenersToChips() {
+ let me = this;
+ const chips = $(".recent-search");
+ window.chips = chips;
+
+ for (let chip of chips) {
+ chip.addEventListener("click", () => {
+ me.searchBox[0].value = chip.innerText.trim();
+
+ // Start search with `recent query`
+ me.searchBox.trigger("input");
+ me.searchBox.focus();
+ });
+ }
+ }
+
+ setRecentSearches(query) {
+ let recents = this.getRecentSearches();
+ if (recents.length >= this.MAX_RECENT_SEARCHES) {
+ // Remove the `first` query
+ recents.splice(0, 1);
+ }
+
+ if (recents.indexOf(query) >= 0) {
+ return;
+ }
+
+ recents.push(query);
+ localStorage.setItem("recent_searches", JSON.stringify(recents));
+
+ this.populateRecentSearches();
+ }
+
+ populateRecentSearches() {
+ let recents = this.getRecentSearches();
+
+ if (!recents.length) {
+ this.recents_container.html(`<span class=""text-muted">No searches yet.</span>`);
+ return;
+ }
+
+ let html = "";
+ recents.forEach((key) => {
+ html += `
+ <div class="recent-search mr-1" style="font-size: 13px">
+ <span class="mr-2">
+ <svg width="20" height="20" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M8 14C11.3137 14 14 11.3137 14 8C14 4.68629 11.3137 2 8 2C4.68629 2 2 4.68629 2 8C2 11.3137 4.68629 14 8 14Z" stroke="var(--gray-500)"" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M8.00027 5.20947V8.00017L10 10" stroke="var(--gray-500)" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ </span>
+ ${ key }
+ </div>
+ `;
+ });
+
+ this.recents_container.html(html);
+ this.attachEventListenersToChips();
+ }
+
+ populateResults(product_results) {
+ if (!product_results || product_results.length === 0) {
+ let empty_html = ``;
+ this.products_container.html(empty_html);
+ return;
+ }
+
+ let html = "";
+
+ product_results.forEach((res) => {
+ let thumbnail = res.thumbnail || '/assets/erpnext/images/ui-states/cart-empty-state.png';
+ html += `
+ <div class="dropdown-item" style="display: flex;">
+ <img class="item-thumb col-2" src=${thumbnail} />
+ <div class="col-9" style="white-space: normal;">
+ <a href="/${res.route}">${res.web_item_name}</a><br>
+ <span class="brand-line">${res.brand ? "by " + res.brand : ""}</span>
+ </div>
+ </div>
+ `;
+ });
+
+ this.products_container.html(html);
+ }
+
+ populateCategoriesList(category_results) {
+ if (!category_results || category_results.length === 0) {
+ let empty_html = `
+ <div class="category-container mt-2">
+ <div class="category-chips">
+ </div>
+ </div>
+ `;
+ this.category_container.html(empty_html);
+ return;
+ }
+
+ let html = `
+ <div class="mb-2">
+ <b>${ __("Categories") }</b>
+ </div>
+ `;
+
+ category_results.forEach((category) => {
+ html += `
+ <a href="/${category.route}" class="btn btn-sm category-chip mr-2 mb-2"
+ style="font-size: 13px" role="button">
+ ${ category.name }
+ </button>
+ `;
+ });
+
+ this.category_container.html(html);
+ }
+};
\ No newline at end of file
diff --git a/erpnext/e_commerce/product_ui/views.js b/erpnext/e_commerce/product_ui/views.js
new file mode 100644
index 0000000..1b5c440
--- /dev/null
+++ b/erpnext/e_commerce/product_ui/views.js
@@ -0,0 +1,532 @@
+erpnext.ProductView = class {
+ /* Options:
+ - View Type
+ - Products Section Wrapper,
+ - Item Group: If its an Item Group page
+ */
+ constructor(options) {
+ Object.assign(this, options);
+ this.preference = this.view_type;
+ this.make();
+ }
+
+ make(from_filters=false) {
+ this.products_section.empty();
+ this.prepare_toolbar();
+ this.get_item_filter_data(from_filters);
+ }
+
+ prepare_toolbar() {
+ this.products_section.append(`
+ <div class="toolbar d-flex">
+ </div>
+ `);
+ this.prepare_search();
+ this.prepare_view_toggler();
+
+ new erpnext.ProductSearch();
+ }
+
+ prepare_view_toggler() {
+
+ if (!$("#list").length || !$("#image-view").length) {
+ this.render_view_toggler();
+ this.bind_view_toggler_actions();
+ this.set_view_state();
+ }
+ }
+
+ get_item_filter_data(from_filters=false) {
+ // Get and render all Product related views
+ let me = this;
+ this.from_filters = from_filters;
+ let args = this.get_query_filters();
+
+ this.disable_view_toggler(true);
+
+ frappe.call({
+ method: "erpnext.e_commerce.api.get_product_filter_data",
+ args: {
+ query_args: args
+ },
+ callback: function(result) {
+ if (!result || result.exc || !result.message || result.message.exc) {
+ me.render_no_products_section(true);
+ } else {
+ // Sub Category results are independent of Items
+ if (me.item_group && result.message["sub_categories"].length) {
+ me.render_item_sub_categories(result.message["sub_categories"]);
+ }
+
+ if (!result.message["items"].length) {
+ // if result has no items or result is empty
+ me.render_no_products_section();
+ } else {
+ // Add discount filters
+ me.re_render_discount_filters(result.message["filters"].discount_filters);
+
+ // Render views
+ me.render_list_view(result.message["items"], result.message["settings"]);
+ me.render_grid_view(result.message["items"], result.message["settings"]);
+
+ me.products = result.message["items"];
+ me.product_count = result.message["items_count"];
+ }
+
+ // Bind filter actions
+ if (!from_filters) {
+ // If `get_product_filter_data` was triggered after checking a filter,
+ // don't touch filters unnecessarily, only data must change
+ // filter persistence is handle on filter change event
+ me.bind_filters();
+ me.restore_filters_state();
+ }
+
+ // Bottom paging
+ me.add_paging_section(result.message["settings"]);
+ }
+
+ me.disable_view_toggler(false);
+ }
+ });
+ }
+
+ disable_view_toggler(disable=false) {
+ $('#list').prop('disabled', disable);
+ $('#image-view').prop('disabled', disable);
+ }
+
+ render_grid_view(items, settings) {
+ // loop over data and add grid html to it
+ let me = this;
+ this.prepare_product_area_wrapper("grid");
+
+ new erpnext.ProductGrid({
+ items: items,
+ products_section: $("#products-grid-area"),
+ settings: settings,
+ preference: me.preference
+ });
+ }
+
+ render_list_view(items, settings) {
+ let me = this;
+ this.prepare_product_area_wrapper("list");
+
+ new erpnext.ProductList({
+ items: items,
+ products_section: $("#products-list-area"),
+ settings: settings,
+ preference: me.preference
+ });
+ }
+
+ prepare_product_area_wrapper(view) {
+ let left_margin = view == "list" ? "ml-2" : "";
+ let top_margin = view == "list" ? "mt-6" : "mt-minus-1";
+ return this.products_section.append(`
+ <br>
+ <div id="products-${view}-area" class="row products-list ${ top_margin } ${ left_margin }"></div>
+ `);
+ }
+
+ get_query_filters() {
+ const filters = frappe.utils.get_query_params();
+ let {field_filters, attribute_filters} = filters;
+
+ field_filters = field_filters ? JSON.parse(field_filters) : {};
+ attribute_filters = attribute_filters ? JSON.parse(attribute_filters) : {};
+
+ return {
+ field_filters: field_filters,
+ attribute_filters: attribute_filters,
+ item_group: this.item_group,
+ start: filters.start || null,
+ from_filters: this.from_filters || false
+ };
+ }
+
+ add_paging_section(settings) {
+ $(".product-paging-area").remove();
+
+ if (this.products) {
+ let paging_html = `
+ <div class="row product-paging-area mt-5">
+ <div class="col-3">
+ </div>
+ <div class="col-9 text-right">
+ `;
+ let query_params = frappe.utils.get_query_params();
+ let start = query_params.start ? cint(JSON.parse(query_params.start)) : 0;
+ let page_length = settings.products_per_page || 0;
+
+ let prev_disable = start > 0 ? "" : "disabled";
+ let next_disable = (this.product_count > page_length) ? "" : "disabled";
+
+ paging_html += `
+ <button class="btn btn-default btn-prev" data-start="${ start - page_length }"
+ style="float: left" ${prev_disable}>
+ ${ __("Prev") }
+ </button>`;
+
+ paging_html += `
+ <button class="btn btn-default btn-next" data-start="${ start + page_length }"
+ ${next_disable}>
+ ${ __("Next") }
+ </button>
+ `;
+
+ paging_html += `</div></div>`;
+
+ $(".page_content").append(paging_html);
+ this.bind_paging_action();
+ }
+ }
+
+ prepare_search() {
+ $(".toolbar").append(`
+ <div class="input-group col-8 p-0">
+ <div class="dropdown w-100" id="dropdownMenuSearch">
+ <input type="search" name="query" id="search-box" class="form-control font-md"
+ placeholder="Search for Products"
+ aria-label="Product" aria-describedby="button-addon2">
+ <div class="search-icon">
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor" stroke-width="2" stroke-linecap="round"
+ stroke-linejoin="round"
+ class="feather feather-search">
+ <circle cx="11" cy="11" r="8"></circle>
+ <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
+ </svg>
+ </div>
+ <!-- Results dropdown rendered in product_search.js -->
+ </div>
+ </div>
+ `);
+ }
+
+ render_view_toggler() {
+ $(".toolbar").append(`<div class="toggle-container col-4 p-0"></div>`);
+
+ ["btn-list-view", "btn-grid-view"].forEach(view => {
+ let icon = view === "btn-list-view" ? "list" : "image-view";
+ $(".toggle-container").append(`
+ <div class="form-group mb-0" id="toggle-view">
+ <button id="${ icon }" class="btn ${ view } mr-2">
+ <span>
+ <svg class="icon icon-md">
+ <use href="#icon-${ icon }"></use>
+ </svg>
+ </span>
+ </button>
+ </div>
+ `);
+ });
+ }
+
+ bind_view_toggler_actions() {
+ $("#list").click(function() {
+ let $btn = $(this);
+ $btn.removeClass('btn-primary');
+ $btn.addClass('btn-primary');
+ $(".btn-grid-view").removeClass('btn-primary');
+
+ $("#products-grid-area").addClass("hidden");
+ $("#products-list-area").removeClass("hidden");
+ localStorage.setItem("product_view", "List View");
+ });
+
+ $("#image-view").click(function() {
+ let $btn = $(this);
+ $btn.removeClass('btn-primary');
+ $btn.addClass('btn-primary');
+ $(".btn-list-view").removeClass('btn-primary');
+
+ $("#products-list-area").addClass("hidden");
+ $("#products-grid-area").removeClass("hidden");
+ localStorage.setItem("product_view", "Grid View");
+ });
+ }
+
+ set_view_state() {
+ if (this.preference === "List View") {
+ $("#list").addClass('btn-primary');
+ $("#image-view").removeClass('btn-primary');
+ } else {
+ $("#image-view").addClass('btn-primary');
+ $("#list").removeClass('btn-primary');
+ }
+ }
+
+ bind_paging_action() {
+ let me = this;
+ $('.btn-prev, .btn-next').click((e) => {
+ const $btn = $(e.target);
+ me.from_filters = false;
+
+ $btn.prop('disabled', true);
+ const start = $btn.data('start');
+
+ let query_params = frappe.utils.get_query_params();
+ query_params.start = start;
+ let path = window.location.pathname + '?' + frappe.utils.get_url_from_dict(query_params);
+ window.location.href = path;
+ });
+ }
+
+ re_render_discount_filters(filter_data) {
+ this.get_discount_filter_html(filter_data);
+ if (this.from_filters) {
+ // Bind filter action if triggered via filters
+ // if not from filter action, page load will bind actions
+ this.bind_discount_filter_action();
+ }
+ // discount filters are rendered with Items (later)
+ // unlike the other filters
+ this.restore_discount_filter();
+ }
+
+ get_discount_filter_html(filter_data) {
+ $("#discount-filters").remove();
+ if (filter_data) {
+ $("#product-filters").append(`
+ <div id="discount-filters" class="mb-4 filter-block pb-5">
+ <div class="filter-label mb-3">${ __("Discounts") }</div>
+ </div>
+ `);
+
+ let html = `<div class="filter-options">`;
+ filter_data.forEach(filter => {
+ html += `
+ <div class="checkbox">
+ <label data-value="${ filter[0] }">
+ <input type="radio"
+ class="product-filter discount-filter"
+ name="discount" id="${ filter[0] }"
+ data-filter-name="discount"
+ data-filter-value="${ filter[0] }"
+ style="width: 14px !important"
+ >
+ <span class="label-area" for="${ filter[0] }">
+ ${ filter[1] }
+ </span>
+ </label>
+ </div>
+ `;
+ });
+ html += `</div>`;
+
+ $("#discount-filters").append(html);
+ }
+ }
+
+ restore_discount_filter() {
+ const filters = frappe.utils.get_query_params();
+ let field_filters = filters.field_filters;
+ if (!field_filters) return;
+
+ field_filters = JSON.parse(field_filters);
+
+ if (field_filters && field_filters["discount"]) {
+ const values = field_filters["discount"];
+ const selector = values.map(value => {
+ return `input[data-filter-name="discount"][data-filter-value="${value}"]`;
+ }).join(',');
+ $(selector).prop('checked', true);
+ this.field_filters = field_filters;
+ }
+ }
+
+ bind_discount_filter_action() {
+ let me = this;
+ $('.discount-filter').on('change', (e) => {
+ const $checkbox = $(e.target);
+ const is_checked = $checkbox.is(':checked');
+
+ const {
+ filterValue: filter_value
+ } = $checkbox.data();
+
+ delete this.field_filters["discount"];
+
+ if (is_checked) {
+ this.field_filters["discount"] = [];
+ this.field_filters["discount"].push(filter_value);
+ }
+
+ if (this.field_filters["discount"].length === 0) {
+ delete this.field_filters["discount"];
+ }
+
+ me.change_route_with_filters();
+ });
+ }
+
+ bind_filters() {
+ let me = this;
+ this.field_filters = {};
+ this.attribute_filters = {};
+
+ $('.product-filter').on('change', (e) => {
+ me.from_filters = true;
+
+ const $checkbox = $(e.target);
+ const is_checked = $checkbox.is(':checked');
+
+ if ($checkbox.is('.attribute-filter')) {
+ const {
+ attributeName: attribute_name,
+ attributeValue: attribute_value
+ } = $checkbox.data();
+
+ if (is_checked) {
+ this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || [];
+ this.attribute_filters[attribute_name].push(attribute_value);
+ } else {
+ this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || [];
+ this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name].filter(v => v !== attribute_value);
+ }
+
+ if (this.attribute_filters[attribute_name].length === 0) {
+ delete this.attribute_filters[attribute_name];
+ }
+ } else if ($checkbox.is('.field-filter') || $checkbox.is('.discount-filter')) {
+ const {
+ filterName: filter_name,
+ filterValue: filter_value
+ } = $checkbox.data();
+
+ if ($checkbox.is('.discount-filter')) {
+ // clear previous discount filter to accomodate new
+ delete this.field_filters["discount"];
+ }
+ if (is_checked) {
+ this.field_filters[filter_name] = this.field_filters[filter_name] || [];
+ if (!in_list(this.field_filters[filter_name], filter_value)) {
+ this.field_filters[filter_name].push(filter_value);
+ }
+ } else {
+ this.field_filters[filter_name] = this.field_filters[filter_name] || [];
+ this.field_filters[filter_name] = this.field_filters[filter_name].filter(v => v !== filter_value);
+ }
+
+ if (this.field_filters[filter_name].length === 0) {
+ delete this.field_filters[filter_name];
+ }
+ }
+
+ me.change_route_with_filters();
+ });
+ }
+
+ change_route_with_filters() {
+ let route_params = frappe.utils.get_query_params();
+
+ let start = this.if_key_exists(route_params.start) || 0;
+ if (this.from_filters) {
+ start = 0; // show items from first page if new filters are triggered
+ }
+
+ const query_string = this.get_query_string({
+ start: start,
+ field_filters: JSON.stringify(this.if_key_exists(this.field_filters)),
+ attribute_filters: JSON.stringify(this.if_key_exists(this.attribute_filters)),
+ });
+ window.history.pushState('filters', '', `${location.pathname}?` + query_string);
+
+ $('.page_content input').prop('disabled', true);
+
+ this.make(true);
+ $('.page_content input').prop('disabled', false);
+ }
+
+ restore_filters_state() {
+ const filters = frappe.utils.get_query_params();
+ let {field_filters, attribute_filters} = filters;
+
+ if (field_filters) {
+ field_filters = JSON.parse(field_filters);
+ for (let fieldname in field_filters) {
+ const values = field_filters[fieldname];
+ const selector = values.map(value => {
+ return `input[data-filter-name="${fieldname}"][data-filter-value="${value}"]`;
+ }).join(',');
+ $(selector).prop('checked', true);
+ }
+ this.field_filters = field_filters;
+ }
+ if (attribute_filters) {
+ attribute_filters = JSON.parse(attribute_filters);
+ for (let attribute in attribute_filters) {
+ const values = attribute_filters[attribute];
+ const selector = values.map(value => {
+ return `input[data-attribute-name="${attribute}"][data-attribute-value="${value}"]`;
+ }).join(',');
+ $(selector).prop('checked', true);
+ }
+ this.attribute_filters = attribute_filters;
+ }
+ }
+
+ render_no_products_section(error=false) {
+ let error_section = `
+ <div class="mt-4 w-100 alert alert-error font-md">
+ Something went wrong. Please refresh or contact us.
+ </div>
+ `;
+ let no_results_section = `
+ <div class="cart-empty frappe-card mt-4">
+ <div class="cart-empty-state">
+ <img src="/assets/erpnext/images/ui-states/cart-empty-state.png" alt="Empty Cart">
+ </div>
+ <div class="cart-empty-message mt-4">${ __('No products found') }</p>
+ </div>
+ `;
+
+ this.products_section.append(error ? error_section : no_results_section);
+ }
+
+ render_item_sub_categories(categories) {
+ if (categories && categories.length) {
+ let sub_group_html = `
+ <div class="sub-category-container scroll-categories">
+ `;
+
+ categories.forEach(category => {
+ sub_group_html += `
+ <a href="${ category.route || '#' }" style="text-decoration: none;">
+ <div class="category-pill">
+ ${ category.name }
+ </div>
+ </a>
+ `;
+ });
+ sub_group_html += `</div>`;
+
+ $("#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 0000000..59c7f32
--- /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/portal/product_configurator/__init__.py b/erpnext/e_commerce/shopping_cart/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/e_commerce/shopping_cart/__init__.py
diff --git a/erpnext/shopping_cart/cart.py b/erpnext/e_commerce/shopping_cart/cart.py
similarity index 87%
rename from erpnext/shopping_cart/cart.py
rename to erpnext/e_commerce/shopping_cart/cart.py
index ebbe233..458cf69 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.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 @@
"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 @@
@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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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/e_commerce/shopping_cart/product_info.py b/erpnext/e_commerce/shopping_cart/product_info.py
new file mode 100644
index 0000000..595fed0
--- /dev/null
+++ b/erpnext/e_commerce/shopping_cart/product_info.py
@@ -0,0 +1,97 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+
+import frappe
+
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
+ get_shopping_cart_settings,
+ show_quantity_in_website,
+)
+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)
+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"
+ # 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 = 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
+ })
+
+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"] = ""
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 60c220a..8519e68 100644
--- a/erpnext/shopping_cart/test_shopping_cart.py
+++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
@@ -8,8 +8,14 @@
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 @@
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 @@
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 @@
# 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 @@
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 5f0c792..e9745a4 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 @@
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/portal/product_configurator/__init__.py b/erpnext/e_commerce/variant_selector/__init__.py
similarity index 100%
rename from erpnext/portal/product_configurator/__init__.py
rename to erpnext/e_commerce/variant_selector/__init__.py
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 636ae8d..bb6b3ef 100644
--- a/erpnext/portal/product_configurator/item_variants_cache.py
+++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py
@@ -44,7 +44,7 @@
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 @@
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 0000000..b83961e
--- /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 0000000..3380273
--- /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/portal/product_configurator/__init__.py b/erpnext/e_commerce/web_template/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/e_commerce/web_template/__init__.py
diff --git a/erpnext/shopping_cart/web_template/hero_slider/__init__.py b/erpnext/e_commerce/web_template/hero_slider/__init__.py
similarity index 100%
rename from erpnext/shopping_cart/web_template/hero_slider/__init__.py
rename to erpnext/e_commerce/web_template/hero_slider/__init__.py
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 04fb1d2..2b1807c 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/shopping_cart/web_template/item_card_group/__init__.py b/erpnext/e_commerce/web_template/item_card_group/__init__.py
similarity index 100%
rename from erpnext/shopping_cart/web_template/item_card_group/__init__.py
rename to erpnext/e_commerce/web_template/item_card_group/__init__.py
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 fe061d5..07952f0 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 ad087b0..ad9e2a7 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/shopping_cart/web_template/product_card/__init__.py b/erpnext/e_commerce/web_template/product_card/__init__.py
similarity index 100%
rename from erpnext/shopping_cart/web_template/product_card/__init__.py
rename to erpnext/e_commerce/web_template/product_card/__init__.py
diff --git a/erpnext/shopping_cart/web_template/product_card/product_card.html b/erpnext/e_commerce/web_template/product_card/product_card.html
similarity index 100%
rename from erpnext/shopping_cart/web_template/product_card/product_card.html
rename to erpnext/e_commerce/web_template/product_card/product_card.html
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 1059c1b..2eb7374 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/shopping_cart/web_template/product_category_cards/__init__.py b/erpnext/e_commerce/web_template/product_category_cards/__init__.py
similarity index 100%
rename from erpnext/shopping_cart/web_template/product_category_cards/__init__.py
rename to erpnext/e_commerce/web_template/product_category_cards/__init__.py
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 06b76af..6d75a8b 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 @@
}) -%}
<div class="card h-100">
{% if image %}
- <img class="card-img-top" src="{{ image }}" alt="{{ title }}">
+ <img class="card-img-top" src="{{ image }}" alt="{{ title }}" style="max-height: 200px;">
+ {% else %}
+ <div class="placeholder-div" style="max-height: 200px;">
+ <span class="placeholder">
+ {{ frappe.utils.get_abbr(title or '') }}
+ </span>
+ </div>
{% endif %}
+
<div class="card-body text-center text-muted small">
{{ title or '' }}
</div>
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 ba5f63b..0202165 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 a23d492..4d0f3a9 100644
--- a/erpnext/education/doctype/program_enrollment/program_enrollment.py
+++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py
@@ -6,6 +6,7 @@
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 @@
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 03c1a1a..29bc36f 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 @@
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 04f5793..0e29038 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -51,15 +51,15 @@
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 @@
'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 @@
]
},
"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"
diff --git a/erpnext/hr/doctype/employee/employee_reminders.py b/erpnext/hr/doctype/employee/employee_reminders.py
index 559bd39..0bb6637 100644
--- a/erpnext/hr/doctype/employee/employee_reminders.py
+++ b/erpnext/hr/doctype/employee/employee_reminders.py
@@ -20,6 +20,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
return reminder_text, message
+
def send_birthday_reminder(recipients, reminder_text, birthday_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -129,10 +136,12 @@
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 @@
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 @@
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 @@
return reminder_text, message
+
def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message):
frappe.sendmail(
recipients=recipients,
@@ -249,5 +260,5 @@
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 8a2da08..67cbea6 100644
--- a/erpnext/hr/doctype/employee/test_employee.py
+++ b/erpnext/hr/doctype/employee/test_employee.py
@@ -36,7 +36,7 @@
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 52c0098..a4097ab 100644
--- a/erpnext/hr/doctype/employee/test_employee_reminders.py
+++ b/erpnext/hr/doctype/employee/test_employee_reminders.py
@@ -5,10 +5,12 @@
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 @@
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 @@
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 @@
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 @@
)
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 @@
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 @@
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 75e99f8..6b85927 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 @@
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 @@
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 @@
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 @@
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)
diff --git a/erpnext/loan_management/doctype/loan/loan.js b/erpnext/loan_management/doctype/loan/loan.js
index f9c201a..940a1bb 100644
--- a/erpnext/loan_management/doctype/loan/loan.js
+++ b/erpnext/loan_management/doctype/loan/loan.js
@@ -46,7 +46,7 @@
});
});
- $.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 @@
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 @@
})
},
+ 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 af26f7b..196f36f 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 f660a24..b798e08 100644
--- a/erpnext/loan_management/doctype/loan/loan.py
+++ b/erpnext/loan_management/doctype/loan/loan.py
@@ -10,6 +10,7 @@
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 @@
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 @@
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 1676c21..5ebb2e1 100644
--- a/erpnext/loan_management/doctype/loan/test_loan.py
+++ b/erpnext/loan_management/doctype/loan/test_loan.py
@@ -42,16 +42,17 @@
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 @@
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 @@
"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 @@
}).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 @@
"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 d367e92..640709c 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 @@
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 e2d758b..df3aadf 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 @@
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 @@
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 94ec84e..10be750 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 @@
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 46aaaad..e8c7750 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 @@
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 7e997e8..acf3a65 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 @@
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 @@
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 04c89c4..9f9137c 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 @@
});
});
- $.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 c0a5d2c..00337e4 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/loan_management/doctype/salary_slip_loan/salary_slip_loan.json b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
index 3d07081..b7b20d9 100644
--- a/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
+++ b/erpnext/loan_management/doctype/salary_slip_loan/salary_slip_loan.json
@@ -70,7 +70,6 @@
{
"fieldname": "loan_repayment_entry",
"fieldtype": "Link",
- "hidden": 1,
"label": "Loan Repayment Entry",
"no_copy": 1,
"options": "Loan Repayment",
@@ -88,7 +87,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
- "modified": "2021-03-14 20:47:11.725818",
+ "modified": "2022-01-31 14:50:14.823213",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Salary Slip Loan",
@@ -97,5 +96,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/manufacturing/doctype/bom/bom.js b/erpnext/manufacturing/doctype/bom/bom.js
index fc3b971..8a7634e 100644
--- a/erpnext/manufacturing/doctype/bom/bom.js
+++ b/erpnext/manufacturing/doctype/bom/bom.js
@@ -93,7 +93,7 @@
});
}
- 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 21a126b..276e708 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 @@
# 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/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 03e0910..a86edfa 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -449,7 +449,13 @@
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 090a3e7..2693352 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 @@
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 8368db6..e1e7225 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 @@
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 e62e2bc..c5705c1 100644
--- a/erpnext/modules.txt
+++ b/erpnext/modules.txt
@@ -9,7 +9,6 @@
Stock
Support
Utilities
-Shopping Cart
Assets
Portal
Maintenance
@@ -21,4 +20,5 @@
Communication
Loan Management
Payroll
-Telephony
\ No newline at end of file
+Telephony
+E-commerce
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 5a8f8ef..feafecb 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -293,6 +293,9 @@
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.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
@@ -328,6 +332,7 @@
erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v13_0.update_asset_quantity_field
erpnext.patches.v13_0.delete_bank_reconciliation_detail
+erpnext.patches.v13_0.enable_provisional_accounting
[post_model_sync]
erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents
@@ -342,3 +347,6 @@
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 0000000..d3ee3f8
--- /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 0000000..da162a3
--- /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/enable_provisional_accounting.py b/erpnext/patches/v13_0/enable_provisional_accounting.py
new file mode 100644
index 0000000..8e22270
--- /dev/null
+++ b/erpnext/patches/v13_0/enable_provisional_accounting.py
@@ -0,0 +1,19 @@
+import frappe
+
+
+def execute():
+ frappe.reload_doc("setup", "doctype", "company")
+
+ company = frappe.qb.DocType("Company")
+
+ frappe.qb.update(
+ company
+ ).set(
+ company.enable_provisional_accounting_for_non_stock_items, company.enable_perpetual_inventory_for_non_stock_items
+ ).set(
+ company.default_provisional_account, company.service_received_but_not_billed
+ ).where(
+ company.enable_perpetual_inventory_for_non_stock_items == 1
+ ).where(
+ company.service_received_but_not_billed.isnotnull()
+ ).run()
\ No newline at end of file
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 0000000..32ad542
--- /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 0000000..7a7ddba
--- /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 0000000..8f9ee51
--- /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 0000000..35710a9
--- /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 = """<div class="ql-editor read-mode"><p>You are seeing this message because Shopping Cart is enabled on your site. </p><p><br></p><p>Shopping Cart Settings and Products settings are now merged into "E Commerce Settings". </p><p><br></p><p>You can learn about new and improved E-Commerce features in the official documentation.</p><ol><li data-list="bullet"><span class="ql-ui" contenteditable="false"></span><a href="https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce" rel="noopener noreferrer">https://docs.erpnext.com/docs/v13/user/manual/en/e_commerce/set_up_e_commerce</a></li></ol><p><br></p></div>"""
+ 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 0000000..c56fa8f
--- /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 3d217d8..c4f097f 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 @@
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 4f097fa..5f836db 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 @@
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 f33443d..f727ff4 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -746,11 +746,12 @@
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 @@
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 @@
# 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 @@
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 @@
"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 bcf981b..30b604b 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 @@
# 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 @@
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 5dd1d70..8df9957 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 c7c66e0..59f808a 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 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 ad27278..73f816d 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 1e056a6..8092ba2 100644
--- a/erpnext/portal/doctype/homepage/homepage.py
+++ b/erpnext/portal/doctype/homepage/homepage.py
@@ -14,12 +14,14 @@
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 01c32ef..63789e3 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 2f8b037..0000000
--- 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 2cf8431..0000000
--- 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 0e106c6..0000000
--- 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 <b>{1}</b> 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 66026fc..0000000
--- 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 2874dc4..eed33ec 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 b478489..0000000
--- 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 cf623c8..0000000
--- 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 974b51e..24bcab4 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 f8e8177..569910d 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",
@@ -65,5 +66,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/conf.js b/erpnext/public/js/conf.js
index eb709e5..a0f56a2 100644
--- a/erpnext/public/js/conf.js
+++ b/erpnext/public/js/conf.js
@@ -21,6 +21,6 @@
'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 d696ef5..54e5daa 100644
--- a/erpnext/public/js/controllers/buying.js
+++ b/erpnext/public/js/controllers/buying.js
@@ -441,7 +441,7 @@
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/customer_reviews.js b/erpnext/public/js/customer_reviews.js
new file mode 100644
index 0000000..e13ded6
--- /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(`
+ <div class="mb-3 review">
+ <div class="d-flex">
+ <p class="mr-4 user-review-title">
+ <span>${__(review.review_title)}</span>
+ </p>
+ <div class="rating">
+ ${me.get_review_stars(review.rating)}
+ </div>
+ </div>
+
+ <div class="product-description mb-4">
+ <p>
+ ${__(review.comment)}
+ </p>
+ </div>
+ <div class="review-signature mb-2">
+ <span class="reviewer">${__(review.customer)}</span>
+ <span class="indicator grey" style="--text-on-gray: var(--gray-300);"></span>
+ <span class="reviewer">${__(review.published_on)}</span>
+ </div>
+ </div>
+ `);
+ });
+ }
+
+ get_review_stars(rating) {
+ let stars = ``;
+ for (let i = 1; i < 6; i++) {
+ let fill_class = i <= rating ? 'star-click' : '';
+ stars += `
+ <svg class="icon icon-sm ${fill_class}">
+ <use href="#icon-star"></use>
+ </svg>
+ `;
+ }
+ 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 7db6967..cbe899d 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/shopping_cart.js b/erpnext/public/js/shopping_cart.js
index 6a923ae..d14740c 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 @@
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 @@
$(".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 @@
},
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 @@
},
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 @@
}
},
- 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 @@
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 = `
+ <div class="text-center w-100 intermediate-empty-cart mt-4 mb-4 text-muted">
+ ${ __("Cart is Empty") }
+ </div>
+ `;
+ $(".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 @@
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 @@
});
},
-
- 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 = $('<div id="freeze" class="modal-backdrop fade"></div>')
+ .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 0000000..f6599e9
--- /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(`
+ <div class="cart-empty frappe-card">
+ <div class="cart-empty-state">
+ <img src="/assets/erpnext/images/ui-states/cart-empty-state.png" alt="Empty Cart">
+ </div>
+ <div class="cart-empty-message mt-4">${ __('Wishlist is empty !') }</p>
+ </div>
+ `);
+ }
+
+});
+
+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 fef1e76..4b645b9 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 @@
}
}
+.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 @@
}
}
+ .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 @@
.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: 80%;
+ 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);
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 @@
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 @@
}
}
+.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 @@
max-height: 430px;
}
- overflow: scroll;
+ overflow: auto;
}
.item-slideshow-image {
@@ -261,29 +409,116 @@
.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 @@
}
}
-.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 @@
display: flex;
flex-direction: column;
justify-content: space-between;
+ height: fit-content;
}
.cart-items-header {
@@ -357,6 +646,10 @@
}
.cart-table {
+ tr {
+ margin-bottom: 1rem;
+ }
+
th, tr, td {
border-color: var(--border-color);
border-width: 1px;
@@ -374,71 +667,200 @@
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 @@
.address-card {
.card-title {
- font-size: var(--text-base);
+ font-size: 14px;
font-weight: 500;
}
@@ -463,27 +885,37 @@
}
.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 @@
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 d48cd67..cb79cf8 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 @@
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 @@
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 @@
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 d74d5a6..7742f26 100644
--- a/erpnext/selling/doctype/customer/customer.py
+++ b/erpnext/selling/doctype/customer/customer.py
@@ -258,30 +258,6 @@
.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 daab6fb..eebde76 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -287,7 +287,7 @@
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_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json
index 8b53902..31a9589 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/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index 42bc0b7..acf048e 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 @@
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 27bc541..7c4a3f6 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 92d00bc..0000000
--- 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 db5b20e..993c61d 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 @@
["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 @@
), {'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 @@
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 @@
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 ce74f6d..ea8459f 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 @@
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 @@
}
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 @@
},
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 @@
})
.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 4920584..4a99f06 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 @@
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 @@
$(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 a30bcd7..1177615 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 @@
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 a8f0674..45b4db8 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/company/company.json b/erpnext/setup/doctype/company/company.json
index 63d96bf..370a327 100644
--- a/erpnext/setup/doctype/company/company.json
+++ b/erpnext/setup/doctype/company/company.json
@@ -3,7 +3,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "field:company_name",
- "creation": "2013-04-10 08:35:39",
+ "creation": "2022-01-25 10:29:55.938239",
"description": "Legal Entity / Subsidiary with a separate Chart of Accounts belonging to the Organization.",
"doctype": "DocType",
"document_type": "Setup",
@@ -77,13 +77,13 @@
"default_finance_book",
"auto_accounting_for_stock_settings",
"enable_perpetual_inventory",
- "enable_perpetual_inventory_for_non_stock_items",
+ "enable_provisional_accounting_for_non_stock_items",
"default_inventory_account",
"stock_adjustment_account",
"default_in_transit_warehouse",
"column_break_32",
"stock_received_but_not_billed",
- "service_received_but_not_billed",
+ "default_provisional_account",
"expenses_included_in_valuation",
"fixed_asset_defaults",
"accumulated_depreciation_account",
@@ -685,20 +685,6 @@
"options": "Terms and Conditions"
},
{
- "fieldname": "service_received_but_not_billed",
- "fieldtype": "Link",
- "ignore_user_permissions": 1,
- "label": "Service Received But Not Billed",
- "no_copy": 1,
- "options": "Account"
- },
- {
- "default": "0",
- "fieldname": "enable_perpetual_inventory_for_non_stock_items",
- "fieldtype": "Check",
- "label": "Enable Perpetual Inventory For Non Stock Items"
- },
- {
"fieldname": "default_in_transit_warehouse",
"fieldtype": "Link",
"label": "Default In-Transit Warehouse",
@@ -741,6 +727,20 @@
"fieldname": "section_break_28",
"fieldtype": "Section Break",
"label": "Chart of Accounts"
+ },
+ {
+ "default": "0",
+ "fieldname": "enable_provisional_accounting_for_non_stock_items",
+ "fieldtype": "Check",
+ "label": "Enable Provisional Accounting For Non Stock Items"
+ },
+ {
+ "fieldname": "default_provisional_account",
+ "fieldtype": "Link",
+ "ignore_user_permissions": 1,
+ "label": "Default Provisional Account",
+ "no_copy": 1,
+ "options": "Account"
}
],
"icon": "fa fa-building",
@@ -748,7 +748,7 @@
"image_field": "company_logo",
"is_tree": 1,
"links": [],
- "modified": "2021-10-04 12:09:25.833133",
+ "modified": "2022-01-25 10:33:16.826067",
"modified_by": "Administrator",
"module": "Setup",
"name": "Company",
@@ -809,5 +809,6 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/setup/doctype/company/company.py b/erpnext/setup/doctype/company/company.py
index 0a02bcd..95b1e8b 100644
--- a/erpnext/setup/doctype/company/company.py
+++ b/erpnext/setup/doctype/company/company.py
@@ -10,6 +10,7 @@
from frappe import _
from frappe.cache_manager import clear_defaults_cache
from frappe.contacts.address_and_contact import load_address_and_contact
+from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.utils import cint, formatdate, get_timestamp, today
from frappe.utils.nestedset import NestedSet
@@ -45,7 +46,7 @@
self.validate_currency()
self.validate_coa_input()
self.validate_perpetual_inventory()
- self.validate_perpetual_inventory_for_non_stock_items()
+ self.validate_provisional_account_for_non_stock_items()
self.check_country_change()
self.check_parent_changed()
self.set_chart_of_accounts()
@@ -187,11 +188,14 @@
frappe.msgprint(_("Set default inventory account for perpetual inventory"),
alert=True, indicator='orange')
- def validate_perpetual_inventory_for_non_stock_items(self):
+ def validate_provisional_account_for_non_stock_items(self):
if not self.get("__islocal"):
- if cint(self.enable_perpetual_inventory_for_non_stock_items) == 1 and not self.service_received_but_not_billed:
- frappe.throw(_("Set default {0} account for perpetual inventory for non stock items").format(
- frappe.bold('Service Received But Not Billed')))
+ if cint(self.enable_provisional_accounting_for_non_stock_items) == 1 and not self.default_provisional_account:
+ frappe.throw(_("Set default {0} account for non stock items").format(
+ frappe.bold('Provisional Account')))
+
+ make_property_setter("Purchase Receipt", "provisional_expense_account", "hidden",
+ not self.enable_provisional_accounting_for_non_stock_items, "Check", validate_fields_for_doctype=False)
def check_country_change(self):
frappe.flags.country_change = False
diff --git a/erpnext/setup/doctype/item_group/item_group.py b/erpnext/setup/doctype/item_group/item_group.py
index 9f1eb75..4f92240 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 @@
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 @@
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 @@
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 @@
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"}
-def get_parent_item_groups(item_group_name):
+ 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}
+
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\041/welcome_back_to_erpnext\041.json" "b/erpnext/setup/onboarding_slide/welcome_back_to_erpnext\041/welcome_back_to_erpnext\041.json"
deleted file mode 100644
index f00dc94..0000000
--- "a/erpnext/setup/onboarding_slide/welcome_back_to_erpnext\041/welcome_back_to_erpnext\041.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": "<p>Let's continue where you left from!</p>",
- "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\041/welcome_to_erpnext\041.json" "b/erpnext/setup/onboarding_slide/welcome_to_erpnext\041/welcome_to_erpnext\041.json"
deleted file mode 100644
index 37eb67b..0000000
--- "a/erpnext/setup/onboarding_slide/welcome_to_erpnext\041/welcome_to_erpnext\041.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": "<div class=\"text center\">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!</div>",
- "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 358b921..74c1bd8 100644
--- a/erpnext/setup/setup_wizard/operations/company_setup.py
+++ b/erpnext/setup/setup_wizard/operations/company_setup.py
@@ -29,10 +29,10 @@
'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 9dbf49e..cd2738a 100644
--- a/erpnext/setup/setup_wizard/operations/install_fixtures.py
+++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py
@@ -535,8 +535,8 @@
# 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/__init__.py b/erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/shopping_cart/doctype/shopping_cart_settings/__init__.py
+++ /dev/null
diff --git a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js
deleted file mode 100644
index b38828e..0000000
--- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.js
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-// License: GNU General Public License v3. See license.txt
-
-frappe.ui.form.on("Shopping Cart Settings", {
- onload: function(frm) {
- if(frm.doc.__onload && frm.doc.__onload.quotation_series) {
- frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
- frm.refresh_field("quotation_series");
- }
-
- frm.set_query('payment_gateway_account', function() {
- return { 'filters': { 'payment_channel': "Email" } };
- });
- },
- refresh: function(frm) {
- if (frm.doc.enabled) {
- frm.get_field('store_page_docs').$wrapper.removeClass('hide-control').html(
- `<div>${__("Follow these steps to create a landing page for your store")}:
- <a href="https://docs.erpnext.com/docs/user/manual/en/website/store-landing-page"
- style="color: var(--gray-600)">
- docs/store-landing-page
- </a>
- </div>`
- );
- }
- },
- enabled: function(frm) {
- if (frm.doc.enabled === 1) {
- frm.set_value('enable_variants', 1);
- }
- else {
- frm.set_value('company', '');
- frm.set_value('price_list', '');
- frm.set_value('default_customer_group', '');
- frm.set_value('quotation_series', '');
- }
- }
-});
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 7a4bb20..0000000
--- 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/doctype/shopping_cart_settings/shopping_cart_settings.py b/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py
deleted file mode 100644
index 4a75599..0000000
--- a/erpnext/shopping_cart/doctype/shopping_cart_settings/shopping_cart_settings.py
+++ /dev/null
@@ -1,84 +0,0 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
-# License: GNU General Public License v3. See license.txt
-
-# For license information, please see license.txt
-
-
-import frappe
-from frappe import _
-from frappe.model.document import Document
-from frappe.utils import flt
-
-
-class ShoppingCartSetupError(frappe.ValidationError): pass
-
-class ShoppingCartSettings(Document):
- def onload(self):
- self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
-
- def validate(self):
- if self.enabled:
- self.validate_price_list_exchange_rate()
-
- def validate_price_list_exchange_rate(self):
- "Check if exchange rate exists for Price List currency (to Company's currency)."
- from erpnext.setup.utils import get_exchange_rate
-
- if not self.enabled or not self.company or not self.price_list:
- return # this function is also called from hooks, check values again
-
- company_currency = frappe.get_cached_value("Company", self.company, "default_currency")
- price_list_currency = frappe.db.get_value("Price List", self.price_list, "currency")
-
- if not company_currency:
- msg = f"Please specify currency in Company {self.company}"
- frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
-
- if not price_list_currency:
- msg = f"Please specify currency in Price List {frappe.bold(self.price_list)}"
- frappe.throw(_(msg), title=_("Missing Currency"), exc=ShoppingCartSetupError)
-
- if price_list_currency != company_currency:
- from_currency, to_currency = price_list_currency, company_currency
-
- # Get exchange rate checks Currency Exchange Records too
- exchange_rate = get_exchange_rate(from_currency, to_currency, args="for_selling")
-
- if not flt(exchange_rate):
- msg = f"Missing Currency Exchange Rates for {from_currency}-{to_currency}"
- frappe.throw(_(msg), title=_("Missing"), exc=ShoppingCartSetupError)
-
- def validate_tax_rule(self):
- if not frappe.db.get_value("Tax Rule", {"use_for_shopping_cart" : 1}, "name"):
- frappe.throw(frappe._("Set Tax Rule for shopping cart"), ShoppingCartSetupError)
-
- def get_tax_master(self, billing_territory):
- tax_master = self.get_name_from_territory(billing_territory, "sales_taxes_and_charges_masters",
- "sales_taxes_and_charges_master")
- return tax_master and tax_master[0] or None
-
- def get_shipping_rules(self, shipping_territory):
- return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule")
-
-def validate_cart_settings(doc=None, method=None):
- frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate")
-
-def get_shopping_cart_settings():
- if not getattr(frappe.local, "shopping_cart_settings", None):
- frappe.local.shopping_cart_settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings")
-
- return frappe.local.shopping_cart_settings
-
-@frappe.whitelist(allow_guest=True)
-def is_cart_enabled():
- return get_shopping_cart_settings().enabled
-
-def show_quantity_in_website():
- return get_shopping_cart_settings().show_quantity_in_website
-
-def check_shopping_cart_enabled():
- if not get_shopping_cart_settings().enabled:
- frappe.throw(_("You need to enable Shopping Cart"), ShoppingCartSetupError)
-
-def show_attachments():
- return get_shopping_cart_settings().show_attachments
diff --git a/erpnext/shopping_cart/filters.py b/erpnext/shopping_cart/filters.py
deleted file mode 100644
index ef0badc..0000000
--- 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_info.py b/erpnext/shopping_cart/product_info.py
deleted file mode 100644
index 977f12f..0000000
--- a/erpnext/shopping_cart/product_info.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# Copyright (c) 2015, 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 (
- get_shopping_cart_settings,
- show_quantity_in_website,
-)
-from erpnext.utilities.product import get_non_stock_item_status, get_price, get_qty_in_stock
-
-
-@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 frappe._dict()
-
- 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 = 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")
-
- 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 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
- })
-
-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"] = ""
diff --git a/erpnext/shopping_cart/product_query.py b/erpnext/shopping_cart/product_query.py
deleted file mode 100644
index 5cc0505..0000000
--- 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/item/item.js b/erpnext/stock/doctype/item/item.js
index 86c702c..2a30ca1 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -17,8 +17,6 @@
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 @@
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('<a href="/app/website-item/%(item_encoded)s" class="strong">%(item)s</a>', {
+ 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 @@
}
},
- 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 @@
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 2d28cc0..e71cdb3 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": [
@@ -857,125 +841,6 @@
"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",
"hidden": 1,
@@ -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",
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index d99fadc..b9e8b3f 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 @@
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 @@
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 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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
"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 @@
)
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 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_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 @@
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 0957ce0..fc45ba9 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -536,7 +536,7 @@
"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 6cec852..91c77d5 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 488920a..5e1f7d5 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 @@
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 f63498b..be1517e 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 @@
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 9204842..df8cadd 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 @@
"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 @@
"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 @@
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 830d546..d2d4789 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 e4091c4..07c2f1f 100644
--- a/erpnext/stock/doctype/packed_item/packed_item.py
+++ b/erpnext/stock/doctype/packed_item/packed_item.py
@@ -8,187 +8,253 @@
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 0000000..2521ac9
--- /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 74b823a..8a3172e 100644
--- a/erpnext/stock/doctype/price_list/price_list.py
+++ b/erpnext/stock/doctype/price_list/price_list.py
@@ -36,14 +36,14 @@
(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.json b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
index 112dded..b54a90e 100755
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.json
@@ -106,6 +106,8 @@
"terms",
"bill_no",
"bill_date",
+ "accounting_details_section",
+ "provisional_expense_account",
"more_info",
"project",
"status",
@@ -1144,16 +1146,30 @@
"label": "Represents Company",
"options": "Company",
"read_only": 1
+ },
+ {
+ "collapsible": 1,
+ "fieldname": "accounting_details_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Details"
+ },
+ {
+ "fieldname": "provisional_expense_account",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Provisional Expense Account",
+ "options": "Account"
}
],
"icon": "fa fa-truck",
"idx": 261,
"is_submittable": 1,
"links": [],
- "modified": "2021-09-28 13:11:10.181328",
+ "modified": "2022-02-01 11:40:52.690984",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt",
+ "naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@@ -1214,6 +1230,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"timeline_field": "supplier",
"title_field": "title",
"track_changes": 1
diff --git a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
index c97b306..33e40c8 100644
--- a/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/purchase_receipt.py
@@ -8,6 +8,7 @@
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, getdate, nowdate
+import erpnext
from erpnext.accounts.utils import get_account_currency
from erpnext.assets.doctype.asset.asset import get_asset_account, is_cwip_accounting_enabled
from erpnext.assets.doctype.asset_category.asset_category import get_asset_category_account
@@ -112,6 +113,7 @@
self.validate_uom_is_integer("uom", ["qty", "received_qty"])
self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_cwip_accounts()
+ self.validate_provisional_expense_account()
self.check_on_hold_or_closed_status()
@@ -133,6 +135,15 @@
company = self.company)
break
+ def validate_provisional_expense_account(self):
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
+
+ if provisional_accounting_for_non_stock_items:
+ default_provisional_account = self.get_company_default("default_provisional_account")
+ if not self.provisional_expense_account:
+ self.provisional_expense_account = default_provisional_account
+
def validate_with_previous_doc(self):
super(PurchaseReceipt, self).validate_with_previous_doc({
"Purchase Order": {
@@ -258,13 +269,15 @@
get_purchase_document_details,
)
- stock_rbnb = self.get_company_default("stock_received_but_not_billed")
- landed_cost_entries = get_item_account_wise_additional_cost(self.name)
- expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
- auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
+ if erpnext.is_perpetual_inventory_enabled(self.company):
+ stock_rbnb = self.get_company_default("stock_received_but_not_billed")
+ landed_cost_entries = get_item_account_wise_additional_cost(self.name)
+ expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
warehouse_with_no_account = []
stock_items = self.get_stock_items()
+ provisional_accounting_for_non_stock_items = \
+ cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
@@ -273,10 +286,7 @@
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"]
@@ -422,43 +432,58 @@
elif d.warehouse not in warehouse_with_no_account or \
d.rejected_warehouse not in warehouse_with_no_account:
warehouse_with_no_account.append(d.warehouse)
- elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and auto_accounting_for_non_stock_items:
- service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
- credit_currency = get_account_currency(service_received_but_not_billed_account)
- debit_currency = get_account_currency(d.expense_account)
- remarks = self.get("remarks") or _("Accounting Entry for Service")
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=service_received_but_not_billed_account,
- cost_center=d.cost_center,
- debit=0.0,
- credit=d.amount,
- remarks=remarks,
- against_account=d.expense_account,
- account_currency=credit_currency,
- project=d.project,
- voucher_detail_no=d.name, item=d)
-
- self.add_gl_entry(
- gl_entries=gl_entries,
- account=d.expense_account,
- cost_center=d.cost_center,
- debit=d.amount,
- credit=0.0,
- remarks=remarks,
- against_account=service_received_but_not_billed_account,
- account_currency = debit_currency,
- project=d.project,
- voucher_detail_no=d.name,
- item=d)
+ elif d.item_code not in stock_items and not d.is_fixed_asset and flt(d.qty) and provisional_accounting_for_non_stock_items:
+ self.add_provisional_gl_entry(d, gl_entries, self.posting_date)
if warehouse_with_no_account:
frappe.msgprint(_("No accounting entries for the following warehouses") + ": \n" +
"\n".join(warehouse_with_no_account))
+ def add_provisional_gl_entry(self, item, gl_entries, posting_date, reverse=0):
+ provisional_expense_account = self.get('provisional_expense_account')
+ credit_currency = get_account_currency(provisional_expense_account)
+ debit_currency = get_account_currency(item.expense_account)
+ expense_account = item.expense_account
+ remarks = self.get("remarks") or _("Accounting Entry for Service")
+ multiplication_factor = 1
+
+ if reverse:
+ multiplication_factor = -1
+ expense_account = frappe.db.get_value('Purchase Receipt Item', {'name': item.get('pr_detail')}, ['expense_account'])
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=provisional_expense_account,
+ cost_center=item.cost_center,
+ debit=0.0,
+ credit=multiplication_factor * item.amount,
+ remarks=remarks,
+ against_account=expense_account,
+ account_currency=credit_currency,
+ project=item.project,
+ voucher_detail_no=item.name,
+ item=item,
+ posting_date=posting_date)
+
+ self.add_gl_entry(
+ gl_entries=gl_entries,
+ account=expense_account,
+ cost_center=item.cost_center,
+ debit=multiplication_factor * item.amount,
+ credit=0.0,
+ remarks=remarks,
+ against_account=provisional_expense_account,
+ account_currency = debit_currency,
+ project=item.project,
+ voucher_detail_no=item.name,
+ item=item,
+ posting_date=posting_date)
+
def make_tax_gl_entries(self, gl_entries):
- expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
+
+ if erpnext.is_perpetual_inventory_enabled(self.company):
+ expenses_included_in_valuation = self.get_company_default("expenses_included_in_valuation")
+
negative_expense_to_be_booked = sum([flt(d.item_tax_amount) for d in self.get('items')])
# Cost center-wise amount breakup for other charges included for valuation
valuation_tax = {}
@@ -515,7 +540,8 @@
def add_gl_entry(self, gl_entries, account, cost_center, debit, credit, remarks, against_account,
debit_in_account_currency=None, credit_in_account_currency=None, account_currency=None,
- project=None, voucher_detail_no=None, item=None):
+ project=None, voucher_detail_no=None, item=None, posting_date=None):
+
gl_entry = {
"account": account,
"cost_center": cost_center,
@@ -534,6 +560,9 @@
if credit_in_account_currency:
gl_entry.update({"credit_in_account_currency": credit_in_account_currency})
+ if posting_date:
+ gl_entry.update({"posting_date": posting_date})
+
gl_entries.append(self.get_gl_dict(gl_entry, item=item))
def get_asset_gl_entry(self, gl_entries):
@@ -562,6 +591,7 @@
# debit cwip account
debit_in_account_currency = (base_asset_amount
if cwip_account_currency == self.company_currency else asset_amount)
+
self.add_gl_entry(
gl_entries=gl_entries,
account=cwip_account,
@@ -577,6 +607,7 @@
# credit arbnb account
credit_in_account_currency = (base_asset_amount
if asset_rbnb_currency == self.company_currency else asset_amount)
+
self.add_gl_entry(
gl_entries=gl_entries,
account=arbnb_account,
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 2909a2d..5ab7929 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.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):
@@ -1312,58 +1313,6 @@
self.assertEqual(pr.status, "To Bill")
self.assertAlmostEqual(pr.per_billed, 50.0, places=2)
- def test_service_item_purchase_with_perpetual_inventory(self):
- company = '_Test Company with perpetual inventory'
- service_item = '_Test Non Stock Item'
-
- before_test_value = frappe.db.get_value(
- 'Company', company, 'enable_perpetual_inventory_for_non_stock_items'
- )
- frappe.db.set_value(
- 'Company', company,
- 'enable_perpetual_inventory_for_non_stock_items', 1
- )
- srbnb_account = 'Stock Received But Not Billed - TCP1'
- frappe.db.set_value(
- 'Company', company,
- 'service_received_but_not_billed', srbnb_account
- )
-
- pr = make_purchase_receipt(
- company=company, item=service_item,
- warehouse='Finished Goods - TCP1', do_not_save=1
- )
- item_row_with_diff_rate = frappe.copy_doc(pr.items[0])
- item_row_with_diff_rate.rate = 100
- pr.append('items', item_row_with_diff_rate)
-
- pr.save()
- pr.submit()
-
- item_one_gl_entry = frappe.db.get_all("GL Entry", {
- 'voucher_type': pr.doctype,
- 'voucher_no': pr.name,
- 'account': srbnb_account,
- 'voucher_detail_no': pr.items[0].name
- }, pluck="name")
-
- item_two_gl_entry = frappe.db.get_all("GL Entry", {
- 'voucher_type': pr.doctype,
- 'voucher_no': pr.name,
- 'account': srbnb_account,
- 'voucher_detail_no': pr.items[1].name
- }, pluck="name")
-
- # check if the entries are not merged into one
- # seperate entries should be made since voucher_detail_no is different
- self.assertEqual(len(item_one_gl_entry), 1)
- self.assertEqual(len(item_two_gl_entry), 1)
-
- frappe.db.set_value(
- 'Company', company,
- 'enable_perpetual_inventory_for_non_stock_items', before_test_value
- )
-
def test_purchase_receipt_with_exchange_rate_difference(self):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
make_purchase_receipt as create_purchase_receipt,
@@ -1439,6 +1388,36 @@
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/purchase_receipt_item/purchase_receipt_item.json b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
index 30ea1c3..e5994b2 100644
--- a/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
+++ b/erpnext/stock/doctype/purchase_receipt_item/purchase_receipt_item.json
@@ -976,7 +976,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-11-15 15:46:10.591600",
+ "modified": "2022-02-01 11:32:27.980524",
"modified_by": "Administrator",
"module": "Stock",
"name": "Purchase Receipt Item",
@@ -985,5 +985,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py
index 01c5e3e..977d470 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 @@
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 @@
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 2f37778..c38dfaa 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",
@@ -84,14 +83,6 @@
"oldfieldtype": "Section Break"
},
{
- "fieldname": "title",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Title",
- "no_copy": 1,
- "print_hide": 1
- },
- {
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Series",
@@ -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 c51c9bc..a2ef7b4 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -76,7 +76,6 @@
self.validate_posting_time()
self.validate_purpose()
- self.set_title()
self.validate_item()
self.validate_customer_provided_item()
self.validate_qty()
@@ -1835,14 +1834,6 @@
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 3402972..a882a61 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",
@@ -112,15 +111,6 @@
"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 428370c..86af0a0 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 @@
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 33d9a6c..438ec16 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",
@@ -122,7 +129,7 @@
{
"fieldname": "section_break_7",
"fieldtype": "Section Break",
- "label": "Serialised and Batch Setting"
+ "label": "Serial & Batch Item Settings"
},
{
"default": "0",
@@ -276,10 +283,6 @@
"label": "Quality Inspection Settings"
},
{
- "fieldname": "column_break_21",
- "fieldtype": "Column Break"
- },
- {
"default": "Stop",
"fieldname": "action_if_quality_inspection_is_rejected",
"fieldtype": "Select",
@@ -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-04 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 5ee3167..0000000
--- 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 c60a6ca..81fa045 100644
--- a/erpnext/stock/report/stock_ledger/stock_ledger.py
+++ b/erpnext/stock/report/stock_ledger/stock_ledger.py
@@ -104,6 +104,7 @@
{"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/templates/generators/item/item.html b/erpnext/templates/generators/item/item.html
index 17f6880..4070d40 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 %}
-<div class="product-container">
+<div class="product-container item-main">
{% from "erpnext/templates/includes/macros.html" import product_image %}
<div class="item-content">
<div class="product-page-content" itemscope itemtype="http://schema.org/Product">
+ <!-- Image, Description, Add to Cart -->
<div class="row mb-5">
{% include "templates/generators/item/item_image.html" %}
{% include "templates/generators/item/item_details.html" %}
</div>
-
- {% include "templates/generators/item/item_specifications.html" %}
-
- {{ doc.website_content or '' }}
</div>
</div>
</div>
+
+<!-- Additional Info/Reviews, Recommendations -->
+<div class="d-flex">
+ {% 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 '' %}
+
+ <div class="product-container mt-4 {{ padding_top }} {{ info_col }}">
+ <div class="item-content {{ 'mt-minus-2' if (show_tabs and tabs) else '' }}">
+ <div class="product-page-content" itemscope itemtype="http://schema.org/Product">
+ <!-- Product Specifications Table Section -->
+ {% if show_tabs and tabs %}
+ <div class="category-tabs">
+ <!-- tabs -->
+ {{ web_block("Section with Tabs", values=tabs, add_container=0,
+ add_top_padding=0, add_bottom_padding=0)
+ }}
+ </div>
+ {% elif website_specifications %}
+ {% include "templates/generators/item/item_specifications.html"%}
+ {% endif %}
+
+ <!-- Advanced Custom Website Content -->
+ {{ doc.website_content or '' }}
+
+ <!-- Reviews and Comments -->
+ {% if shopping_cart.cart_settings.enable_reviews and not doc.has_variants %}
+ {% include "templates/generators/item/item_reviews.html"%}
+ {% endif %}
+ </div>
+ </div>
+ </div>
+
+ <!-- Recommended Items -->
+ {% if show_recommended_items %}
+ <div class="mt-4 col-3 recommended-item-section">
+ <span class="recommendation-header">Recommended</span>
+ <div class="product-container mt-2 recommendation-container">
+ {% for item in recommended_items %}
+ {{ recommended_item_row(item) }}
+ {% endfor %}
+ </div>
+ </div>
+ {% endif %}
+
+</div>
{% endblock %}
{% block base_scripts %}
<!-- js should be loaded in body! -->
+<script type="text/javascript" src="/assets/frappe/js/lib/jquery/jquery.min.js"></script>
{{ 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 167c848..8000a24 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 @@
<div class="item-cart row mt-2" data-variant-item-code="{{ item_code }}">
<div class="col-md-12">
+ <!-- Price and Availability -->
{% if cart_settings.show_price and product_info.price %}
- <div class="product-price">
- {{ product_info.price.formatted_price_sales_uom }}
- <small class="formatted-price">({{ product_info.price.formatted_price }} / {{ product_info.uom }})</small>
- </div>
+ {% set price_info = product_info.price %}
+
+ <div class="product-price">
+ <!-- Final Price -->
+ {{ price_info.formatted_price_sales_uom }}
+
+ <!-- Striked Price and Discount -->
+ {% if price_info.formatted_mrp %}
+ <small class="formatted-price">
+ <s>MRP {{ price_info.formatted_mrp }}</s>
+ </small>
+ <small class="ml-1 formatted-price in-green">
+ -{{ price_info.get("formatted_discount_percent") or price_info.get("formatted_discount_rate")}}
+ </small>
+ {% endif %}
+
+ <!-- Price per UOM -->
+ <small class="formatted-price ml-2">
+ ({{ price_info.formatted_price }} / {{ product_info.uom }})
+ </small>
+ </div>
{% else %}
{{ _("UOM") }} : {{ product_info.uom }}
{% endif %}
{% if cart_settings.show_stock_availability %}
- <div>
- {% if product_info.in_stock == 0 %}
- <span class="text-danger no-stock">
- {{ _('Not in stock') }}
- </span>
+ <div class="mt-2">
+ {% if product_info.get("on_backorder") %}
+ <span class="no-stock out-of-stock" style="color: var(--primary-color);">
+ {{ _('Available on backorder') }}
+ </span>
+ {% elif product_info.in_stock == 0 %}
+ <span class="no-stock out-of-stock">
+ {{ _('Out of stock') }}
+ </span>
{% elif product_info.in_stock == 1 %}
- <span class="text-success has-stock">
- {{ _('In stock') }}
- {% if product_info.show_stock_qty and product_info.stock_qty %}
- ({{ product_info.stock_qty[0][0] }})
- {% endif %}
- </span>
+ <span class="in-green has-stock">
+ {{ _('In stock') }}
+ {% if product_info.show_stock_qty and product_info.stock_qty %}
+ ({{ product_info.stock_qty[0][0] }})
+ {% endif %}
+ </span>
{% endif %}
</div>
{% endif %}
- <div class="mt-5 mb-5">
- {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %}
- <a href="/cart"
- class="btn btn-light btn-view-in-cart {% if not product_info.qty %}hidden{% endif %}"
- role="button"
- >
- {{ _("View in Cart") }}
- </a>
- <button
- data-item-code="{{item_code}}"
- class="btn btn-primary btn-add-to-cart {% if product_info.qty %}hidden{% endif %} w-100"
- >
- <span class="mr-2">
- <svg class="icon icon-md">
- <use href="#icon-assets"></use>
+
+ <!-- Offers -->
+ {% if doc.offers %}
+ <br>
+ <div class="offers-heading mb-4">
+ <span class="mr-1 tag-icon">
+ <svg class="icon icon-lg"><use href="#icon-tag"></use></svg>
+ </span>
+ <b>Available Offers</b>
+ </div>
+ <div class="offer-container">
+ {% for offer in doc.offers %}
+ <div class="mt-2 d-flex">
+ <div class="mr-2" >
+ <svg width="24" height="24" viewBox="0 0 24 24" stroke="var(--yellow-500)" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M19 15.6213C19 15.2235 19.158 14.842 19.4393 14.5607L20.9393 13.0607C21.5251 12.4749 21.5251 11.5251 20.9393 10.9393L19.4393 9.43934C19.158 9.15804 19 8.7765 19 8.37868V6.5C19 5.67157 18.3284 5 17.5 5H15.6213C15.2235 5 14.842 4.84196 14.5607 4.56066L13.0607 3.06066C12.4749 2.47487 11.5251 2.47487 10.9393 3.06066L9.43934 4.56066C9.15804 4.84196 8.7765 5 8.37868 5H6.5C5.67157 5 5 5.67157 5 6.5V8.37868C5 8.7765 4.84196 9.15804 4.56066 9.43934L3.06066 10.9393C2.47487 11.5251 2.47487 12.4749 3.06066 13.0607L4.56066 14.5607C4.84196 14.842 5 15.2235 5 15.6213V17.5C5 18.3284 5.67157 19 6.5 19H8.37868C8.7765 19 9.15804 19.158 9.43934 19.4393L10.9393 20.9393C11.5251 21.5251 12.4749 21.5251 13.0607 20.9393L14.5607 19.4393C14.842 19.158 15.2235 19 15.6213 19H17.5C18.3284 19 19 18.3284 19 17.5V15.6213Z" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M15 9L9 15" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M10.5 9.5C10.5 10.0523 10.0523 10.5 9.5 10.5C8.94772 10.5 8.5 10.0523 8.5 9.5C8.5 8.94772 8.94772 8.5 9.5 8.5C10.0523 8.5 10.5 8.94772 10.5 9.5Z" fill="white" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M15.5 14.5C15.5 15.0523 15.0523 15.5 14.5 15.5C13.9477 15.5 13.5 15.0523 13.5 14.5C13.5 13.9477 13.9477 13.5 14.5 13.5C15.0523 13.5 15.5 13.9477 15.5 14.5Z" fill="white" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
- </span>
- {{ _("Add to Cart") }}
- </button>
- {% endif %}
- {% if cart_settings.show_contact_us_button %}
- {% include "templates/generators/item/item_inquiry.html" %}
- {% endif %}
+ </div>
+ <p class="mr-1 mb-1">
+ {{ _(offer.offer_title) }}:
+ {{ _(offer.offer_subtitle) if offer.offer_subtitle else '' }}
+ <a class="offer-details" href="#"
+ data-offer-title="{{ offer.offer_title }}" data-offer-id="{{ offer.name }}"
+ role="button">
+ {{ _("More") }}
+ </a>
+ </p>
+ </div>
+ {% endfor %}
+ </div>
+ {% endif %}
+
+ <!-- Add to Cart / View in Cart, Contact Us -->
+ <div class="mt-6 mb-5">
+ <div class="mb-4 d-flex">
+ <!-- Add to Cart -->
+ {% if product_info.price and (cart_settings.allow_items_not_in_stock or product_info.in_stock) %}
+ <a href="/cart" class="btn btn-light btn-view-in-cart hidden mr-2 font-md"
+ role="button">
+ {{ _("View in Cart") if cart_settings.enable_checkout else _("View in Quote") }}
+ </a>
+ <button
+ data-item-code="{{item_code}}"
+ class="btn btn-primary btn-add-to-cart mr-2 w-30-40"
+ >
+ <span class="mr-2">
+ <svg class="icon icon-md">
+ <use href="#icon-assets"></use>
+ </svg>
+ </span>
+ {{ _("Add to Cart") if cart_settings.enable_checkout else _("Add to Quote") }}
+ </button>
+ {% endif %}
+
+ <!-- Contact Us -->
+ {% if cart_settings.show_contact_us_button %}
+ {% include "templates/generators/item/item_inquiry.html" %}
+ {% endif %}
+ </div>
</div>
</div>
</div>
@@ -60,10 +121,11 @@
<script>
frappe.ready(() => {
$('.page_content').on('click', '.btn-add-to-cart', (e) => {
+ // Bind action on add to cart button
const $btn = $(e.currentTarget);
$btn.prop('disabled', true);
const item_code = $btn.data('item-code');
- erpnext.shopping_cart.update_cart({
+ erpnext.e_commerce.shopping_cart.update_cart({
item_code,
qty: 1,
callback(r) {
@@ -74,7 +136,42 @@
}
});
});
+
+ $('.page_content').on('click', '.offer-details', (e) => {
+ // Bind action on More link in Offers
+ const $btn = $(e.currentTarget);
+ $btn.prop('disabled', true);
+
+ var d = new frappe.ui.Dialog({
+ title: __($btn.data('offer-title')),
+ fields: [
+ {
+ fieldname: 'offer_details',
+ fieldtype: 'HTML'
+ },
+ {
+ fieldname: 'section_break',
+ fieldtype: 'Section Break'
+ }
+ ]
+ });
+
+ frappe.call({
+ method: 'erpnext.e_commerce.doctype.website_offer.website_offer.get_offer_details',
+ args: {
+ offer_id: $btn.data('offer-id')
+ },
+ callback: (value) => {
+ d.set_value("offer_details", value.message);
+ d.show();
+ $btn.prop('disabled', false);
+ }
+ })
+
+ });
});
+
+
</script>
{% endif %}
diff --git a/erpnext/templates/generators/item/item_configure.html b/erpnext/templates/generators/item/item_configure.html
index b61ac73..e97a275 100644
--- a/erpnext/templates/generators/item/item_configure.html
+++ b/erpnext/templates/generators/item/item_configure.html
@@ -3,11 +3,11 @@
<div class="mt-5 mb-6">
{% if cart_settings.enable_variants | int %}
- <button class="btn btn-primary-light btn-configure"
- data-item-code="{{ doc.name }}"
- data-item-name="{{ doc.item_name }}"
+ <button class="btn btn-primary-light btn-configure font-md mr-2"
+ data-item-code="{{ doc.item_code }}"
+ data-item-name="{{ doc.web_item_name }}"
>
- {{ _('Configure') }}
+ {{ _('Select Variant') }}
</button>
{% 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 8eadb84..231ae05 100644
--- a/erpnext/templates/generators/item/item_configure.js
+++ b/erpnext/templates/generators/item/item_configure.js
@@ -29,7 +29,7 @@
});
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 @@
<span class="mr-2">
${frappe.utils.icon('assets', 'md')}
</span>
- ${__("Add to Cart")}s
+ ${__("Add to Cart")}
</button>
` : '';
@@ -214,7 +214,7 @@
? `<div class="alert alert-success d-flex justify-content-between align-items-center" role="alert">
<div><div>
${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 @@
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 @@
}
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 @@
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 3b77585..028936b 100644
--- a/erpnext/templates/generators/item/item_details.html
+++ b/erpnext/templates/generators/item/item_details.html
@@ -1,27 +1,63 @@
-<div class="col-md-7 product-details">
-<!-- title -->
-<h1 class="product-title" itemprop="name">
- {{ item_name }}
-</h1>
-<p class="product-code">
- <span>{{ _("Item Code") }}:</span>
- <span itemprop="productID">{{ doc.name }}</span>
-</p>
-{% if has_variants %}
- <!-- configure template -->
- {% include "templates/generators/item/item_configure.html" %}
-{% else %}
- <!-- add variant to cart -->
- {% include "templates/generators/item/item_add_to_cart.html" %}
-{% endif %}
-<!-- description -->
-<div class="product-description" itemprop="description">
-{% 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 {} %}
+
+<div class="col-md-7 product-details {{ width_class }}">
+ <div class="d-flex">
+ <!-- title -->
+ <div class="product-title col-11" itemprop="name">
+ {{ doc.web_item_name }}
+ </div>
+
+ <!-- Wishlist -->
+ {% if cart_settings.enable_wishlist %}
+ <div class="like-action-item-fp like-action {{ 'like-action-wished' if wished else ''}} ml-2"
+ data-item-code="{{ doc.item_code }}">
+ <svg class="icon sm">
+ <use class="{{ 'wished' if wished else 'not-wished' }} wish-icon" href="#icon-heart"></use>
+ </svg>
+ </div>
+ {% endif %}
+ </div>
+
+ <p class="product-code">
+ <span class="product-item-group">
+ {{ _(doc.item_group) }}
+ </span>
+ <span class="product-item-code">
+ {{ _("Item Code") }}:
+ </span>
+ <span itemprop="productID">{{ doc.item_code }}</span>
+ </p>
+ {% if has_variants %}
+ <!-- configure template -->
+ {% include "templates/generators/item/item_configure.html" %}
+ {% else %}
+ <!-- add variant to cart -->
+ {% include "templates/generators/item/item_add_to_cart.html" %}
+ {% endif %}
+ <!-- description -->
+ <div class="product-description" itemprop="description">
+ {% 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 %}
+ </div>
</div>
-</div>
+
+{% block base_scripts %}
+<!-- js should be loaded in body! -->
+<script type="text/javascript" src="/assets/frappe/js/lib/jquery/jquery.min.js"></script>
+{% endblock %}
+
+<script>
+ $('.page_content').on('click', '.like-action-item-fp', (e) => {
+ // Bind action on wishlist button
+ const $btn = $(e.currentTarget);
+ erpnext.e_commerce.wishlist.wishlist_action($btn);
+ });
+</script>
\ 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 39a30d0..930bb7a 100644
--- a/erpnext/templates/generators/item/item_image.html
+++ b/erpnext/templates/generators/item/item_image.html
@@ -1,29 +1,30 @@
-<div class="col-md-5 h-100 d-flex">
+{% set column_size = 5 if slides else 4 %}
+<div class="col-md-{{ column_size }} h-100 d-flex mb-4">
{% if slides %}
- <div class="item-slideshow d-flex flex-column mr-3">
- {% for item in slides %}
- <img class="item-slideshow-image mb-2 {% if loop.first %}active{% endif %}"
- src="{{ item.image }}" alt="{{ item.heading }}">
- {% endfor %}
- </div>
- {{ product_image(slides[0].image, 'product-image') }}
- <!-- Simple image slideshow -->
- <script>
- frappe.ready(() => {
- $('.page_content').on('click', '.item-slideshow-image', (e) => {
- const $img = $(e.currentTarget);
- const link = $img.prop('src');
- const $product_image = $('.product-image');
- $product_image.find('a').prop('href', link);
- $product_image.find('img').prop('src', link);
+ <div class="item-slideshow d-flex flex-column mr-3">
+ {% for item in slides %}
+ <img class="item-slideshow-image mb-2 {% if loop.first %}active{% endif %}"
+ src="{{ item.image }}" alt="{{ item.heading }}">
+ {% endfor %}
+ </div>
+ {{ product_image(slides[0].image, 'product-image') }}
+ <!-- Simple image slideshow -->
+ <script>
+ frappe.ready(() => {
+ $('.page_content').on('click', '.item-slideshow-image', (e) => {
+ const $img = $(e.currentTarget);
+ const link = $img.prop('src');
+ const $product_image = $('.product-image');
+ $product_image.find('a').prop('href', link);
+ $product_image.find('img').prop('src', link);
- $('.item-slideshow-image').removeClass('active');
- $img.addClass('active');
- });
- })
- </script>
+ $('.item-slideshow-image').removeClass('active');
+ $img.addClass('active');
+ });
+ })
+ </script>
{% 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 %}
<!-- Simple image preview -->
diff --git a/erpnext/templates/generators/item/item_inquiry.html b/erpnext/templates/generators/item/item_inquiry.html
index 83653b6..af636f1 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 %}
- <button class="btn btn-inquiry btn-primary-light" data-item-code="{{ doc.name }}">
- {{ _('Contact Us') }}
- </button>
+ {% if cart_settings.show_contact_us_button | int %}
+ <button class="btn btn-inquiry font-md w-30-40" data-item-code="{{ doc.name }}">
+ {{ _('Contact Us') }}
+ </button>
{% endif %}
<script>
{% include "templates/generators/item/item_inquiry.js" %}
diff --git a/erpnext/templates/generators/item/item_inquiry.js b/erpnext/templates/generators/item/item_inquiry.js
index 4724b68..0aee996 100644
--- a/erpnext/templates/generators/item/item_inquiry.js
+++ b/erpnext/templates/generators/item/item_inquiry.js
@@ -52,7 +52,7 @@
d.hide();
- frappe.call('erpnext.shopping_cart.cart.create_lead_for_item_inquiry', {
+ frappe.call('erpnext.e_commerce.shopping_cart.cart.create_lead_for_item_inquiry', {
lead: doc,
subject: values.subject,
message: values.message
diff --git a/erpnext/templates/generators/item/item_reviews.html b/erpnext/templates/generators/item/item_reviews.html
new file mode 100644
index 0000000..c62c6f7
--- /dev/null
+++ b/erpnext/templates/generators/item/item_reviews.html
@@ -0,0 +1,88 @@
+{% from "erpnext/templates/includes/macros.html" import user_review, ratings_summary %}
+
+<div class="mt-4 ratings-reviews-section">
+ <!-- Title and Action -->
+ <div class="w-100 mt-4 mb-2 d-flex">
+ <div class="reviews-header col-9">
+ {{ _("Customer Reviews") }}
+ </div>
+
+ <div class="write-a-review-btn col-3">
+ <!-- Write a Review for legitimate users -->
+ {% if frappe.session.user != "Guest" and user_is_customer %}
+ <button class="btn btn-write-review"
+ data-web-item="{{ doc.name }}">
+ {{ _("Write a Review") }}
+ </button>
+ {% endif %}
+ </div>
+ </div>
+
+ <!-- Summary -->
+ {{ ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=True, total_reviews=total_reviews) }}
+
+
+ <!-- Reviews and Comments -->
+ <div class="mt-8">
+ {% if reviews %}
+ {{ user_review(reviews) }}
+
+ {% if total_reviews > 4 %}
+ <div class="mt-6 mb-6"style="color: var(--primary);">
+ <a href="/customer_reviews?web_item={{ doc.name }}">{{ _("View all reviews") }}</a>
+ </div>
+ {% endif %}
+
+ {% else %}
+ <h6 class="text-muted mt-6">
+ {{ _("No Reviews") }}
+ </h6>
+ {% endif %}
+ </div>
+</div>
+
+<script>
+ frappe.ready(() => {
+ $('.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() {
+ var data = d.get_values();
+ frappe.call({
+ method: "erpnext.e_commerce.doctype.item_review.item_review.add_item_review",
+ args: {
+ web_item: "{{ doc.name }}",
+ title: data.title,
+ rating: data.rating,
+ comment: data.comment
+ },
+ freeze: true,
+ freeze_message: __("Submitting Review ..."),
+ callback: function(r) {
+ if(!r.exc) {
+ frappe.msgprint({
+ message: __("Thank you for the review"),
+ title: __("Review Submitted"),
+ indicator: "green"
+ });
+ d.hide();
+ location.reload();
+ }
+ }
+ });
+ },
+ primary_action_label: __('Submit')
+ });
+ d.show();
+ });
+ });
+</script>
diff --git a/erpnext/templates/generators/item/item_specifications.html b/erpnext/templates/generators/item/item_specifications.html
index d4dfa8e..0814d81 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 -%}
-<div class="row item-website-specification mt-5">
- <div class="col-md-12">
- <table class="table table-bordered">
- {% for d in doc.website_specifications -%}
+<!-- Is reused to render within tabs as well as independently -->
+{% if website_specifications %}
+<div class="{{ 'mt-2' if not show_tabs else 'mt-5'}} item-website-specification">
+ <div class="col-md-11">
+ {% if not show_tabs %}
+ <div class="product-title mb-5 mt-4">
+ Product Details
+ </div>
+ {% endif %}
+ <table class="table">
+ {% for d in website_specifications -%}
<tr>
- <td class="text-muted" style="width: 30%;">{{ d.label }}</td>
- <td>{{ d.description }}</td>
+ <td class="spec-label">{{ d.label }}</td>
+ <td class="spec-content">{{ d.description }}</td>
</tr>
{%- endfor %}
</table>
</div>
</div>
-{%- endif %}
+{% endif %}
diff --git a/erpnext/templates/generators/item_group.html b/erpnext/templates/generators/item_group.html
index b5f18ba..e099cdd 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 %}
-<!-- <h2>{{ title }}</h2> -->
+<div class="mb-6">{{ _(item_group_name) }}</div>
{% endblock header %}
{% block script %}
<script type="text/javascript" src="/all-products/index.js"></script>
{% endblock %}
+{% block breadcrumbs %}
+<div class="item-breadcrumbs small text-muted">
+ {% include "templates/includes/breadcrumbs.html" %}
+</div>
+{% endblock %}
+
{% block page_content %}
-<div class="item-group-content" itemscope itemtype="http://schema.org/Product" data-item-group="{{ name }}">
+<div class="item-group-content" itemscope itemtype="http://schema.org/Product"
+ data-item-group="{{ name }}">
<div class="item-group-slideshow">
- {% if slideshow %}<!-- slideshow -->
+ {% if slideshow %} <!-- slideshow -->
{{ web_block(
"Hero Slider",
values=slideshow,
@@ -20,91 +28,28 @@
add_bottom_padding=0,
) }}
{% endif %}
- <h2 class="mt-3">{{ title }}</h2>
- {% if description %}<!-- description -->
+
+ {% if description %} <!-- description -->
<div class="item-group-description text-muted mb-5" itemprop="description">{{ description or ""}}</div>
{% endif %}
</div>
<div class="row">
- <div class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
- <div class="row products-list">
- {% 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 %}
- </div>
+ <div id="product-listing" class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
+ <!-- Products Rendered in all-products/index.js-->
</div>
+
<div class="col-12 order-1 col-md-3 order-md-1">
<div class="collapse d-md-block mr-4 filters-section" id="product-filters">
<div class="d-flex justify-content-between align-items-center mb-5 title-section">
<div class="mb-4 filters-title" > {{ _('Filters') }} </div>
<a class="mb-4 clear-filters" href="/{{ doc.route }}">{{ _('Clear All') }}</a>
</div>
- {% for field_filter in field_filters %}
- {%- set item_field = field_filter[0] %}
- {%- set values = field_filter[1] %}
- <div class="mb-4 filter-block pb-5">
- <div class="filter-label mb-3">{{ item_field.label }}</div>
+ <!-- field filters -->
+ {{ field_filter_section(field_filters) }}
- {% if values | len > 20 %}
- <!-- show inline filter if values more than 20 -->
- <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
- {% endif %}
+ <!-- attribute filters -->
+ {{ attribute_filter_section(attribute_filters) }}
- {% if values %}
- <div class="filter-options">
- {% for value in values %}
- <div class="checkbox" data-value="{{ value }}">
- <label for="{{value}}">
- <input type="checkbox"
- class="product-filter field-filter"
- id="{{value}}"
- data-filter-name="{{ item_field.fieldname }}"
- data-filter-value="{{ value }}"
- >
- <span class="label-area">{{ value }}</span>
- </label>
- </div>
- {% endfor %}
- </div>
- {% else %}
- <i class="text-muted">{{ _('No values') }}</i>
- {% endif %}
- </div>
- {% endfor %}
-
- {% for attribute in attribute_filters %}
- <div class="mb-4 filter-block pb-5">
- <div class="filter-label mb-3">{{ attribute.name}}</div>
- {% if values | len > 20 %}
- <!-- show inline filter if values more than 20 -->
- <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
- {% endif %}
-
- {% if attribute.item_attribute_values %}
- <div class="filter-options">
- {% for attr_value in attribute.item_attribute_values %}
- <div class="checkbox">
- <label data-value="{{ value }}">
- <input type="checkbox"
- class="product-filter attribute-filter"
- id="{{attr_value.name}}"
- data-attribute-name="{{ attribute.name }}"
- data-attribute-value="{{ attr_value.attribute_value }}"
- {% if attr_value.checked %} checked {% endif %}>
- <span class="label-area">{{ attr_value.attribute_value }}</span>
- </label>
- </div>
- {% endfor %}
- </div>
- {% else %}
- <i class="text-muted">{{ _('No values') }}</i>
- {% endif %}
- </div>
- {% endfor %}
</div>
<script>
@@ -127,23 +72,6 @@
</script>
</div>
</div>
- <div class="row mt-6">
- <div class="col-3">
- </div>
- <div class="col-9">
- {% if frappe.form_dict.start|int > 0 %}
- <button class="btn btn-outline-secondary btn-prev" data-start="{{ frappe.form_dict.start|int - page_length }}">
- {{ _("Prev") }}
- </button>
- {% endif %}
- {% if items|length >= page_length %}
- <button class="btn btn-outline-secondary btn-next" data-start="{{ frappe.form_dict.start|int + page_length }}"
- style="float: right;">
- {{ _("Next") }}
- </button>
- {% endif %}
- </div>
- </div>
</div>
<script>
diff --git a/erpnext/templates/includes/cart/address_card.html b/erpnext/templates/includes/cart/address_card.html
index 667144b..830ed64 100644
--- a/erpnext/templates/includes/cart/address_card.html
+++ b/erpnext/templates/includes/cart/address_card.html
@@ -1,5 +1,5 @@
<div class="card address-card h-100">
- <div class="btn btn-sm btn-default btn-change-address" style="position: absolute; right: 0; top: 0;">
+ <div class="btn btn-sm btn-default btn-change-address font-md" style="position: absolute; right: 0; top: 0;">
{{ _('Change') }}
</div>
<div class="card-body p-0">
diff --git a/erpnext/templates/includes/cart/cart_address.html b/erpnext/templates/includes/cart/cart_address.html
index 4482bc1..cf60017 100644
--- a/erpnext/templates/includes/cart/cart_address.html
+++ b/erpnext/templates/includes/cart/cart_address.html
@@ -4,18 +4,14 @@
{% set select_address = True %}
{% endif %}
-{% set show_coupon_code = frappe.db.get_single_value('Shopping Cart Settings', 'show_apply_coupon_code_in_website') %}
-{% if show_coupon_code == 1%}
-<div class="mb-3">
- <div class="row no-gutters">
- <input type="text" class="txtcoupon form-control mr-3 w-25" placeholder="Enter Coupon Code" name="txtcouponcode" ></input>
- <button class="btn btn-primary btn-sm bt-coupon">{{ _("Apply Coupon Code") }}</button>
- <input type="hidden" class="txtreferral_sales_partner" placeholder="Enter Sales Partner" name="txtreferral_sales_partner" type="text"></input>
- </div>
-</div>
-{% endif %}
<div class="mb-3 frappe-card p-5" data-section="shipping-address">
- <h6>{{ _("Shipping Address") }}</h6>
+ <div class="d-flex">
+ <div class="col-6 address-header"><h6>{{ _("Shipping Address") }}</h6></div>
+ <div class="col-6" style="padding: 0;">
+ <a class="ml-4 btn-new-address" role="button">{{ _("Add a new address") }}</a>
+ </div>
+ </div>
+
<hr>
{% for address in shipping_addresses %}
{% if doc.shipping_address_name == address.name %}
@@ -27,26 +23,36 @@
{% endif %}
{% endfor %}
</div>
+
+<!-- Billing Address -->
<div class="checkbox ml-1 mb-2">
<label for="input_same_billing">
- <input type="checkbox" id="input_same_billing" checked>
- <span class="label-area">{{ _('Billing Address is same as Shipping Address') }}</span>
+ <input type="checkbox" class="product-filter" id="input_same_billing" checked style="width: 14px !important">
+ <span class="label-area font-md">{{ _('Billing Address is same as Shipping Address') }}</span>
</label>
</div>
-<div class="mb-3 frappe-card p-5" data-section="billing-address">
- <h6>{{ _("Billing Address") }}</h6>
- <hr>
- {% for address in billing_addresses %}
- {% if doc.customer_address == address.name %}
- <div class="row no-gutters" data-fieldname="customer_address">
- <div class="w-100 address-container" data-address-name="{{address.name}}" data-address-type="billing" data-active>
- {% include "templates/includes/cart/address_card.html" %}
- </div>
+
+{% if billing_addresses %}
+ <div class="mb-3 frappe-card p-5" data-section="billing-address">
+ <div class="d-flex">
+ <div class="col-6 address-header"><h6>{{ _("Billing Address") }}</h6></div>
+ <div class="col-6" style="padding: 0;">
+ <a class="ml-4 btn-new-address" role="button">{{ _("Add a new address") }}</a>
+ </div>
</div>
- {% endif %}
- {% endfor %}
-</div>
-<button class="btn btn-outline-primary btn-sm mt-1 btn-new-address bg-white">{{ _("Add a new address") }}</button>
+
+ <hr>
+ {% for address in billing_addresses %}
+ {% if doc.customer_address == address.name %}
+ <div class="row no-gutters" data-fieldname="customer_address">
+ <div class="w-100 address-container" data-address-name="{{address.name}}" data-address-type="billing" data-active>
+ {% include "templates/includes/cart/address_card.html" %}
+ </div>
+ </div>
+ {% endif %}
+ {% endfor %}
+ </div>
+{% endif %}
<script>
frappe.ready(() => {
@@ -125,15 +131,16 @@
{
fieldname: "phone",
fieldtype: "Data",
- label: "Phone"
+ label: "Phone",
+ reqd: 1
},
],
primary_action_label: __('Save'),
primary_action: (values) => {
- frappe.call('erpnext.shopping_cart.cart.add_new_address', { doc: values })
+ frappe.call('erpnext.e_commerce.shopping_cart.cart.add_new_address', { doc: values })
.then(r => {
frappe.call({
- method: "erpnext.shopping_cart.cart.update_cart_address",
+ method: "erpnext.e_commerce.shopping_cart.cart.update_cart_address",
args: {
address_type: r.message.address_type,
address_name: r.message.name
diff --git a/erpnext/templates/includes/cart/cart_items.html b/erpnext/templates/includes/cart/cart_items.html
index 75441c4..428b36e 100644
--- a/erpnext/templates/includes/cart/cart_items.html
+++ b/erpnext/templates/includes/cart/cart_items.html
@@ -1,42 +1,113 @@
-{% for d in doc.items %}
-<tr data-name="{{ d.name }}">
- <td>
- <div class="item-title mb-1">
- {{ d.item_name }}
- </div>
- <div class="item-subtitle">
- {{ d.item_code }}
- </div>
- {%- set variant_of = frappe.db.get_value('Item', d.item_code, 'variant_of') %}
- {% if variant_of %}
- <span class="item-subtitle">
- {{ _('Variant of') }} <a href="{{frappe.db.get_value('Item', variant_of, 'route')}}">{{ variant_of }}</a>
- </span>
- {% endif %}
- <div class="mt-2">
- <textarea data-item-code="{{d.item_code}}" class="form-control" rows="2" placeholder="{{ _('Add notes') }}">{{d.additional_notes or ''}}</textarea>
- </div>
- </td>
- <td class="text-right">
- <div class="input-group number-spinner">
- <span class="input-group-prepend d-none d-sm-inline-block">
- <button class="btn cart-btn" data-dir="dwn">–</button>
- </span>
- <input class="form-control text-center cart-qty" value="{{ d.get_formatted('qty') }}" data-item-code="{{ d.item_code }}">
- <span class="input-group-append d-none d-sm-inline-block">
- <button class="btn cart-btn" data-dir="up">+</button>
+{% from "erpnext/templates/includes/macros.html" import product_image %}
+
+{% macro item_subtotal(item) %}
+ <div>
+ {{ item.get_formatted('amount') }}
+ </div>
+
+ {% if item.is_free_item %}
+ <div class="text-success mt-4">
+ <span class="free-tag">
+ {{ _('FREE') }}
</span>
</div>
- </td>
- {% if cart_settings.enable_checkout %}
- <td class="text-right item-subtotal">
- <div>
- {{ d.get_formatted('amount') }}
- </div>
+ {% else %}
<span class="item-rate">
- {{ _('Rate:') }} {{ d.get_formatted('rate') }}
+ {{ _('Rate:') }} {{ item.get_formatted('rate') }}
</span>
- </td>
{% endif %}
-</tr>
+{% endmacro %}
+
+{% for d in doc.items %}
+ <tr data-name="{{ d.name }}">
+ <td style="width: 60%;">
+ <div class="d-flex">
+ <div class="cart-item-image mr-4">
+ {% if d.thumbnail %}
+ {{ product_image(d.thumbnail, alt="d.web_item_name", no_border=True) }}
+ {% else %}
+ <div class = "no-image-cart-item">
+ {{ frappe.utils.get_abbr(d.web_item_name) or "NA" }}
+ </div>
+ {% endif %}
+ </div>
+
+ <div class="d-flex w-100" style="flex-direction: column;">
+ <div class="item-title mb-1 mr-3">
+ {{ d.get("web_item_name") or d.item_name }}
+ </div>
+ <div class="item-subtitle mr-2">
+ {{ d.item_code }}
+ </div>
+ {%- set variant_of = frappe.db.get_value('Item', d.item_code, 'variant_of') %}
+ {% if variant_of %}
+ <span class="item-subtitle mr-2">
+ {{ _('Variant of') }}
+ <a href="{{frappe.db.get_value('Website Item', {'item_code': variant_of}, 'route') or '#'}}">
+ {{ variant_of }}
+ </a>
+ </span>
+ {% endif %}
+
+ <div class="mt-2 notes">
+ <textarea data-item-code="{{d.item_code}}" class="form-control" rows="2" placeholder="{{ _('Add notes') }}">
+ {{d.additional_notes or ''}}
+ </textarea>
+ </div>
+ </div>
+ </div>
+ </td>
+
+ <!-- Qty column -->
+ <td class="text-right" style="width: 25%;">
+ <div class="d-flex">
+ {% set disabled = 'disabled' if d.is_free_item else '' %}
+ <div class="input-group number-spinner mt-1 mb-4">
+ <span class="input-group-prepend d-sm-inline-block">
+ <button class="btn cart-btn" data-dir="dwn" {{ disabled }}>
+ {{ '–' if not d.is_free_item else ''}}
+ </button>
+ </span>
+
+ <input class="form-control text-center cart-qty" value="{{ d.get_formatted('qty') }}" data-item-code="{{ d.item_code }}"
+ style="max-width: 70px;" {{ disabled }}>
+
+ <span class="input-group-append d-sm-inline-block">
+ <button class="btn cart-btn" data-dir="up" {{ disabled }}>
+ {{ '+' if not d.is_free_item else ''}}
+ </button>
+ </span>
+ </div>
+
+ <div>
+ {% if not d.is_free_item %}
+ <div class="remove-cart-item column-sm-view d-flex" data-item-code="{{ d.item_code }}">
+ <span>
+ <svg class="icon sm remove-cart-item-logo"
+ width="18" height="18" viewBox="0 0 18 18"
+ xmlns="http://www.w3.org/2000/svg" id="icon-close">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M4.146 11.217a.5.5 0 1 0 .708.708l3.182-3.182 3.181 3.182a.5.5 0 1 0 .708-.708l-3.182-3.18 3.182-3.182a.5.5 0 1 0-.708-.708l-3.18 3.181-3.183-3.182a.5.5 0 0 0-.708.708l3.182 3.182-3.182 3.181z" stroke-width="0"></path>
+ </svg>
+ </span>
+ </div>
+ {% endif %}
+ </div>
+ </div>
+
+
+ <!-- Shown on mobile view, else hidden -->
+ {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+ <div class="text-right sm-item-subtotal">
+ {{ item_subtotal(d) }}
+ </div>
+ {% endif %}
+ </td>
+
+ <!-- Subtotal column -->
+ {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+ <td class="text-right item-subtotal column-sm-view w-100">
+ {{ item_subtotal(d) }}
+ </td>
+ {% endif %}
+ </tr>
{% endfor %}
diff --git a/erpnext/templates/includes/cart/cart_items_total.html b/erpnext/templates/includes/cart/cart_items_total.html
new file mode 100644
index 0000000..c94fde4
--- /dev/null
+++ b/erpnext/templates/includes/cart/cart_items_total.html
@@ -0,0 +1,10 @@
+<!-- Total at the end of the cart items -->
+<tr>
+ <th></th>
+ <th class="text-left item-grand-total" colspan="1">
+ {{ _("Total") }}
+ </th>
+ <th class="text-left item-grand-total totals" colspan="3">
+ {{ doc.get_formatted("total") }}
+ </th>
+</tr>
\ No newline at end of file
diff --git a/erpnext/templates/includes/cart/cart_payment_summary.html b/erpnext/templates/includes/cart/cart_payment_summary.html
new file mode 100644
index 0000000..b5655a2
--- /dev/null
+++ b/erpnext/templates/includes/cart/cart_payment_summary.html
@@ -0,0 +1,84 @@
+<!-- Payment -->
+{% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+<h6>
+ {{ _("Payment Summary") }}
+</h6>
+{% endif %}
+
+<div class="card h-100">
+ <div class="card-body p-0">
+ {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+ <table class="table w-100">
+ <tr>
+ {% set total_items = frappe.utils.cstr(frappe.utils.flt(doc.total_qty, 0)) %}
+ <td class="bill-label">{{ _("Net Total (") + total_items + _(" Items)") }}</td>
+ <td class="bill-content net-total text-right">{{ doc.get_formatted("net_total") }}</td>
+ </tr>
+
+ <!-- taxes -->
+ {% for d in doc.taxes %}
+ {% if d.base_tax_amount %}
+ <tr>
+ <td class="bill-label">
+ {{ d.description }}
+ </td>
+ <td class="bill-content text-right">
+ {{ d.get_formatted("base_tax_amount") }}
+ </td>
+ </tr>
+ {% endif %}
+ {% endfor %}
+ </table>
+
+ <!-- TODO: Apply Coupon Dialog-->
+ <!-- {% set show_coupon_code = cart_settings.show_apply_coupon_code_in_website and cart_settings.enable_checkout %}
+ {% if show_coupon_code %}
+ <button class="btn btn-coupon-code w-100 text-left">
+ <svg width="24" height="24" viewBox="0 0 24 24" stroke="var(--gray-600)" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M19 15.6213C19 15.2235 19.158 14.842 19.4393 14.5607L20.9393 13.0607C21.5251 12.4749 21.5251 11.5251 20.9393 10.9393L19.4393 9.43934C19.158 9.15804 19 8.7765 19 8.37868V6.5C19 5.67157 18.3284 5 17.5 5H15.6213C15.2235 5 14.842 4.84196 14.5607 4.56066L13.0607 3.06066C12.4749 2.47487 11.5251 2.47487 10.9393 3.06066L9.43934 4.56066C9.15804 4.84196 8.7765 5 8.37868 5H6.5C5.67157 5 5 5.67157 5 6.5V8.37868C5 8.7765 4.84196 9.15804 4.56066 9.43934L3.06066 10.9393C2.47487 11.5251 2.47487 12.4749 3.06066 13.0607L4.56066 14.5607C4.84196 14.842 5 15.2235 5 15.6213V17.5C5 18.3284 5.67157 19 6.5 19H8.37868C8.7765 19 9.15804 19.158 9.43934 19.4393L10.9393 20.9393C11.5251 21.5251 12.4749 21.5251 13.0607 20.9393L14.5607 19.4393C14.842 19.158 15.2235 19 15.6213 19H17.5C18.3284 19 19 18.3284 19 17.5V15.6213Z" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M15 9L9 15" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M10.5 9.5C10.5 10.0523 10.0523 10.5 9.5 10.5C8.94772 10.5 8.5 10.0523 8.5 9.5C8.5 8.94772 8.94772 8.5 9.5 8.5C10.0523 8.5 10.5 8.94772 10.5 9.5Z" fill="white" stroke-linecap="round" stroke-linejoin="round"/>
+ <path d="M15.5 14.5C15.5 15.0523 15.0523 15.5 14.5 15.5C13.9477 15.5 13.5 15.0523 13.5 14.5C13.5 13.9477 13.9477 13.5 14.5 13.5C15.0523 13.5 15.5 13.9477 15.5 14.5Z" fill="white" stroke-linecap="round" stroke-linejoin="round"/>
+ </svg>
+ <span class="ml-2">Apply Coupon</span>
+ </button>
+ {% endif %} -->
+
+ <table class="table w-100 grand-total mt-6">
+ <tr>
+ <td class="bill-content net-total">{{ _("Grand Total") }}</td>
+ <td class="bill-content net-total text-right">{{ doc.get_formatted("grand_total") }}</td>
+ </tr>
+ </table>
+ {% endif %}
+
+ {% if cart_settings.enable_checkout %}
+ <button class="btn btn-primary btn-place-order font-md w-100" type="button">
+ {{ _('Place Order') }}
+ </button>
+ {% else %}
+ <button class="btn btn-primary btn-request-for-quotation font-md w-100" type="button">
+ {{ _('Request for Quote') }}
+ </button>
+ {% endif %}
+ </div>
+</div>
+
+<!-- TODO: Apply Coupon Dialog-->
+<!-- <script>
+ frappe.ready(() => {
+ $('.btn-coupon-code').click((e) => {
+ const $btn = $(e.currentTarget);
+ const d = new frappe.ui.Dialog({
+ title: __('Coupons'),
+ fields: [
+ {
+ fieldname: 'coupons_area',
+ fieldtype: 'HTML'
+ }
+ ]
+ });
+ d.show();
+ });
+ });
+</script> -->
\ No newline at end of file
diff --git a/erpnext/templates/includes/macros.html b/erpnext/templates/includes/macros.html
index be0d47f..4741307 100644
--- a/erpnext/templates/includes/macros.html
+++ b/erpnext/templates/includes/macros.html
@@ -7,9 +7,15 @@
</div>
{% endmacro %}
-{% macro product_image(website_image, css_class="product-image", alt="") %}
- <div class="border text-center rounded {{ css_class }}" style="overflow: hidden;">
- <img itemprop="image" class="website-image h-100 w-100" alt="{{ alt }}" src="{{ frappe.utils.quoted(website_image or 'no-image.jpg') | abs_url }}">
+{% macro product_image(website_image, css_class="product-image", alt="", no_border=False) %}
+ <div class="{{ 'border' if not no_border else ''}} text-center rounded {{ css_class }}" style="overflow: hidden;">
+ {% if website_image %}
+ <img itemprop="image" class="website-image h-100 w-100" alt="{{ alt }}" src="{{ frappe.utils.quoted(website_image) | abs_url }}">
+ {% else %}
+ <div class="card-img-top no-image-item">
+ {{ frappe.utils.get_abbr(alt) or "NA" }}
+ </div>
+ {% endif %}
</div>
{% 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 %}
<div class="col-sm-{{ col_size*2 }} item-card">
- <div class="card featured-item {{ align_items_class }}">
+ <div class="card featured-item {{ align_items_class }}" style="height: 360px;">
{% if image %}
<div class="row no-gutters">
- <div class="col-md-6">
+ <div class="col-md-5 ml-4">
<img class="card-img" src="{{ image }}" alt="{{ title }}">
</div>
<div class="col-md-6">
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
</div>
</div>
{% else %}
<div class="col-md-12">
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="col-sm-{{ col_size }} item-card">
- <div class="card {{ align_items_class }}">
+ <div class="card {{ align_items_class }}" style="height: 360px;">
{% if image %}
- <div class="card-img-container">
- <img class="card-img" src="{{ image }}" alt="{{ title }}">
- </div>
+ <div class="card-img-container">
+ <a href="/{{ item.route or '#' }}" style="text-decoration: none;">
+ <img class="card-img" src="{{ image }}" alt="{{ title }}">
+ </a>
+ </div>
{% else %}
- <div class="card-img-top no-image">
- {{ frappe.utils.get_abbr(title) }}
- </div>
+ <a href="/{{ item.route or '#' }}" style="text-decoration: none;">
+ <div class="card-img-top no-image">
+ {{ frappe.utils.get_abbr(title) }}
+ </div>
+ </a>
{% endif %}
- {{ item_card_body(title, description, url, rate, category, is_featured, align) }}
+ {{ item_card_body(title, description, item, is_featured, align) }}
</div>
</div>
{% 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,
}) -%}
-<div class="card-body {{ align_class }}">
- <div class="product-title">{{ title or '' }}</div>
+<div class="card-body {{ align_class }}" style="width:100%">
+ <div class="mt-4">
+ <a href="/{{ item.route or '#' }}">
+ <div class="product-title">
+ {{ title or '' }}
+ </div>
+ </a>
+ </div>
{% if is_featured %}
- <div class="product-price">{{ rate or '' }}</div>
- <div class="product-description ellipsis">{{ description or '' }}</div>
+ <div class="product-description ellipsis text-muted" style="white-space: normal;">
+ {{ description or '' }}
+ </div>
{% else %}
- <div class="product-category">{{ category or '' }}</div>
- <div class="product-price">{{ rate or '' }}</div>
+ <div class="product-category">{{ item.item_group or '' }}</div>
{% endif %}
</div>
-<a href="/{{ url or '#' }}" class="stretched-link"></a>
+{%- endmacro -%}
+
+
+{%- macro wishlist_card(item, settings) %}
+{%- set title = item.web_item_name or ''-%}
+{%- set title = title[:90] + "..." if title|len > 90 else title -%}
+<div class="col-sm-3 wishlist-card">
+ <div class="card text-center">
+ <div class="card-img-container">
+ <a href="/{{ item.route or '#' }}" style="text-decoration: none;">
+ {% if item.image %}
+ <img class="card-img" src="{{ item.image }}" alt="{{ title }}">
+ {% else %}
+ <div class="card-img-top no-image">
+ {{ frappe.utils.get_abbr(title) }}
+ </div>
+ {% endif %}
+ </a>
+ <div class="remove-wish" data-item-code="{{ item.item_code }}">
+ <svg class="icon icon-md remove-wish-icon">
+ <use class="close" href="#icon-delete"></use>
+ </svg>
+ </div>
+ </div>
+
+ {{ wishlist_card_body(item, title, settings) }}
+ </div>
+</div>
+{%- endmacro -%}
+
+{%- macro wishlist_card_body(item, title, settings) %}
+<div class="card-body card-body-flex text-left" style="width: 100%;">
+ <div class="mt-4">
+ <div class="product-title">{{ title or ''}}</div>
+ <div class="product-category">{{ item.item_group or '' }}</div>
+ </div>
+ <div class="product-price">
+ {{ item.get("formatted_price") or '' }}
+
+ {% if item.get("formatted_mrp") %}
+ <small class="ml-1 striked-price">
+ <s>{{ item.formatted_mrp }}</s>
+ </small>
+ <small class="ml-1 product-info-green" >
+ {{ item.discount }} OFF
+ </small>
+ {% endif %}
+ </div>
+
+ {% if (item.available and settings.show_stock_availability) or (not settings.show_stock_availability) %}
+ <!-- Show move to cart button if in stock or if showing stock availability is disabled -->
+ <button data-item-code="{{ item.item_code}}"
+ class="btn btn-primary btn-add-to-cart-list btn-add-to-cart mt-2 w-100">
+ <span class="mr-2">
+ <svg class="icon icon-md">
+ <use href="#icon-assets"></use>
+ </svg>
+ </span>
+ {{ _("Move to Cart") }}
+ </button>
+ {% else %}
+ <div class="out-of-stock">
+ {{ _("Out of stock") }}
+ </div>
+ {% endif %}
+</div>
+{%- endmacro -%}
+
+{%- macro ratings_with_title(avg_rating, title, size, rating_header_class, for_summary=False) -%}
+<div class="{{ 'd-flex' if not for_summary else '' }}">
+ <p class="mr-4 {{ rating_header_class }}">
+ <span>{{ title }}</span>
+ </p>
+ <div class="rating {{ 'ratings-pill' if for_summary else ''}}">
+ {% for i in range(1,6) %}
+ {% set fill_class = 'star-click' if i <= avg_rating else '' %}
+ <svg class="icon icon-{{ size }} {{ fill_class }}">
+ <use href="#icon-star"></use>
+ </svg>
+ {% endfor %}
+ </div>
+</div>
+{%- endmacro -%}
+
+{%- macro ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=False, total_reviews=None)-%}
+<div class="rating-summary-section mt-4">
+ <div class="rating-summary-numbers col-3">
+ <h2 style="font-size: 2rem;">
+ {{ average_rating or 0 }}
+ </h2>
+ <div class="mb-2" style="margin-top: -.5rem;">
+ {{ frappe.utils.cstr(total_reviews or 0) + " " + _("ratings") }}
+ </div>
+
+ <!-- Ratings Summary -->
+ {% 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 %}
+
+ <div class="mt-2">{{ frappe.utils.cstr(average_rating or 0) + " " + _("out of 5") }}</div>
+ </div>
+
+ <!-- Rating Progress Bars -->
+ <div class="rating-progress-bar-section col-4 ml-4">
+ {% for percent in reviews_per_rating %}
+ <div class="col-sm-4 small rating-bar-title">
+ {{ loop.index }} star
+ </div>
+ <div class="row">
+ <div class="col-md-7">
+ <div class="progress rating-progress-bar" title="{{ percent }} % of reviews are {{ loop.index }} star">
+ <div class="progress-bar progress-bar-cosmetic" role="progressbar"
+ aria-valuenow="{{ percent }}"
+ aria-valuemin="0" aria-valuemax="100"
+ style="width: {{ percent }}%;">
+ </div>
+ </div>
+ </div>
+ <div class="col-sm-1 small">
+ {{ percent }}%
+ </div>
+ </div>
+ {% endfor %}
+ </div>
+</div>
+{%- endmacro -%}
+
+{%- macro user_review(reviews)-%}
+<!-- User Reviews -->
+<div class="user-reviews">
+ {% for review in reviews %}
+ <div class="mb-3 review">
+ {{ ratings_with_title(review.rating, _(review.review_title), "sm", "user-review-title") }}
+
+ <div class="product-description mb-4">
+ <p>
+ {{ _(review.comment) }}
+ </p>
+ </div>
+
+ <div class="review-signature mb-2">
+ <span class="reviewer">{{ _(review.customer) }}</span>
+ <span class="indicator grey" style="--text-on-gray: var(--gray-300);"></span>
+ <span class="reviewer">{{ review.published_on }}</span>
+ </div>
+ </div>
+ {% endfor %}
+</div>
+{%- endmacro -%}
+
+{%- macro field_filter_section(filters)-%}
+{% for field_filter in filters %}
+ {%- set item_field = field_filter[0] %}
+ {%- set values = field_filter[1] %}
+ <div class="mb-4 filter-block pb-5">
+ <div class="filter-label mb-3">{{ item_field.label }}</div>
+
+ {% if values | len > 20 %}
+ <!-- show inline filter if values more than 20 -->
+ <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
+ {% endif %}
+
+ {% if values %}
+ <div class="filter-options">
+ {% for value in values %}
+ <div class="checkbox" data-value="{{ value }}">
+ <label for="{{value}}">
+ <input type="checkbox"
+ class="product-filter field-filter"
+ id="{{value}}"
+ data-filter-name="{{ item_field.fieldname }}"
+ data-filter-value="{{ value }}"
+ style="width: 14px !important">
+ <span class="label-area">{{ value }}</span>
+ </label>
+ </div>
+ {% endfor %}
+ </div>
+ {% else %}
+ <i class="text-muted">{{ _('No values') }}</i>
+ {% endif %}
+ </div>
+{% endfor %}
+{%- endmacro -%}
+
+{%- macro attribute_filter_section(filters)-%}
+{% for attribute in filters %}
+ <div class="mb-4 filter-block pb-5">
+ <div class="filter-label mb-3">{{ attribute.name}}</div>
+ {% if values | len > 20 %}
+ <!-- show inline filter if values more than 20 -->
+ <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
+ {% endif %}
+
+ {% if attribute.item_attribute_values %}
+ <div class="filter-options">
+ {% for attr_value in attribute.item_attribute_values %}
+ <div class="checkbox">
+ <label data-value="{{ attr_value }}">
+ <input type="checkbox"
+ class="product-filter attribute-filter"
+ id="{{ attr_value }}"
+ data-attribute-name="{{ attribute.name }}"
+ data-attribute-value="{{ attr_value }}"
+ style="width: 14px !important"
+ {% if attr_value.checked %} checked {% endif %}>
+ <span class="label-area">{{ attr_value }}</span>
+ </label>
+ </div>
+ {% endfor %}
+ </div>
+ {% else %}
+ <i class="text-muted">{{ _('No values') }}</i>
+ {% endif %}
+ </div>
+{% endfor %}
+{%- endmacro -%}
+
+{%- macro recommended_item_row(item)-%}
+<div class="recommended-item mb-6 d-flex">
+ <div class="r-item-image">
+ {% 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 %}
+ <div class="no-image-r-item">
+ {{ frappe.utils.get_abbr(item.website_item_name) or "NA" }}
+ </div>
+ {% endif %}
+ </div>
+ <div class="r-item-info">
+ <a href="/{{ item.route or '#'}}" target="_blank">
+ {% set title = item.website_item_name %}
+ {{ title[:70] + "..." if title|len > 70 else title }}
+ </a>
+
+ {% if item.get('price_info') %}
+ {% set price = item.get('price_info') %}
+ <div class="mt-2">
+ <span class="item-price">
+ {{ price.get('formatted_price') or '' }}
+ </span>
+
+ {% if price.get('formatted_mrp') %}
+ <br>
+ <span class="striked-item-price">
+ <s>MRP {{ price.formatted_mrp }}</s>
+ </span>
+ <span class="in-green">
+ - {{ price.get('formatted_discount_percent') or price.get('formatted_discount_rate')}}
+ </span>
+ {% endif %}
+ </div>
+ {% endif %}
+ </div>
+</div>
{%- endmacro -%}
diff --git a/erpnext/templates/includes/navbar/navbar_items.html b/erpnext/templates/includes/navbar/navbar_items.html
index 2912206..3275521 100644
--- a/erpnext/templates/includes/navbar/navbar_items.html
+++ b/erpnext/templates/includes/navbar/navbar_items.html
@@ -6,7 +6,17 @@
<svg class="icon icon-lg">
<use href="#icon-assets"></use>
</svg>
- <span class="badge badge-primary cart-badge" id="cart-count"></span>
+ <span class="badge badge-primary shopping-badge" id="cart-count"></span>
</a>
- </li>
+ </li>
+ {% if frappe.db.get_single_value("E Commerce Settings", "enable_wishlist") %}
+ <li class="wishlist wishlist-icon hidden">
+ <a class="nav-link" href="/wishlist">
+ <svg class="icon icon-lg">
+ <use href="#icon-heart-active"></use>
+ </svg>
+ <span class="badge badge-primary shopping-badge" id="wish-count"></span>
+ </a>
+ </li>
+ {% endif %}
{% endblock %}
diff --git a/erpnext/templates/includes/order/order_macros.html b/erpnext/templates/includes/order/order_macros.html
index 7b3c9a4..3f2c1f2 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) %}
- <div class="row item_name_and_description">
- <div class="col-xs-4 col-sm-2 order-image-col">
- <div class="order-image">
- {{ product_image_square(d.thumbnail or d.image) }}
- </div>
- </div>
- <div class="col-xs-8 col-sm-10">
- {{ d.item_code }}
- <div class="text-muted small item-description">
+ <div class="row item_name_and_description">
+ <div class="col-xs-4 col-sm-2 order-image-col">
+ <div class="order-image">
+ {% if d.thumbnail or d.image %}
+ {{ product_image(d.thumbnail or d.image, no_border=True) }}
+ {% else %}
+ <div class="no-image-cart-item" style="min-height: 100px;">
+ {{ frappe.utils.get_abbr(d.item_name) or "NA" }}
+ </div>
+ {% endif %}
+ </div>
+ </div>
+ <div class="col-xs-8 col-sm-10">
+ {{ d.item_code }}
+ <div class="text-muted small item-description">
{{ html2text(d.description) | truncate(140) }}
</div>
- </div>
- </div>
+ </div>
+ </div>
{% endmacro %}
{% macro item_name_and_description_cart(d) %}
- <div class="row item_name_dropdown">
- <div class="col-xs-4 col-sm-4 order-image-col">
- <div class="order-image">
- {{ product_image_square(d.thumbnail or d.image) }}
- </div>
- </div>
- <div class="col-xs-8 col-sm-8">
- {{ d.item_name|truncate(25) }}
- <div class="input-group number-spinner">
- <span class="input-group-btn">
- <button class="btn btn-light cart-btn" data-dir="dwn">
- –</button>
- </span>
- <input class="form-control text-right cart-qty"
- value = "{{ d.get_formatted('qty') }}"
- data-item-code="{{ d.item_code }}">
- <span class="input-group-btn">
- <button class="btn btn-light cart-btn" data-dir="up">
- +</button>
- </span>
+ <div class="row item_name_dropdown">
+ <div class="col-xs-4 col-sm-4 order-image-col">
+ <div class="order-image">
+ {{ product_image_square(d.thumbnail or d.image) }}
</div>
- </div>
- </div>
+ </div>
+ <div class="col-xs-8 col-sm-8">
+ {{ d.item_name|truncate(25) }}
+ <div class="input-group number-spinner">
+ <span class="input-group-btn">
+ <button class="btn btn-light cart-btn" data-dir="dwn">
+ –</button>
+ </span>
+ <input class="form-control text-right cart-qty"
+ value = "{{ d.get_formatted('qty') }}"
+ data-item-code="{{ d.item_code }}">
+ <span class="input-group-btn">
+ <button class="btn btn-light cart-btn" data-dir="up">
+ +</button>
+ </span>
+ </div>
+ </div>
+ </div>
{% endmacro %}
diff --git a/erpnext/templates/includes/order/order_taxes.html b/erpnext/templates/includes/order/order_taxes.html
index d2c458e..b821e62 100644
--- a/erpnext/templates/includes/order/order_taxes.html
+++ b/erpnext/templates/includes/order/order_taxes.html
@@ -1,9 +1,9 @@
{% if doc.taxes %}
<tr>
- <td class="text-right" colspan="2">
+ <td class="text-left" colspan="1">
{{ _("Net Total") }}
</td>
- <td class="text-right">
+ <td class="text-right totals" colspan="3">
{{ doc.get_formatted("net_total") }}
</td>
</tr>
@@ -12,10 +12,10 @@
{% for d in doc.taxes %}
{% if d.base_tax_amount %}
<tr>
- <td class="text-right" colspan="2">
+ <td class="text-left" colspan="1">
{{ d.description }}
</td>
- <td class="text-right">
+ <td class="text-right totals" colspan="3">
{{ d.get_formatted("base_tax_amount") }}
</td>
</tr>
@@ -23,76 +23,62 @@
{% endfor %}
{% if doc.doctype == 'Quotation' %}
-{% if doc.coupon_code %}
-<tr>
- <th class="text-right" colspan="2">
- {{ _("Discount") }}
- </th>
- <th class="text-right tot_quotation_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) }}
- </th>
-</tr>
-{% endif %}
+ {% if doc.coupon_code %}
+ <tr>
+ <td class="text-left total-discount" colspan="1">
+ {{ _("Savings") }}
+ </td>
+ <td class="text-right tot_quotation_discount total-discount totals" colspan="3">
+ {% 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) }}
+ </td>
+ </tr>
+ {% endif %}
{% endif %}
{% if doc.doctype == 'Sales Order' %}
-{% if doc.coupon_code %}
-<tr>
- <th class="text-right" colspan="2">
- {{ _("Total Amount") }}
- </th>
- <th class="text-right">
- <span>
- {% 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) }}
- </span>
- </th>
-</tr>
-<tr>
- <th class="text-right" colspan="2">
- {{ _("Applied Coupon Code") }}
- </th>
- <th class="text-right">
- <span>
- {%- for row in frappe.get_all(doctype="Coupon Code",
- fields=["coupon_code"], filters={ "name":doc.coupon_code}) -%}
- <span>{{ row.coupon_code }}</span>
- {% endfor %}
- </span>
- </th>
-</tr>
-<tr>
- <th class="text-right" colspan="2">
- {{ _("Discount") }}
- </th>
- <th class="text-right">
- <span>
- {% 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) }}
- </span>
- </th>
-</tr>
-{% endif %}
+ {% if doc.coupon_code %}
+ <tr>
+ <td class="text-left total-discount" colspan="2" style="padding-right: 2rem;">
+ {{ _("Applied Coupon Code") }}
+ </td>
+ <td class="text-right total-discount">
+ <span>
+ {%- for row in frappe.get_all(doctype="Coupon Code",
+ fields=["coupon_code"], filters={ "name":doc.coupon_code}) -%}
+ <span>{{ row.coupon_code }}</span>
+ {% endfor %}
+ </span>
+ </td>
+ </tr>
+ <tr>
+ <td class="text-left total-discount" colspan="2">
+ {{ _("Savings") }}
+ </td>
+ <td class="text-right total-discount">
+ <span>
+ {% 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) }}
+ </span>
+ </td>
+ </tr>
+ {% endif %}
{% endif %}
<tr>
- <th></th>
- <th class="item-grand-total">
+ <th class="text-left item-grand-total" colspan="1">
{{ _("Grand Total") }}
</th>
- <th class="text-right item-grand-total">
+ <th class="text-right item-grand-total totals" colspan="3">
{{ doc.get_formatted("grand_total") }}
</th>
</tr>
diff --git a/erpnext/templates/includes/product_page.js b/erpnext/templates/includes/product_page.js
index 90a1d86..a3979d0 100644
--- a/erpnext/templates/includes/product_page.js
+++ b/erpnext/templates/includes/product_page.js
@@ -7,7 +7,7 @@
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 9bf9fd9..a9369bb 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 %}
+<!-- Used in Product Search -->
<a class="product-link product-list-link" href="{{ route|abs_url }}">
<div class='row'>
<div class='col-xs-3 col-sm-2 product-image-wrapper'>
diff --git a/erpnext/templates/pages/cart.html b/erpnext/templates/pages/cart.html
index c64c634..2b7d9e3 100644
--- a/erpnext/templates/pages/cart.html
+++ b/erpnext/templates/pages/cart.html
@@ -4,13 +4,6 @@
{% block header %}<h3 class="shopping-cart-header mt-2 mb-6">{{ _("Shopping Cart") }}</h1>{% endblock %}
-<!--
-{% block script %}
-<script>{% include "templates/includes/cart.js" %}</script>
-{% endblock %}
--->
-
-
{% block header_actions %}
{% endblock %}
@@ -21,8 +14,9 @@
{% if doc.items %}
<div class="cart-container">
<div class="row m-0">
- <div class="col-md-8 frappe-card p-5">
- <div>
+ <!-- Left section -->
+ <div class="col-md-8">
+ <div class="frappe-card p-5 mb-4">
<div id="cart-error" class="alert alert-danger" style="display: none;"></div>
<div class="cart-items-header">
{{ _('Items') }}
@@ -30,89 +24,82 @@
<table class="table mt-3 cart-table">
<thead>
<tr>
- <th width="60%">{{ _('Item') }}</th>
+ <th class="item-column">{{ _('Item') }}</th>
<th width="20%">{{ _('Quantity') }}</th>
- {% if cart_settings.enable_checkout %}
- <th width="20%" class="text-right">{{ _('Subtotal') }}</th>
+ {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+ <th width="20" class="text-right column-sm-view">{{ _('Subtotal') }}</th>
{% endif %}
+ <th width="10%" class="column-sm-view"></th>
</tr>
</thead>
<tbody class="cart-items">
{% include "templates/includes/cart/cart_items.html" %}
</tbody>
- {% if cart_settings.enable_checkout %}
- <tfoot class="cart-tax-items">
- {% include "templates/includes/order/order_taxes.html" %}
- </tfoot>
+
+ {% if cart_settings.enable_checkout or cart_settings.show_price_in_quotation %}
+ <tfoot class="cart-tax-items">
+ {% include "templates/includes/cart/cart_items_total.html" %}
+ </tfoot>
{% endif %}
</table>
- </div>
- <div class="row">
- <div class="col-4">
- {% if cart_settings.enable_checkout %}
- <a class="btn btn-outline-primary" href="/orders">
- {{ _('See past orders') }}
- </a>
- {% else %}
- <a class="btn btn-outline-primary" href="/quotations">
- {{ _('See past quotations') }}
- </a>
- {% endif %}
- </div>
- <div class="col-8">
- {% if doc.items %}
- <div class="place-order-container">
- <a class="btn btn-primary-light mr-2" href="/all-products">
- {{ _("Continue Shopping") }}
- </a>
+
+ <div class="row mt-2">
+ <div class="col-3">
{% if cart_settings.enable_checkout %}
- <button class="btn btn-primary btn-place-order" type="button">
- {{ _("Place Order") }}
- </button>
+ <a class="btn btn-primary-light font-md" href="/orders">
+ {{ _('Past Orders') }}
+ </a>
{% else %}
- <button class="btn btn-primary btn-request-for-quotation" type="button">
- {{ _("Request for Quotation") }}
- </button>
+ <a class="btn btn-primary-light font-md" href="/quotations">
+ {{ _('Past Quotes') }}
+ </a>
{% endif %}
</div>
- {% endif %}
+ <div class="col-9">
+ {% if doc.items %}
+ <div class="place-order-container">
+ <a class="btn btn-primary-light mr-2 font-md" href="/all-products">
+ {{ _('Continue Shopping') }}
+ </a>
+ </div>
+ {% endif %}
+ </div>
</div>
</div>
-
+ <!-- Terms and Conditions -->
{% if doc.items %}
- {% if doc.tc_name %}
- <div class="terms-and-conditions-link">
- <a href class="link-terms-and-conditions" data-terms-name="{{ doc.tc_name }}">
- {{ _("Terms and Conditions") }}
- </a>
- <script>
- frappe.ready(() => {
- $('.link-terms-and-conditions').click((e) => {
- e.preventDefault();
- const $link = $(e.target);
- const terms_name = $link.attr('data-terms-name');
- show_terms_and_conditions(terms_name);
- })
- });
- function show_terms_and_conditions(terms_name) {
- frappe.call('erpnext.shopping_cart.cart.get_terms_and_conditions', { terms_name })
- .then(r => {
- frappe.msgprint({
- title: terms_name,
- message: r.message
- });
- });
- }
- </script>
- </div>
- {% endif %}
+ {% if doc.terms %}
+ <div class="t-and-c-container mt-4 frappe-card">
+ <h5>{{ _("Terms and Conditions") }}</h5>
+ <div class="t-and-c-terms mt-2">
+ {{ doc.terms }}
+ </div>
+ </div>
+ {% endif %}
</div>
+ <!-- Right section -->
<div class="col-md-4">
- <div class="cart-addresses">
- {% include "templates/includes/cart/cart_address.html" %}
+ <div class="cart-payment-addresses">
+ <!-- Apply Coupon Code -->
+ {% set show_coupon_code = cart_settings.show_apply_coupon_code_in_website and cart_settings.enable_checkout %}
+ {% if show_coupon_code == 1%}
+ <div class="mb-3">
+ <div class="row no-gutters">
+ <input type="text" class="txtcoupon form-control mr-3 w-50 font-md" placeholder="Enter Coupon Code" name="txtcouponcode" ></input>
+ <button class="btn btn-primary btn-sm bt-coupon font-md">{{ _("Apply Coupon Code") }}</button>
+ <input type="hidden" class="txtreferral_sales_partner font-md" placeholder="Enter Sales Partner" name="txtreferral_sales_partner" type="text"></input>
+ </div>
+ </div>
+ {% endif %}
+
+ <div class="mb-3 frappe-card p-5 payment-summary">
+ {% include "templates/includes/cart/cart_payment_summary.html" %}
</div>
+
+ {% include "templates/includes/cart/cart_address.html" %}
+ </div>
</div>
{% endif %}
</div>
@@ -124,11 +111,11 @@
</div>
<div class="cart-empty-message mt-4">{{ _('Your cart is Empty') }}</p>
{% if cart_settings.enable_checkout %}
- <a class="btn btn-outline-primary" href="/orders">
+ <a class="btn btn-outline-primary" href="/orders" style="font-size: 16px;">
{{ _('See past orders') }}
</a>
{% else %}
- <a class="btn btn-outline-primary" href="/quotations">
+ <a class="btn btn-outline-primary" href="/quotations" style="font-size: 16px;">
{{ _('See past quotations') }}
</a>
{% endif %}
diff --git a/erpnext/templates/includes/cart.js b/erpnext/templates/pages/cart.js
similarity index 84%
rename from erpnext/templates/includes/cart.js
rename to erpnext/templates/pages/cart.js
index c390cd1..fb2d159 100644
--- a/erpnext/templates/includes/cart.js
+++ b/erpnext/templates/pages/cart.js
@@ -1,11 +1,9 @@
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
// License: GNU General Public License v3. See license.txt
-// js inside blog page
-
-// shopping cart
-frappe.provide("erpnext.shopping_cart");
-var shopping_cart = erpnext.shopping_cart;
+// JS exclusive to /cart page
+frappe.provide("erpnext.e_commerce.shopping_cart");
+var shopping_cart = erpnext.e_commerce.shopping_cart;
$.extend(shopping_cart, {
show_error: function(title, text) {
@@ -18,8 +16,8 @@
shopping_cart.bind_place_order();
shopping_cart.bind_request_quotation();
shopping_cart.bind_change_qty();
+ shopping_cart.bind_remove_cart_item();
shopping_cart.bind_change_notes();
- shopping_cart.bind_dropdown_cart_buttons();
shopping_cart.bind_coupon_code();
},
@@ -48,7 +46,7 @@
const address_name = $card.closest('[data-address-name]').attr('data-address-name');
frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.cart.update_cart_address",
+ method: "erpnext.e_commerce.shopping_cart.cart.update_cart_address",
freeze: true,
args: {
address_type,
@@ -57,7 +55,7 @@
callback: function(r) {
d.hide();
if (!r.exc) {
- $(".cart-tax-items").html(r.message.taxes);
+ $(".cart-tax-items").html(r.message.total);
shopping_cart.parent.find(
`.address-container[data-address-type="${address_type}"]`
).html(r.message.address);
@@ -129,8 +127,14 @@
}
}
input.val(newVal);
+
+ let notes = input.closest("td").siblings().find(".notes").text().trim();
var item_code = input.attr("data-item-code");
- shopping_cart.shopping_cart_update({item_code, qty: newVal});
+ shopping_cart.shopping_cart_update({
+ item_code,
+ qty: newVal,
+ additional_notes: notes
+ });
});
},
@@ -148,6 +152,18 @@
});
},
+ bind_remove_cart_item: function() {
+ $(".cart-items").on("click", ".remove-cart-item", (e) => {
+ const $remove_cart_item_btn = $(e.currentTarget);
+ var item_code = $remove_cart_item_btn.data("item-code");
+
+ shopping_cart.shopping_cart_update({
+ item_code: item_code,
+ qty: 0
+ });
+ });
+ },
+
render_tax_row: function($cart_taxes, doc, shipping_rules) {
var shipping_selector;
if(shipping_rules) {
@@ -185,7 +201,7 @@
return frappe.call({
btn: btn,
type: "POST",
- method: "erpnext.shopping_cart.cart.apply_shipping_rule",
+ method: "erpnext.e_commerce.shopping_cart.cart.apply_shipping_rule",
args: { shipping_rule: rule },
callback: function(r) {
if(!r.exc) {
@@ -196,12 +212,15 @@
},
place_order: function(btn) {
+ shopping_cart.freeze();
+
return frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.cart.place_order",
+ method: "erpnext.e_commerce.shopping_cart.cart.place_order",
btn: btn,
callback: function(r) {
if(r.exc) {
+ shopping_cart.unfreeze();
var msg = "";
if(r._server_messages) {
msg = JSON.parse(r._server_messages || []).join("<br>");
@@ -212,7 +231,6 @@
.html(msg || frappe._("Something went wrong!"))
.toggle(true);
} else {
- $('.cart-container table').hide();
$(btn).hide();
window.location.href = '/orders/' + encodeURIComponent(r.message);
}
@@ -221,12 +239,15 @@
},
request_quotation: function(btn) {
+ shopping_cart.freeze();
+
return frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.cart.request_for_quotation",
+ method: "erpnext.e_commerce.shopping_cart.cart.request_for_quotation",
btn: btn,
callback: function(r) {
if(r.exc) {
+ shopping_cart.unfreeze();
var msg = "";
if(r._server_messages) {
msg = JSON.parse(r._server_messages || []).join("<br>");
@@ -237,7 +258,6 @@
.html(msg || frappe._("Something went wrong!"))
.toggle(true);
} else {
- $('.cart-container table').hide();
$(btn).hide();
window.location.href = '/quotations/' + encodeURIComponent(r.message);
}
@@ -254,7 +274,7 @@
apply_coupon_code: function(btn) {
return frappe.call({
type: "POST",
- method: "erpnext.shopping_cart.cart.apply_coupon_code",
+ method: "erpnext.e_commerce.shopping_cart.cart.apply_coupon_code",
btn: btn,
args : {
applied_code : $('.txtcoupon').val(),
@@ -270,7 +290,9 @@
});
frappe.ready(function() {
- $(".cart-icon").hide();
+ if (window.location.pathname === "/cart") {
+ $(".cart-icon").hide();
+ }
shopping_cart.parent = $(".cart-container");
shopping_cart.bind_events();
});
diff --git a/erpnext/templates/pages/cart.py b/erpnext/templates/pages/cart.py
index 0bba1ff..cadb46f 100644
--- a/erpnext/templates/pages/cart.py
+++ b/erpnext/templates/pages/cart.py
@@ -1,11 +1,11 @@
-# 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
no_cache = 1
-
-from erpnext.shopping_cart.cart import get_cart_quotation
+from erpnext.e_commerce.shopping_cart.cart import get_cart_quotation
def get_context(context):
+ context.body_class = "product-page"
context.update(get_cart_quotation())
diff --git a/erpnext/templates/pages/cart_terms.html b/erpnext/templates/pages/cart_terms.html
deleted file mode 100644
index 6d84fb8..0000000
--- a/erpnext/templates/pages/cart_terms.html
+++ /dev/null
@@ -1,2 +0,0 @@
-
-<div>{{doc.terms}}</div>
diff --git a/erpnext/templates/pages/customer_reviews.html b/erpnext/templates/pages/customer_reviews.html
new file mode 100644
index 0000000..121bec3
--- /dev/null
+++ b/erpnext/templates/pages/customer_reviews.html
@@ -0,0 +1,67 @@
+{% extends "templates/web.html" %}
+{% from "erpnext/templates/includes/macros.html" import user_review, ratings_summary %}
+
+{% block title %} {{ _("Customer Reviews") }} {% endblock %}
+
+{% block page_content %}
+<div class="product-container reviews-full-page col-md-12">
+ {% if enable_reviews %}
+ <!-- Title and Action -->
+ <div class="w-100 mb-6 d-flex">
+ <div class="reviews-header col-9">
+ {{ _("Customer Reviews") }}
+ </div>
+
+ <div class="write-a-review-btn col-3">
+ <!-- Write a Review for legitimate users -->
+ {% if frappe.session.user != "Guest" and user_is_customer %}
+ <button class="btn btn-write-review"
+ data-web-item="{{ web_item }}">
+ {{ _("Write a Review") }}
+ </button>
+ {% endif %}
+ </div>
+ </div>
+
+ <!-- Summary -->
+ {{ ratings_summary(reviews, reviews_per_rating, average_rating, average_whole_rating, for_summary=True, total_reviews=total_reviews) }}
+
+
+ <!-- Reviews and Comments -->
+ <div class="mt-8">
+ {% if reviews %}
+ {{ user_review(reviews) }}
+
+ {% if not reviews | len >= total_reviews %}
+ <button class="btn btn-light btn-view-more mr-2 mt-4 mb-4 w-30"
+ data-web-item="{{ web_item }}">
+ {{ _("View More") }}
+ </button>
+ {% endif %}
+
+ {% else %}
+ <h6 class="text-muted mt-6">
+ {{ _("No Reviews") }}
+ </h6>
+ {% endif %}
+ </div>
+ {% else %}
+ <!-- If reviews are disabled -->
+ <div class="text-center">
+ <h3 class="text-muted mt-8">
+ {{ _("No Reviews") }}
+ </h3>
+ </div>
+ {% endif %}
+</div>
+
+{% endblock %}
+
+{% block base_scripts %}
+<!-- js should be loaded in body! -->
+<script type="text/javascript" src="/assets/frappe/js/lib/jquery/jquery.min.js"></script>
+<script type="text/javascript" src="/assets/js/frappe-web.min.js"></script>
+<script type="text/javascript" src="/assets/js/control.min.js"></script>
+<script type="text/javascript" src="/assets/js/dialog.min.js"></script>
+<script type="text/javascript" src="/assets/js/bootstrap-4-web.min.js"></script>
+{% endblock %}
\ No newline at end of file
diff --git a/erpnext/templates/pages/customer_reviews.py b/erpnext/templates/pages/customer_reviews.py
new file mode 100644
index 0000000..c1f0c93
--- /dev/null
+++ b/erpnext/templates/pages/customer_reviews.py
@@ -0,0 +1,25 @@
+# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+import frappe
+
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import (
+ get_shopping_cart_settings,
+)
+from erpnext.e_commerce.doctype.item_review.item_review import get_item_reviews
+from erpnext.e_commerce.doctype.website_item.website_item import check_if_user_is_customer
+
+
+def get_context(context):
+ context.body_class = "product-page"
+ context.no_cache = 1
+ context.full_page = True
+ context.reviews = None
+
+ if frappe.form_dict and frappe.form_dict.get("web_item"):
+ context.web_item = frappe.form_dict.get("web_item")
+ context.user_is_customer = check_if_user_is_customer()
+ context.enable_reviews = get_shopping_cart_settings().enable_reviews
+
+ if context.enable_reviews:
+ reviews_data = get_item_reviews(context.web_item)
+ context.update(reviews_data)
diff --git a/erpnext/templates/pages/home.py b/erpnext/templates/pages/home.py
index 5d046a8..d08e81b 100644
--- a/erpnext/templates/pages/home.py
+++ b/erpnext/templates/pages/home.py
@@ -10,7 +10,7 @@
homepage = frappe.get_doc('Homepage')
for item in homepage.products:
- route = frappe.db.get_value('Item', item.item_code, 'route')
+ route = frappe.db.get_value('Website Item', {"item_code": item.item_code}, 'route')
if route:
item.route = '/' + route
diff --git a/erpnext/templates/pages/order.html b/erpnext/templates/pages/order.html
index 28faea8..a10870d 100644
--- a/erpnext/templates/pages/order.html
+++ b/erpnext/templates/pages/order.html
@@ -12,172 +12,173 @@
{% endblock %}
{% block header_actions %}
-<div class="dropdown">
- <button class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
- <span>{{ _('Actions') }}</span>
- <b class="caret"></b>
- </button>
- <ul class="dropdown-menu dropdown-menu-right" role="menu">
- {% if doc.doctype == 'Purchase Order' %}
- <a class="dropdown-item" href="/api/method/erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice_from_portal?purchase_order_name={{ doc.name }}" data-action="make_purchase_invoice">{{ _("Make Purchase Invoice") }}</a>
- {% endif %}
- <a class="dropdown-item" href='/printview?doctype={{ doc.doctype}}&name={{ doc.name }}&format={{ print_format }}'
- target="_blank" rel="noopener noreferrer">
- {{ _("Print") }}
- </a>
- </ul>
-</div>
-
+ <div class="dropdown">
+ <button class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-expanded="false">
+ <span class="font-md">{{ _('Actions') }}</span>
+ <b class="caret"></b>
+ </button>
+ <ul class="dropdown-menu dropdown-menu-right" role="menu">
+ {% if doc.doctype == 'Purchase Order' %}
+ <a class="dropdown-item" href="/api/method/erpnext.buying.doctype.purchase_order.purchase_order.make_purchase_invoice_from_portal?purchase_order_name={{ doc.name }}" data-action="make_purchase_invoice">{{ _("Make Purchase Invoice") }}</a>
+ {% endif %}
+ <a class="dropdown-item" href='/printview?doctype={{ doc.doctype}}&name={{ doc.name }}&format={{ print_format }}'
+ target="_blank" rel="noopener noreferrer">
+ {{ _("Print") }}
+ </a>
+ </ul>
+ </div>
{% endblock %}
{% block page_content %}
-
-<div class="row transaction-subheading">
- <div class="col-6">
- <span class="indicator-pill {{ doc.indicator_color or ("blue" if doc.docstatus==1 else "darkgrey") }}">
- {% if doc.doctype == "Quotation" and not doc.docstatus %}
- {{ _("Pending") }}
- {% else %}
- {{ _(doc.get('indicator_title')) or _(doc.status) or _("Submitted") }}
- {% endif %}
- </span>
- </div>
- <div class="col-6 text-muted text-right small pt-3">
- {{ frappe.utils.format_date(doc.transaction_date, 'medium') }}
- {% if doc.valid_till %}
- <p>
- {{ _("Valid Till") }}: {{ frappe.utils.format_date(doc.valid_till, 'medium') }}
- </p>
- {% endif %}
- </div>
-</div>
-
-<p class="small my-3">
- {%- set party_name = doc.supplier_name if doc.doctype in ['Supplier Quotation', 'Purchase Invoice', 'Purchase Order'] else doc.customer_name %}
- <b>{{ party_name }}</b>
-
- {% if doc.contact_display and doc.contact_display != party_name %}
- <br>
- {{ doc.contact_display }}
- {% endif %}
-</p>
-
-{% if doc._header %}
-{{ doc._header }}
-{% endif %}
-
-<div class="order-container">
- <!-- items -->
- <table class="order-item-table w-100 table">
- <thead class="order-items order-item-header">
- <th width="60%">
- {{ _("Item") }}
- </th>
- <th width="20%" class="text-right">
- {{ _("Quantity") }}
- </th>
- <th width="20%" class="text-right">
- {{ _("Amount") }}
- </th>
- </thead>
- <tbody>
- {% for d in doc.items %}
- <tr class="order-items">
- <td>
- {{ item_name_and_description(d) }}
- </td>
- <td class="text-right">
- {{ d.qty }}
- {% if d.delivered_qty is defined and d.delivered_qty != None %}
- <p class="text-muted small">{{ _("Delivered") }} {{ d.delivered_qty }}</p>
+ <div class="row transaction-subheading">
+ <div class="col-6">
+ <span class="font-md indicator-pill {{ doc.indicator_color or ("blue" if doc.docstatus==1 else "darkgrey") }}">
+ {% if doc.doctype == "Quotation" and not doc.docstatus %}
+ {{ _("Pending") }}
+ {% else %}
+ {{ _(doc.get('indicator_title')) or _(doc.status) or _("Submitted") }}
{% endif %}
- </td>
- <td class="text-right">
- {{ d.get_formatted("amount") }}
- <p class="text-muted small">{{ _("Rate:") }} {{ d.get_formatted("rate") }}</p>
- </td>
- </tr>
- {% endfor %}
- </tbody>
- </table>
- <!-- taxes -->
- <div class="order-taxes d-flex justify-content-end">
- <table>
- {% include "erpnext/templates/includes/order/order_taxes.html" %}
- </table>
- </div>
-</div>
-
-{% if enabled_checkout and ((doc.doctype=="Sales Order" and doc.per_billed <= 0)
- or (doc.doctype=="Sales Invoice" and doc.outstanding_amount > 0)) %}
-
-<div class="panel panel-default">
- <div class="panel-heading">
- <div class="row">
- <div class="form-column col-sm-6 address-title">
- <strong>Payment</strong>
- </div>
+ </span>
</div>
- </div>
- <div class="panel-collapse">
- <div class="panel-body text-muted small">
- <div class="row">
- <div class="form-column col-sm-6">
- {% if available_loyalty_points %}
- <div class="form-group">
- <div class="h6">Enter Loyalty Points</div>
- <div class="control-input-wrapper">
- <div class="control-input">
- <input class="form-control" type="number" min="0" max="{{ available_loyalty_points }}" id="loyalty-point-to-redeem">
- </div>
- <p class="help-box small text-muted d-none d-sm-block"> Available Points: {{ available_loyalty_points }} </p>
- </div>
- </div>
- {% endif %}
- </div>
-
- <div class="form-column col-sm-6">
- <div id="loyalty-points-status" style="text-align: right"></div>
- <div class="page-header-actions-block" data-html-block="header-actions">
- <p>
- <a href="/api/method/erpnext.accounts.doctype.payment_request.payment_request.make_payment_request?dn={{ doc.name }}&dt={{ doc.doctype }}&submit_doc=1&order_type=Shopping Cart"
- class="btn btn-primary btn-sm" id="pay-for-order">{{ _("Pay") }} {{ doc.get_formatted("grand_total") }} </a>
- </p>
- </div>
- </div>
-
- </div>
-
- </div>
- </div>
-</div>
-{% endif %}
-
-
-{% if attachments %}
-<div class="order-item-table">
- <div class="row order-items order-item-header text-muted">
- <div class="col-sm-12 h6 text-uppercase">
- {{ _("Attachments") }}
- </div>
- </div>
- <div class="row order-items">
- <div class="col-sm-12">
- {% for attachment in attachments %}
- <p class="small">
- <a href="{{ attachment.file_url }}" target="blank"> {{ attachment.file_name }} </a>
+ <div class="col-6 text-muted text-right small pt-3">
+ {{ frappe.utils.format_date(doc.transaction_date, 'medium') }}
+ {% if doc.valid_till %}
+ <p>
+ {{ _("Valid Till") }}: {{ frappe.utils.format_date(doc.valid_till, 'medium') }}
</p>
- {% endfor %}
+ {% endif %}
</div>
</div>
-</div>
-{% endif %}
-</div>
-{% if doc.terms %}
-<div class="terms-and-condition text-muted small">
- <hr><p>{{ doc.terms }}</p>
-</div>
-{% endif %}
+
+ <p class="small my-3">
+ {%- set party_name = doc.supplier_name if doc.doctype in ['Supplier Quotation', 'Purchase Invoice', 'Purchase Order'] else doc.customer_name %}
+ <b>{{ party_name }}</b>
+
+ {% if doc.contact_display and doc.contact_display != party_name %}
+ <br>
+ {{ doc.contact_display }}
+ {% endif %}
+ </p>
+
+ {% if doc._header %}
+ {{ doc._header }}
+ {% endif %}
+
+ <div class="order-container">
+ <!-- items -->
+ <table class="order-item-table w-100 table">
+ <thead class="order-items order-item-header">
+ <th width="60%">
+ {{ _("Item") }}
+ </th>
+ <th width="20%" class="text-right">
+ {{ _("Quantity") }}
+ </th>
+ <th width="20%" class="text-right">
+ {{ _("Amount") }}
+ </th>
+ </thead>
+ <tbody>
+ {% for d in doc.items %}
+ <tr class="order-items">
+ <td>
+ {{ item_name_and_description(d) }}
+ </td>
+ <td class="text-right">
+ {{ d.qty }}
+ {% if d.delivered_qty is defined and d.delivered_qty != None %}
+ <p class="text-muted small">{{ _("Delivered") }} {{ d.delivered_qty }}</p>
+ {% endif %}
+ </td>
+ <td class="text-right">
+ {{ d.get_formatted("amount") }}
+ <p class="text-muted small">{{ _("Rate:") }} {{ d.get_formatted("rate") }}</p>
+ </td>
+ </tr>
+ {% endfor %}
+ </tbody>
+ </table>
+ <!-- taxes -->
+ <div class="order-taxes d-flex justify-content-end">
+ <table>
+ {% include "erpnext/templates/includes/order/order_taxes.html" %}
+ </table>
+ </div>
+ </div>
+
+ {% if enabled_checkout and ((doc.doctype=="Sales Order" and doc.per_billed <= 0)
+ or (doc.doctype=="Sales Invoice" and doc.outstanding_amount > 0)) %}
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ <div class="row">
+ <div class="form-column col-sm-6 address-title">
+ <strong>Payment</strong>
+ </div>
+ </div>
+ </div>
+ <div class="panel-collapse">
+ <div class="panel-body text-muted small">
+ <div class="row">
+ <div class="form-column col-sm-6">
+ {% if available_loyalty_points %}
+ <div class="form-group">
+ <div class="h6">Enter Loyalty Points</div>
+ <div class="control-input-wrapper">
+ <div class="control-input">
+ <input class="form-control" type="number" min="0" max="{{ available_loyalty_points }}" id="loyalty-point-to-redeem">
+ </div>
+ <p class="help-box small text-muted d-none d-sm-block"> Available Points: {{ available_loyalty_points }} </p>
+ </div>
+ </div>
+ {% endif %}
+ </div>
+
+ <div class="form-column col-sm-6">
+ <div id="loyalty-points-status" style="text-align: right"></div>
+ <div class="page-header-actions-block" data-html-block="header-actions">
+ <p class="mt-2" style="float: right;">
+ <a href="/api/method/erpnext.accounts.doctype.payment_request.payment_request.make_payment_request?dn={{ doc.name }}&dt={{ doc.doctype }}&submit_doc=1&order_type=Shopping Cart"
+ class="btn btn-primary btn-sm"
+ id="pay-for-order">
+ {{ _("Pay") }} {{ doc.get_formatted("grand_total") }}
+ </a>
+ </p>
+ </div>
+ </div>
+
+ </div>
+
+ </div>
+ </div>
+ </div>
+ {% endif %}
+
+
+ {% if attachments %}
+ <div class="order-item-table">
+ <div class="row order-items order-item-header text-muted">
+ <div class="col-sm-12 h6 text-uppercase">
+ {{ _("Attachments") }}
+ </div>
+ </div>
+ <div class="row order-items">
+ <div class="col-sm-12">
+ {% for attachment in attachments %}
+ <p class="small">
+ <a href="{{ attachment.file_url }}" target="blank"> {{ attachment.file_name }} </a>
+ </p>
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+ {% endif %}
+ </div>
+
+ {% if doc.terms %}
+ <div class="terms-and-condition text-muted small">
+ <hr><p>{{ doc.terms }}</p>
+ </div>
+ {% endif %}
{% endblock %}
{% block script %}
diff --git a/erpnext/templates/pages/order.py b/erpnext/templates/pages/order.py
index 2aa0f9c..712b141 100644
--- a/erpnext/templates/pages/order.py
+++ b/erpnext/templates/pages/order.py
@@ -1,13 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
import frappe
from frappe import _
-from erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings import (
- show_attachments,
-)
+from erpnext.e_commerce.doctype.e_commerce_settings.e_commerce_settings import show_attachments
def get_context(context):
@@ -25,7 +22,7 @@
context.payment_ref = frappe.db.get_value("Payment Request",
{"reference_name": frappe.form_dict.name}, "name")
- context.enabled_checkout = frappe.get_doc("Shopping Cart Settings").enable_checkout
+ context.enabled_checkout = frappe.get_doc("E Commerce Settings").enable_checkout
default_print_format = frappe.db.get_value('Property Setter', dict(property='default_print_format', doc_type=frappe.form_dict.doctype), "value")
if default_print_format:
diff --git a/erpnext/templates/pages/product_search.py b/erpnext/templates/pages/product_search.py
index 5aa1f1e..237adf9 100644
--- a/erpnext/templates/pages/product_search.py
+++ b/erpnext/templates/pages/product_search.py
@@ -1,12 +1,19 @@
-# 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 frappe.utils import cint, cstr, nowdate
+from frappe.utils import cint, cstr
+from redisearch import AutoCompleter, Client, Query
+from erpnext.e_commerce.redisearch_utils import (
+ WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE,
+ WEBSITE_ITEM_INDEX,
+ WEBSITE_ITEM_NAME_AUTOCOMPLETE,
+ is_search_module_loaded,
+ make_key,
+)
+from erpnext.e_commerce.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 erpnext.shopping_cart.product_info import set_product_info_for_website
no_cache = 1
@@ -15,36 +22,116 @@
@frappe.whitelist(allow_guest=True)
def get_product_list(search=None, start=0, limit=12):
- # limit = 12 because we show 12 items in the grid view
-
- # 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)"""
-
- # search term condition
- if search:
- query += """ and (I.web_long_description like %(search)s
- or I.description like %(search)s
- or I.item_name like %(search)s
- or I.name like %(search)s)"""
- search = "%" + cstr(search) + "%"
-
- # order by
- query += """ order by I.weightage desc, in_stock desc, I.modified desc limit %s, %s""" % (cint(start), cint(limit))
-
- data = frappe.db.sql(query, {
- "search": search,
- "today": nowdate()
- }, as_dict=1)
+ data = get_product_data(search, start, limit)
for item in data:
set_product_info_for_website(item)
return [get_item_for_list_in_html(r) for r in data]
+
+def get_product_data(search=None, start=0, limit=12):
+ # limit = 12 because we show 12 items in the grid view
+ # base query
+ query = """
+ SELECT
+ web_item_name, item_name, item_code, brand, route,
+ website_image, thumbnail, item_group,
+ description, web_long_description as website_description,
+ website_warehouse, ranking
+ FROM `tabWebsite Item`
+ WHERE published = 1
+ """
+
+ # search term condition
+ if search:
+ query += """ and (item_name like %(search)s
+ or web_item_name like %(search)s
+ or brand like %(search)s
+ or web_long_description like %(search)s)"""
+ search = "%" + cstr(search) + "%"
+
+ # order by
+ query += """ ORDER BY ranking desc, modified desc limit %s, %s""" % (cint(start), cint(limit))
+
+ return frappe.db.sql(query, {"search": search}, as_dict=1) # nosemgrep
+
+@frappe.whitelist(allow_guest=True)
+def search(query):
+ product_results = product_search(query)
+ category_results = get_category_suggestions(query)
+
+ return {
+ "product_results": product_results.get("results") or [],
+ "category_results": category_results.get("results") or []
+ }
+
+@frappe.whitelist(allow_guest=True)
+def product_search(query, limit=10, fuzzy_search=True):
+ search_results = {"from_redisearch": True, "results": []}
+
+ if not is_search_module_loaded():
+ # Redisearch module not loaded
+ search_results["from_redisearch"] = False
+ search_results["results"] = get_product_data(query, 0, limit)
+ return search_results
+
+ if not query:
+ return search_results
+
+ red = frappe.cache()
+ query = clean_up_query(query)
+
+ ac = AutoCompleter(make_key(WEBSITE_ITEM_NAME_AUTOCOMPLETE), conn=red)
+ client = Client(make_key(WEBSITE_ITEM_INDEX), conn=red)
+ suggestions = ac.get_suggestions(
+ query,
+ num=limit,
+ fuzzy= fuzzy_search and len(query) > 3 # Fuzzy on length < 3 can be real slow
+ )
+
+ # Build a query
+ query_string = query
+
+ for s in suggestions:
+ query_string += f"|('{clean_up_query(s.string)}')"
+
+ q = Query(query_string)
+
+ results = client.search(q)
+ search_results['results'] = list(map(convert_to_dict, results.docs))
+ search_results['results'] = sorted(search_results['results'], key=lambda k: frappe.utils.cint(k['ranking']), reverse=True)
+
+ return search_results
+
+def clean_up_query(query):
+ return ''.join(c for c in query if c.isalnum() or c.isspace())
+
+def convert_to_dict(redis_search_doc):
+ return redis_search_doc.__dict__
+
+@frappe.whitelist(allow_guest=True)
+def get_category_suggestions(query):
+ search_results = {"results": []}
+
+ if not is_search_module_loaded():
+ # Redisearch module not loaded, query db
+ categories = frappe.db.get_all(
+ "Item Group",
+ filters={
+ "name": ["like", "%{0}%".format(query)],
+ "show_in_website": 1
+ },
+ fields=["name", "route"]
+ )
+ search_results['results'] = categories
+ return search_results
+
+ if not query:
+ return search_results
+
+ ac = AutoCompleter(make_key(WEBSITE_ITEM_CATEGORY_AUTOCOMPLETE), conn=frappe.cache())
+ suggestions = ac.get_suggestions(query, num=10)
+
+ search_results['results'] = [s.string for s in suggestions]
+
+ return search_results
\ No newline at end of file
diff --git a/erpnext/templates/pages/wishlist.html b/erpnext/templates/pages/wishlist.html
new file mode 100644
index 0000000..7a81ded
--- /dev/null
+++ b/erpnext/templates/pages/wishlist.html
@@ -0,0 +1,28 @@
+{% extends "templates/web.html" %}
+
+{% block title %} {{ _("Wishlist") }} {% endblock %}
+
+{% block header %}<h3 class="shopping-cart-header mt-2 mb-6">{{ _("Wishlist") }}</h1>{% endblock %}
+
+{% block page_content %}
+{% if items %}
+ <div class="row">
+ <div class="col-md-12 item-card-group-section">
+ <div class="row products-list">
+ {% from "erpnext/templates/includes/macros.html" import wishlist_card %}
+ {% for item in items %}
+ {{ wishlist_card(item, settings) }}
+ {% endfor %}
+ </div>
+ </div>
+ </div>
+{% else %}
+ <div class="cart-empty frappe-card">
+ <div class="cart-empty-state">
+ <img src="/assets/erpnext/images/ui-states/cart-empty-state.png" alt="Empty Cart">
+ </div>
+ <div class="cart-empty-message mt-4">{{ _('Wishlist is empty!') }}</p>
+ </div>
+{% endif %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/erpnext/templates/pages/wishlist.py b/erpnext/templates/pages/wishlist.py
new file mode 100644
index 0000000..72ee34e
--- /dev/null
+++ b/erpnext/templates/pages/wishlist.py
@@ -0,0 +1,71 @@
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
+# License: GNU General Public License v3. See license.txt
+import frappe
+
+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.utilities.product import get_price
+
+
+def get_context(context):
+ is_guest = frappe.session.user == "Guest"
+
+ settings = get_shopping_cart_settings()
+ items = get_wishlist_items() if not is_guest else []
+ selling_price_list = _set_price_list(settings) if not is_guest else None
+
+ items = set_stock_price_details(items, settings, selling_price_list)
+
+ context.body_class = "product-page"
+ context.items = items
+ context.settings = settings
+ context.no_cache = 1
+
+def get_stock_availability(item_code, warehouse):
+ stock_qty = frappe.utils.flt(
+ frappe.db.get_value("Bin",
+ {
+ "item_code": item_code,
+ "warehouse": warehouse
+ },
+ "actual_qty")
+ )
+ return bool(stock_qty)
+
+def get_wishlist_items():
+ if not frappe.db.exists("Wishlist", frappe.session.user):
+ return []
+
+ return frappe.db.get_all(
+ "Wishlist Item",
+ filters={
+ "parent": frappe.session.user
+ },
+ fields=[
+ "web_item_name", "item_code", "item_name",
+ "website_item", "warehouse",
+ "image", "item_group", "route"
+ ])
+
+def set_stock_price_details(items, settings, selling_price_list):
+ for item in items:
+ if settings.show_stock_availability:
+ item.available = get_stock_availability(item.item_code, item.get("warehouse"))
+
+ price_details = get_price(
+ item.item_code,
+ selling_price_list,
+ settings.default_customer_group,
+ settings.company
+ )
+
+ if price_details:
+ item.formatted_price = price_details.get('formatted_price')
+ item.formatted_mrp = price_details.get('formatted_mrp')
+ if item.formatted_mrp:
+ item.discount = price_details.get('formatted_discount_percent') or \
+ price_details.get('formatted_discount_rate')
+
+ return items
\ No newline at end of file
diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py
new file mode 100644
index 0000000..df2dc8b
--- /dev/null
+++ b/erpnext/tests/test_point_of_sale.py
@@ -0,0 +1,53 @@
+# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
+# MIT License. See license.txt
+
+
+from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
+from erpnext.selling.page.point_of_sale.point_of_sale import get_items
+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.tests.utils import ERPNextTestCase
+
+
+class TestPointOfSale(ERPNextTestCase):
+ def test_item_search(self):
+ """
+ Test Stock and Service Item Search.
+ """
+
+ pos_profile = make_pos_profile()
+ item1 = make_item("Test Search Stock Item", {"is_stock_item": 1})
+ make_stock_entry(
+ item_code="Test Search Stock Item",
+ qty=10,
+ to_warehouse="_Test Warehouse - _TC",
+ rate=500,
+ )
+
+ result = get_items(
+ start=0,
+ page_length=20,
+ price_list=None,
+ item_group=item1.item_group,
+ pos_profile=pos_profile.name,
+ search_term="Test Search Stock Item",
+ )
+ filtered_items = result.get("items")
+
+ self.assertEqual(len(filtered_items), 1)
+ self.assertEqual(filtered_items[0]["item_code"], item1.item_code)
+ self.assertEqual(filtered_items[0]["actual_qty"], 10)
+
+ item2 = make_item("Test Search Service Item", {"is_stock_item": 0})
+ result = get_items(
+ start=0,
+ page_length=20,
+ price_list=None,
+ item_group=item2.item_group,
+ pos_profile=pos_profile.name,
+ search_term="Test Search Service Item",
+ )
+ filtered_items = result.get("items")
+
+ self.assertEqual(len(filtered_items), 1)
+ self.assertEqual(filtered_items[0]["item_code"], item2.item_code)
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index 0aca1a0..4a6c834 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -783,6 +783,7 @@
Default BOM ({0}) must be active for this item or its template,Standardstückliste ({0}) muss für diesen Artikel oder dessen Vorlage aktiv sein,
Default BOM for {0} not found,Standardstückliste für {0} nicht gefunden,
Default BOM not found for Item {0} and Project {1},Standard-Stückliste nicht gefunden für Position {0} und Projekt {1},
+Default In-Transit Warehouse, Standardlager für Waren im Transit,
Default Letter Head,Standardbriefkopf,
Default Tax Template,Standardsteuervorlage,
Default Unit of Measure for Item {0} cannot be changed directly because you have already made some transaction(s) with another UOM. You will need to create a new Item to use a different Default UOM.,"Die Standard-Maßeinheit für Artikel {0} kann nicht direkt geändert werden, weil Sie bereits einige Transaktionen mit einer anderen Maßeinheit durchgeführt haben. Sie müssen einen neuen Artikel erstellen, um eine andere Standard-Maßeinheit verwenden zukönnen.",
@@ -1054,6 +1055,7 @@
Fiscal Year {0} not found,Das Geschäftsjahr {0} nicht gefunden,
Fixed Asset,Anlagevermögen,
Fixed Asset Item must be a non-stock item.,Posten des Anlagevermögens muss ein Nichtlagerposition sein.,
+Fixed Asset Defaults, Standards für Anlagevermögen,
Fixed Assets,Anlagevermögen,
Following Material Requests have been raised automatically based on Item's re-order level,Folgende Materialanfragen wurden automatisch auf der Grundlage der Nachbestellmenge des Artikels generiert,
Following accounts might be selected in GST Settings:,In den GST-Einstellungen können folgende Konten ausgewählt werden:,
@@ -2352,6 +2354,7 @@
Reopen,Wieder öffnen,
Reorder Level,Meldebestand,
Reorder Qty,Nachbestellmenge,
+Repair and Maintenance Account, Konto für Reparatur/Instandhaltung von Anlagen und Maschinen,
Repeat Customer Revenue,Umsatz Bestandskunden,
Repeat Customers,Bestandskunden,
Replace BOM and update latest price in all BOMs,Ersetzen Sie die Stückliste und aktualisieren Sie den aktuellen Preis in allen Stücklisten,
@@ -3796,7 +3799,7 @@
Invalid Barcode. There is no Item attached to this barcode.,Ungültiger Barcode. Es ist kein Artikel an diesen Barcode angehängt.,
Invalid credentials,Ungültige Anmeldeinformationen,
Invite as User,Als Benutzer einladen,
-Issue Priority.,Ausgabepriorität.,
+Issue Priority.,Anfragepriorität.,
Issue Type.,Problemtyp.,
"It seems that there is an issue with the server's stripe configuration. In case of failure, the amount will get refunded to your account.","Es scheint, dass ein Problem mit der Stripe-Konfiguration des Servers vorliegt. Im Falle eines Fehlers wird der Betrag Ihrem Konto gutgeschrieben.",
Item Reported,Gegenstand gemeldet,
@@ -4857,6 +4860,7 @@
Allocated,Zugewiesen,
Payment Gateway Account,Payment Gateway Konto,
Payment Account,Zahlungskonto,
+Default Payment Discount Account, Standard Rabattkonto für Zahlungen,
Default Payment Request Message,Standard Payment Request Message,
PMO-,PMO-,
Payment Order Type,Zahlungsauftragsart,
@@ -7789,7 +7793,8 @@
Discount Allowed Account,Rabatt erlaubtes Konto,
Discount Received Account,Discount Received Account,
Exchange Gain / Loss Account,Konto für Wechselkursdifferenzen,
-Unrealized Exchange Gain/Loss Account,Konto für unrealisierte Wechselkurs-Gewinne / -Verluste,
+Unrealized Exchange Gain/Loss Account,Konto für nicht realisierte Wechselkurs-Gewinne/ -Verluste,
+Unrealized Profit / Loss Account, Konto für nicht realisierten Gewinn/Verlust,
Allow Account Creation Against Child Company,Kontoerstellung für untergeordnete Unternehmen zulassen,
Default Payable Account,Standard-Verbindlichkeitenkonto,
Default Employee Advance Account,Standardkonto für Vorschüsse an Arbeitnehmer,
diff --git a/erpnext/utilities/product.py b/erpnext/utilities/product.py
index e9e4baa..0a45002 100644
--- a/erpnext/utilities/product.py
+++ b/erpnext/utilities/product.py
@@ -1,7 +1,6 @@
-# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
+# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
-
import frappe
from frappe.utils import cint, flt, fmt_money, getdate, nowdate
@@ -9,15 +8,15 @@
from erpnext.stock.doctype.batch.batch import get_batch_qty
-def get_qty_in_stock(item_code, item_warehouse_field, warehouse=None):
+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("Item", item_code, item_warehouse_field)
+ 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("Item", template_item_code, item_warehouse_field)
+ warehouse = frappe.db.get_value("Website Item", {"item_code": template_item_code}, item_warehouse_field)
if warehouse:
stock_qty = frappe.db.sql("""
@@ -69,6 +68,8 @@
return qty
def get_price(item_code, price_list, customer_group, company, qty=1):
+ from erpnext.e_commerce.shopping_cart.cart import get_party
+
template_item_code = frappe.db.get_value("Item", item_code, "variant_of")
if price_list:
@@ -80,7 +81,8 @@
filters={"price_list": price_list, "item_code": template_item_code})
if price:
- pricing_rule = get_pricing_rule_for_item(frappe._dict({
+ party = get_party()
+ pricing_rule_dict = frappe._dict({
"item_code": item_code,
"qty": qty,
"stock_qty": qty,
@@ -91,18 +93,33 @@
"conversion_rate": 1,
"for_shopping_cart": True,
"currency": frappe.db.get_value("Price List", price_list, "currency")
- }))
+ })
+
+ if party and party.doctype == "Customer":
+ pricing_rule_dict.update({"customer": party.name})
+
+ pricing_rule = get_pricing_rule_for_item(pricing_rule_dict)
+ price_obj = price[0]
if pricing_rule:
+ # price without any rules applied
+ mrp = price_obj.price_list_rate or 0
+
if pricing_rule.pricing_rule_for == "Discount Percentage":
- price[0].price_list_rate = flt(price[0].price_list_rate * (1.0 - (flt(pricing_rule.discount_percentage) / 100.0)))
+ price_obj.discount_percent = pricing_rule.discount_percentage
+ price_obj.formatted_discount_percent = str(flt(pricing_rule.discount_percentage, 0)) + "%"
+ price_obj.price_list_rate = flt(price_obj.price_list_rate * (1.0 - (flt(pricing_rule.discount_percentage) / 100.0)))
if pricing_rule.pricing_rule_for == "Rate":
- price[0].price_list_rate = pricing_rule.price_list_rate
+ rate_discount = flt(mrp) - flt(pricing_rule.price_list_rate)
+ if rate_discount > 0:
+ price_obj.formatted_discount_rate = fmt_money(rate_discount, currency=price_obj["currency"])
+ price_obj.price_list_rate = pricing_rule.price_list_rate or 0
- price_obj = price[0]
if price_obj:
price_obj["formatted_price"] = fmt_money(price_obj["price_list_rate"], currency=price_obj["currency"])
+ if mrp != price_obj["price_list_rate"]:
+ price_obj["formatted_mrp"] = fmt_money(mrp, currency=price_obj["currency"])
price_obj["currency_symbol"] = not cint(frappe.db.get_default("hide_currency_symbol")) \
and (frappe.db.get_value("Currency", price_obj.currency, "symbol", cache=True) or price_obj.currency) \
@@ -123,15 +140,15 @@
price_obj["currency"] = ""
if not price_obj["formatted_price"]:
- price_obj["formatted_price"] = ""
+ price_obj["formatted_price"], price_obj["formatted_mrp"] = "", ""
return price_obj
def get_non_stock_item_status(item_code, item_warehouse_field):
- #if item belongs to product bundle, check if bundle items are in stock
+ # 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('Item', item_code, item_warehouse_field)
- return all(get_qty_in_stock(d.item_code, item_warehouse_field, bundle_warehouse).in_stock for d in items)
+ 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
diff --git a/erpnext/www/all-products/index.html b/erpnext/www/all-products/index.html
index a7838ee..3d5517c 100644
--- a/erpnext/www/all-products/index.html
+++ b/erpnext/www/all-products/index.html
@@ -1,120 +1,34 @@
+{% from "erpnext/templates/includes/macros.html" import attribute_filter_section, field_filter_section, discount_range_filters %}
{% extends "templates/web.html" %}
-{% block title %}{{ _('Products') }}{% endblock %}
+
+{% block title %}{{ _('All Products') }}{% endblock %}
{% block header %}
-<div class="mb-6">{{ _('Products') }}</div>
+<div class="mb-6">{{ _('All Products') }}</div>
{% endblock header %}
{% block page_content %}
-<div class="row" style="display: none;">
- <div class="col-8">
- <div class="input-group input-group-sm mb-3">
- <input type="search" class="form-control" placeholder="{{_('Search')}}"
- aria-label="{{_('Product Search')}}" aria-describedby="product-search"
- value="{{ frappe.sanitize_html(frappe.form_dict.search) or '' }}"
- >
- </div>
- </div>
-
- <div class="col-4 pl-0">
- <button class="btn btn-light btn-sm btn-block d-md-none"
- type="button"
- data-toggle="collapse"
- data-target="#product-filters"
- aria-expanded="false"
- aria-controls="product-filters"
- style="white-space: nowrap;"
- >
- {{ _('Toggle Filters') }}
- </button>
- </div>
-</div>
-
<div class="row">
- <div class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
- <div class="row products-list">
- {% 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 %}
- </div>
+ <!-- Items section -->
+ <div id="product-listing" class="col-12 order-2 col-md-9 order-md-2 item-card-group-section">
+ <!-- Rendered via JS -->
</div>
+
+ <!-- Filters Section -->
<div class="col-12 order-1 col-md-3 order-md-1">
-
- {% if frappe.form_dict.start or frappe.form_dict.field_filters or frappe.form_dict.attribute_filters or frappe.form_dict.search %}
-
-
- {% endif %}
-
<div class="collapse d-md-block mr-4 filters-section" id="product-filters">
<div class="d-flex justify-content-between align-items-center mb-5 title-section">
<div class="mb-4 filters-title" > {{ _('Filters') }} </div>
<a class="mb-4 clear-filters" href="/all-products">{{ _('Clear All') }}</a>
</div>
- {% for field_filter in field_filters %}
- {%- set item_field = field_filter[0] %}
- {%- set values = field_filter[1] %}
- <div class="mb-4 filter-block pb-5">
- <div class="filter-label mb-3">{{ item_field.label }}</div>
+ <!-- field filters -->
+ {% if field_filters %}
+ {{ field_filter_section(field_filters) }}
+ {% endif %}
- {% if values | len > 20 %}
- <!-- show inline filter if values more than 20 -->
- <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
- {% endif %}
-
- {% if values %}
- <div class="filter-options">
- {% for value in values %}
- <div class="checkbox" data-value="{{ value }}">
- <label for="{{value}}">
- <input type="checkbox"
- class="product-filter field-filter"
- id="{{value}}"
- data-filter-name="{{ item_field.fieldname }}"
- data-filter-value="{{ value }}"
- >
- <span class="label-area">{{ value }}</span>
- </label>
- </div>
- {% endfor %}
- </div>
- {% else %}
- <i class="text-muted">{{ _('No values') }}</i>
- {% endif %}
- </div>
- {% endfor %}
-
- {% for attribute in attribute_filters %}
- <div class="mb-4 filter-block pb-5">
- <div class="filter-label mb-3">{{ attribute.name}}</div>
- {% if values | len > 20 %}
- <!-- show inline filter if values more than 20 -->
- <input type="text" class="form-control form-control-sm mb-2 product-filter-filter"/>
- {% endif %}
-
- {% if attribute.item_attribute_values %}
- <div class="filter-options">
- {% for attr_value in attribute.item_attribute_values %}
- <div class="checkbox">
- <label>
- <input type="checkbox"
- class="product-filter attribute-filter"
- id="{{attr_value}}"
- data-attribute-name="{{ attribute.name }}"
- data-attribute-value="{{ attr_value }}"
- {% if attr_value.checked %} checked {% endif %}>
- <span class="label-area">{{ attr_value }}</span>
- </label>
- </div>
- {% endfor %}
- </div>
- {% else %}
- <i class="text-muted">{{ _('No values') }}</i>
- {% endif %}
- </div>
- {% endfor %}
+ <!-- attribute filters -->
+ {% if attribute_filters %}
+ {{ attribute_filter_section(attribute_filters) }}
+ {% endif %}
</div>
<script>
@@ -137,18 +51,6 @@
</script>
</div>
</div>
-<div class="row product-paging-area mt-5">
- <div class="col-3">
- </div>
- <div class="col-9 text-right">
- {% if frappe.form_dict.start|int > 0 %}
- <button class="btn btn-default btn-prev" data-start="{{ frappe.form_dict.start|int - page_length }}">{{ _("Prev") }}</button>
- {% endif %}
- {% if items|length >= page_length %}
- <button class="btn btn-default btn-next" data-start="{{ frappe.form_dict.start|int + page_length }}">{{ _("Next") }}</button>
- {% endif %}
- </div>
-</div>
<script>
frappe.ready(() => {
diff --git a/erpnext/www/all-products/index.js b/erpnext/www/all-products/index.js
index 1c641b5..98a8441 100644
--- a/erpnext/www/all-products/index.js
+++ b/erpnext/www/all-products/index.js
@@ -1,165 +1,27 @@
$(() => {
class ProductListing {
constructor() {
- this.bind_filters();
- this.bind_search();
- this.restore_filters_state();
- }
+ let me = this;
+ let is_item_group_page = $(".item-group-content").data("item-group");
+ this.item_group = is_item_group_page || null;
- bind_filters() {
- this.field_filters = {};
- this.attribute_filters = {};
+ let view_type = localStorage.getItem("product_view") || "List View";
- $('.product-filter').on('change', frappe.utils.debounce((e) => {
- const $checkbox = $(e.target);
- const is_checked = $checkbox.is(':checked');
-
- if ($checkbox.is('.attribute-filter')) {
- const {
- attributeName: attribute_name,
- attributeValue: attribute_value
- } = $checkbox.data();
-
- if (is_checked) {
- this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || [];
- this.attribute_filters[attribute_name].push(attribute_value);
- } else {
- this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name] || [];
- this.attribute_filters[attribute_name] = this.attribute_filters[attribute_name].filter(v => v !== attribute_value);
- }
-
- if (this.attribute_filters[attribute_name].length === 0) {
- delete this.attribute_filters[attribute_name];
- }
- } else if ($checkbox.is('.field-filter')) {
- const {
- filterName: filter_name,
- filterValue: filter_value
- } = $checkbox.data();
-
- if (is_checked) {
- this.field_filters[filter_name] = this.field_filters[filter_name] || [];
- this.field_filters[filter_name].push(filter_value);
- } else {
- this.field_filters[filter_name] = this.field_filters[filter_name] || [];
- this.field_filters[filter_name] = this.field_filters[filter_name].filter(v => v !== filter_value);
- }
-
- if (this.field_filters[filter_name].length === 0) {
- delete this.field_filters[filter_name];
- }
- }
-
- const query_string = get_query_string({
- field_filters: JSON.stringify(if_key_exists(this.field_filters)),
- attribute_filters: JSON.stringify(if_key_exists(this.attribute_filters)),
- });
- window.history.pushState('filters', '', `${location.pathname}?` + query_string);
-
- $('.page_content input').prop('disabled', true);
- this.get_items_with_filters()
- .then(html => {
- $('.products-list').html(html);
- })
- .then(data => {
- $('.page_content input').prop('disabled', false);
- return data;
- })
- .catch(() => {
- $('.page_content input').prop('disabled', false);
- });
- }, 1000));
- }
-
- make_filters() {
-
- }
-
- bind_search() {
- $('input[type=search]').on('keydown', (e) => {
- if (e.keyCode === 13) {
- // Enter
- const value = e.target.value;
- if (value) {
- window.location.search = 'search=' + e.target.value;
- } else {
- window.location.search = '';
- }
- }
+ // Render Product Views, Filters & Search
+ new erpnext.ProductView({
+ view_type: view_type,
+ products_section: $('#product-listing'),
+ item_group: me.item_group
});
+
+ this.bind_card_actions();
}
- restore_filters_state() {
- const filters = frappe.utils.get_query_params();
- let {field_filters, attribute_filters} = filters;
-
- if (field_filters) {
- field_filters = JSON.parse(field_filters);
- for (let fieldname in field_filters) {
- const values = field_filters[fieldname];
- const selector = values.map(value => {
- return `input[data-filter-name="${fieldname}"][data-filter-value="${value}"]`;
- }).join(',');
- $(selector).prop('checked', true);
- }
- this.field_filters = field_filters;
- }
- if (attribute_filters) {
- attribute_filters = JSON.parse(attribute_filters);
- for (let attribute in attribute_filters) {
- const values = attribute_filters[attribute];
- const selector = values.map(value => {
- return `input[data-attribute-name="${attribute}"][data-attribute-value="${value}"]`;
- }).join(',');
- $(selector).prop('checked', true);
- }
- this.attribute_filters = attribute_filters;
- }
- }
-
- get_items_with_filters() {
- const { attribute_filters, field_filters } = this;
- const args = {
- field_filters: if_key_exists(field_filters),
- attribute_filters: if_key_exists(attribute_filters)
- };
-
- const item_group = $(".item-group-content").data('item-group');
- if (item_group) {
- Object.assign(field_filters, { item_group });
- }
- return new Promise((resolve, reject) => {
- frappe.call('erpnext.portal.product_configurator.utils.get_products_html_for_website', args)
- .then(r => {
- if (r.exc) reject(r.exc);
- else resolve(r.message);
- })
- .fail(reject);
- });
+ bind_card_actions() {
+ erpnext.e_commerce.shopping_cart.bind_add_to_cart_action();
+ erpnext.e_commerce.wishlist.bind_wishlist_action();
}
}
new ProductListing();
-
- function 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();
- }
-
- function if_key_exists(obj) {
- let exists = false;
- for (let key in obj) {
- if (obj.hasOwnProperty(key) && obj[key]) {
- exists = true;
- break;
- }
- }
- return exists ? obj : undefined;
- }
});
diff --git a/erpnext/www/all-products/index.py b/erpnext/www/all-products/index.py
index df5258b..ffaead6 100644
--- a/erpnext/www/all-products/index.py
+++ b/erpnext/www/all-products/index.py
@@ -1,36 +1,19 @@
import frappe
+from frappe.utils import cint
-from erpnext.portal.product_configurator.utils import get_product_settings
-from erpnext.shopping_cart.filters import ProductFiltersBuilder
-from erpnext.shopping_cart.product_query import ProductQuery
+from erpnext.e_commerce.product_data_engine.filters import ProductFiltersBuilder
sitemap = 1
def get_context(context):
-
- 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 = field_filters = attribute_filters = None
- start = 0
-
- engine = ProductQuery()
- context.items = engine.query(attribute_filters, field_filters, search, start)
-
# Add homepage as parent
+ context.body_class = "product-page"
context.parents = [{"name": frappe._("Home"), "route":"/"}]
- product_settings = get_product_settings()
filter_engine = ProductFiltersBuilder()
-
context.field_filters = filter_engine.get_field_filters()
context.attribute_filters = filter_engine.get_attribute_filters()
- context.product_settings = product_settings
- context.body_class = "product-page"
- context.page_length = product_settings.products_per_page or 20
+ context.page_length = cint(frappe.db.get_single_value('E Commerce Settings', 'products_per_page'))or 20
- context.no_cache = 1
+ context.no_cache = 1
\ No newline at end of file
diff --git a/erpnext/www/all-products/item_row.html b/erpnext/www/all-products/item_row.html
deleted file mode 100644
index a7e994c..0000000
--- a/erpnext/www/all-products/item_row.html
+++ /dev/null
@@ -1,6 +0,0 @@
-{% from "erpnext/templates/includes/macros.html" import item_card, item_card_body %}
-
-{{ item_card(
- item.item_name or item.name, item.website_image or item.image, item.route, item.website_description or item.description,
- item.formatted_price, item.item_group
-) }}
diff --git a/erpnext/portal/product_configurator/__init__.py b/erpnext/www/shop-by-category/__init__.py
similarity index 100%
copy from erpnext/portal/product_configurator/__init__.py
copy to erpnext/www/shop-by-category/__init__.py
diff --git a/erpnext/www/shop-by-category/category_card_section.html b/erpnext/www/shop-by-category/category_card_section.html
new file mode 100644
index 0000000..56cb63a
--- /dev/null
+++ b/erpnext/www/shop-by-category/category_card_section.html
@@ -0,0 +1,30 @@
+{%- macro card(title, image, type, url=None, text_primary=False) -%}
+<!-- style defined at shop-by-category index -->
+<div class="card category-card" data-type="{{ type }}" data-name="{{ title }}">
+ {% if image %}
+ <img class="card-img-top" src="{{ image }}" alt="{{ title }}" style="height: 80%;">
+ {% else %}
+ <div class="placeholder-div">
+ <span class="placeholder">
+ {{ frappe.utils.get_abbr(title) }}
+ </span>
+ </div>
+ {% endif %}
+ <div class="card-body text-center text-muted">
+ {{ title or '' }}
+ </div>
+ <a href="{{ url or '#' }}" class="stretched-link"></a>
+</div>
+{%- endmacro -%}
+
+<div class="col-12 item-card-group-section">
+ <div class="row products-list product-category-section">
+ {%- for row in data -%}
+ {%- set title = row.name -%}
+ {%- set image = row.get("image") -%}
+ {%- if title -%}
+ {{ card(title, image, type, row.get("route")) }}
+ {%- endif -%}
+ {%- endfor -%}
+ </div>
+</div>
\ No newline at end of file
diff --git a/erpnext/www/shop-by-category/index.html b/erpnext/www/shop-by-category/index.html
new file mode 100644
index 0000000..04d2d57
--- /dev/null
+++ b/erpnext/www/shop-by-category/index.html
@@ -0,0 +1,48 @@
+{% extends "templates/web.html" %}
+{% block title %}{{ _('Shop by Category') }}{% endblock %}
+
+{% block head_include %}
+<style>
+ .category-slideshow {
+ margin-bottom: 2rem;
+ }
+ .category-card {
+ height: 300px !important;
+ width: 300px !important;
+ margin: 30px !important;
+ }
+</style>
+{% endblock %}
+
+{% block script %}
+<script type="text/javascript" src="/shop-by-category/index.js"></script>
+{% endblock %}
+
+{% block page_content %}
+<div class="shop-by-category-content">
+ <div class="category-slideshow">
+ {% if slideshow %}
+ <!-- slideshow -->
+ {{ web_block(
+ "Hero Slider",
+ values=slideshow,
+ add_container=0,
+ add_top_padding=0,
+ add_bottom_padding=0,
+ ) }}
+ {% endif %}
+ </div>
+ <div class="category-tabs">
+ {% if tabs %}
+ <!-- tabs -->
+ {{ web_block(
+ "Section with Tabs",
+ values=tabs,
+ add_container=0,
+ add_top_padding=0,
+ add_bottom_padding=0
+ ) }}
+ {% endif %}
+ </div>
+</div>
+{% endblock %}
\ No newline at end of file
diff --git a/erpnext/www/shop-by-category/index.js b/erpnext/www/shop-by-category/index.js
new file mode 100644
index 0000000..1b3116f
--- /dev/null
+++ b/erpnext/www/shop-by-category/index.js
@@ -0,0 +1,12 @@
+$(() => {
+ $('.category-card').on('click', (e) => {
+ let category_type = e.currentTarget.dataset.type;
+ let category_name = e.currentTarget.dataset.name;
+
+ if (category_type != "item_group") {
+ let filters = {};
+ filters[category_type] = [category_name];
+ window.location.href = "/all-products?field_filters=" + JSON.stringify(filters);
+ }
+ });
+});
\ No newline at end of file
diff --git a/erpnext/www/shop-by-category/index.py b/erpnext/www/shop-by-category/index.py
new file mode 100644
index 0000000..3946212
--- /dev/null
+++ b/erpnext/www/shop-by-category/index.py
@@ -0,0 +1,77 @@
+import frappe
+from frappe import _
+
+sitemap = 1
+
+def get_context(context):
+ context.body_class = "product-page"
+
+ settings = frappe.get_cached_doc("E Commerce Settings")
+ context.categories_enabled = settings.enable_field_filters
+
+ if context.categories_enabled:
+ categories = [row.fieldname for row in settings.filter_fields]
+ context.tabs = get_tabs(categories)
+
+ if settings.slideshow:
+ context.slideshow = get_slideshow(settings.slideshow)
+
+ context.no_cache = 1
+
+def get_slideshow(slideshow):
+ values = {
+ 'show_indicators': 1,
+ 'show_controls': 1,
+ 'rounded': 1,
+ 'slider_name': "Categories"
+ }
+ slideshow = frappe.get_cached_doc("Website Slideshow", slideshow)
+ slides = slideshow.get({"doctype": "Website Slideshow Item"})
+ for index, slide in enumerate(slides, start=1):
+ values[f"slide_{index}_image"] = slide.image
+ values[f"slide_{index}_title"] = slide.heading
+ values[f"slide_{index}_subtitle"] = slide.description
+ values[f"slide_{index}_theme"] = slide.get("theme") or "Light"
+ values[f"slide_{index}_content_align"] = slide.get("content_align") or "Centre"
+ values[f"slide_{index}_primary_action"] = slide.url
+
+ return values
+
+def get_tabs(categories):
+ tab_values = {
+ 'title': _("Shop by Category"),
+ }
+
+ categorical_data = get_category_records(categories)
+ for index, tab in enumerate(categorical_data, start=1):
+ tab_values[f"tab_{index + 1}_title"] = frappe.unscrub(tab)
+ # pre-render cards for each tab
+ tab_values[f"tab_{index + 1}_content"] = frappe.render_template(
+ "erpnext/www/shop-by-category/category_card_section.html",
+ {"data": categorical_data[tab], "type": tab}
+ )
+ return tab_values
+
+def get_category_records(categories):
+ categorical_data = {}
+ for category in categories:
+ if category == "item_group":
+ categorical_data["item_group"] = frappe.db.get_all(
+ "Item Group",
+ filters={
+ "parent_item_group": "All Item Groups",
+ "show_in_website": 1
+ },
+ fields=["name", "parent_item_group", "is_group", "image", "route"],
+ as_dict=True
+ )
+ else:
+ doctype = frappe.unscrub(category)
+ fields = ["name"]
+ if frappe.get_meta(doctype, cached=True).get_field("image"):
+ fields += ["image"]
+
+ categorical_data[category] = frappe.db.get_all(doctype, fields=fields, as_dict=True)
+
+ return categorical_data
+
diff --git a/requirements.txt b/requirements.txt
index f447fac..39591ca 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -10,3 +10,4 @@
taxjar~=1.9.2
tweepy~=3.10.0
Unidecode~=1.2.0
+redisearch==2.0.0
\ No newline at end of file