Merge branch 'develop' into product-bundle-packing-list-logic

This commit is contained in:
Marica 2022-02-03 17:02:57 +05:30 committed by GitHub
commit e9fc6c3249
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
345 changed files with 15654 additions and 5362 deletions

View File

@ -28,6 +28,7 @@ ignore =
B007, B007,
B950, B950,
W191, W191,
E124, # closing bracket, irritating while writing QB code
max-line-length = 200 max-line-length = 200
exclude=.github/helper/semgrep_rules exclude=.github/helper/semgrep_rules

View File

@ -2,9 +2,10 @@
# For license information, please see license.txt # For license information, please see license.txt
from functools import reduce
import frappe import frappe
from frappe.utils import flt from frappe.utils import flt
from six.moves import reduce
from erpnext.controllers.status_updater import StatusUpdater from erpnext.controllers.status_updater import StatusUpdater

View File

@ -15,17 +15,6 @@ frappe.ui.form.on('Cost Center', {
} }
} }
}); });
frm.set_query("cost_center", "distributed_cost_center", function() {
return {
filters: {
company: frm.doc.company,
is_group: 0,
enable_distributed_cost_center: 0,
name: ['!=', frm.doc.name]
}
};
});
}, },
refresh: function(frm) { refresh: function(frm) {
if (!frm.is_new()) { if (!frm.is_new()) {

View File

@ -16,9 +16,6 @@
"cb0", "cb0",
"is_group", "is_group",
"disabled", "disabled",
"section_break_9",
"enable_distributed_cost_center",
"distributed_cost_center",
"lft", "lft",
"rgt", "rgt",
"old_parent" "old_parent"
@ -122,31 +119,13 @@
"fieldname": "disabled", "fieldname": "disabled",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Disabled" "label": "Disabled"
},
{
"default": "0",
"fieldname": "enable_distributed_cost_center",
"fieldtype": "Check",
"label": "Enable Distributed Cost Center"
},
{
"depends_on": "eval:doc.is_group==0",
"fieldname": "section_break_9",
"fieldtype": "Section Break"
},
{
"depends_on": "enable_distributed_cost_center",
"fieldname": "distributed_cost_center",
"fieldtype": "Table",
"label": "Distributed Cost Center",
"options": "Distributed Cost Center"
} }
], ],
"icon": "fa fa-money", "icon": "fa fa-money",
"idx": 1, "idx": 1,
"is_tree": 1, "is_tree": 1,
"links": [], "links": [],
"modified": "2020-06-17 16:09:30.025214", "modified": "2022-01-31 13:22:58.916273",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Cost Center", "name": "Cost Center",
@ -189,5 +168,6 @@
"search_fields": "parent_cost_center, is_group", "search_fields": "parent_cost_center, is_group",
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "ASC" "sort_order": "ASC",
"states": []
} }

View File

@ -4,7 +4,6 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cint
from frappe.utils.nestedset import NestedSet from frappe.utils.nestedset import NestedSet
from erpnext.accounts.utils import validate_field_number from erpnext.accounts.utils import validate_field_number
@ -20,24 +19,6 @@ class CostCenter(NestedSet):
def validate(self): def validate(self):
self.validate_mandatory() self.validate_mandatory()
self.validate_parent_cost_center() self.validate_parent_cost_center()
self.validate_distributed_cost_center()
def validate_distributed_cost_center(self):
if cint(self.enable_distributed_cost_center):
if not self.distributed_cost_center:
frappe.throw(_("Please enter distributed cost center"))
if sum(x.percentage_allocation for x in self.distributed_cost_center) != 100:
frappe.throw(_("Total percentage allocation for distributed cost center should be equal to 100"))
if not self.get('__islocal'):
if not cint(frappe.get_cached_value("Cost Center", {"name": self.name}, "enable_distributed_cost_center")) \
and self.check_if_part_of_distributed_cost_center():
frappe.throw(_("Cannot enable Distributed Cost Center for a Cost Center already allocated in another Distributed Cost Center"))
if next((True for x in self.distributed_cost_center if x.cost_center == x.parent), False):
frappe.throw(_("Parent Cost Center cannot be added in Distributed Cost Center"))
if check_if_distributed_cost_center_enabled(list(x.cost_center for x in self.distributed_cost_center)):
frappe.throw(_("A Distributed Cost Center cannot be added in the Distributed Cost Center allocation table."))
else:
self.distributed_cost_center = []
def validate_mandatory(self): def validate_mandatory(self):
if self.cost_center_name != self.company and not self.parent_cost_center: if self.cost_center_name != self.company and not self.parent_cost_center:
@ -64,10 +45,10 @@ class CostCenter(NestedSet):
@frappe.whitelist() @frappe.whitelist()
def convert_ledger_to_group(self): def convert_ledger_to_group(self):
if cint(self.enable_distributed_cost_center): if self.if_allocation_exists_against_cost_center():
frappe.throw(_("Cost Center with enabled distributed cost center can not be converted to group")) frappe.throw(_("Cost Center with Allocation records can not be converted to a group"))
if self.check_if_part_of_distributed_cost_center(): if self.check_if_part_of_cost_center_allocation():
frappe.throw(_("Cost Center Already Allocated in a Distributed Cost Center cannot be converted to group")) frappe.throw(_("Cost Center is a part of Cost Center Allocation, hence cannot be converted to a group"))
if self.check_gle_exists(): if self.check_gle_exists():
frappe.throw(_("Cost Center with existing transactions can not be converted to group")) frappe.throw(_("Cost Center with existing transactions can not be converted to group"))
self.is_group = 1 self.is_group = 1
@ -81,8 +62,17 @@ class CostCenter(NestedSet):
return frappe.db.sql("select name from `tabCost Center` where \ return frappe.db.sql("select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2", self.name) parent_cost_center = %s and docstatus != 2", self.name)
def check_if_part_of_distributed_cost_center(self): def if_allocation_exists_against_cost_center(self):
return frappe.db.get_value("Distributed Cost Center", {"cost_center": self.name}) return frappe.db.get_value("Cost Center Allocation", filters = {
"main_cost_center": self.name,
"docstatus": 1
})
def check_if_part_of_cost_center_allocation(self):
return frappe.db.get_value("Cost Center Allocation Percentage", filters = {
"cost_center": self.name,
"docstatus": 1
})
def before_rename(self, olddn, newdn, merge=False): def before_rename(self, olddn, newdn, merge=False):
# Add company abbr if not provided # Add company abbr if not provided
@ -126,8 +116,4 @@ def on_doctype_update():
def get_name_with_number(new_account, account_number): def get_name_with_number(new_account, account_number):
if account_number and not new_account[0].isdigit(): if account_number and not new_account[0].isdigit():
new_account = account_number + " - " + new_account new_account = account_number + " - " + new_account
return new_account return new_account
def check_if_distributed_cost_center_enabled(cost_center_list):
value_list = frappe.get_list("Cost Center", {"name": ["in", cost_center_list]}, "enable_distributed_cost_center", as_list=1)
return next((True for x in value_list if x[0]), False)

View File

@ -23,33 +23,6 @@ class TestCostCenter(unittest.TestCase):
self.assertRaises(frappe.ValidationError, cost_center.save) self.assertRaises(frappe.ValidationError, cost_center.save)
def test_validate_distributed_cost_center(self):
if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center - _TC'}):
frappe.get_doc(test_records[0]).insert()
if not frappe.db.get_value('Cost Center', {'name': '_Test Cost Center 2 - _TC'}):
frappe.get_doc(test_records[1]).insert()
invalid_distributed_cost_center = frappe.get_doc({
"company": "_Test Company",
"cost_center_name": "_Test Distributed Cost Center",
"doctype": "Cost Center",
"is_group": 0,
"parent_cost_center": "_Test Company - _TC",
"enable_distributed_cost_center": 1,
"distributed_cost_center": [{
"cost_center": "_Test Cost Center - _TC",
"percentage_allocation": 40
}, {
"cost_center": "_Test Cost Center 2 - _TC",
"percentage_allocation": 50
}
]
})
self.assertRaises(frappe.ValidationError, invalid_distributed_cost_center.save)
def create_cost_center(**args): def create_cost_center(**args):
args = frappe._dict(args) args = frappe._dict(args)
if args.cost_center_name: if args.cost_center_name:

View File

@ -0,0 +1,19 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Cost Center Allocation', {
setup: function(frm) {
let filters = {"is_group": 0};
if (frm.doc.company) {
$.extend(filters, {
"company": frm.doc.company
});
}
frm.set_query('main_cost_center', function() {
return {
filters: filters
};
});
}
});

View File

@ -0,0 +1,128 @@
{
"actions": [],
"allow_rename": 1,
"autoname": "CC-ALLOC-.#####",
"creation": "2022-01-13 20:07:29.871109",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"main_cost_center",
"company",
"column_break_2",
"valid_from",
"section_break_5",
"allocation_percentages",
"amended_from"
],
"fields": [
{
"fieldname": "main_cost_center",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Main Cost Center",
"options": "Cost Center",
"reqd": 1
},
{
"default": "Today",
"fieldname": "valid_from",
"fieldtype": "Date",
"in_list_view": 1,
"label": "Valid From",
"reqd": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"fieldname": "section_break_5",
"fieldtype": "Section Break"
},
{
"fetch_from": "main_cost_center.company",
"fieldname": "company",
"fieldtype": "Link",
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fieldname": "allocation_percentages",
"fieldtype": "Table",
"label": "Cost Center Allocation Percentages",
"options": "Cost Center Allocation Percentage",
"reqd": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Cost Center Allocation",
"print_hide": 1,
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2022-01-31 11:47:12.086253",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center Allocation",
"name_case": "UPPER CASE",
"naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts Manager",
"share": 1,
"submit": 1,
"write": 1
},
{
"amend": 1,
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "Accounts User",
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,90 @@
# Copyright (c) 2022, 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 add_days, format_date, getdate
class MainCostCenterCantBeChild(frappe.ValidationError):
pass
class InvalidMainCostCenter(frappe.ValidationError):
pass
class InvalidChildCostCenter(frappe.ValidationError):
pass
class WrongPercentageAllocation(frappe.ValidationError):
pass
class InvalidDateError(frappe.ValidationError):
pass
class CostCenterAllocation(Document):
def validate(self):
self.validate_total_allocation_percentage()
self.validate_from_date_based_on_existing_gle()
self.validate_backdated_allocation()
self.validate_main_cost_center()
self.validate_child_cost_centers()
def validate_total_allocation_percentage(self):
total_percentage = sum([d.percentage for d in self.get("allocation_percentages", [])])
if total_percentage != 100:
frappe.throw(_("Total percentage against cost centers should be 100"), WrongPercentageAllocation)
def validate_from_date_based_on_existing_gle(self):
# Check if GLE exists against the main cost center
# If exists ensure from date is set after posting date of last GLE
last_gle_date = frappe.db.get_value("GL Entry",
{"cost_center": self.main_cost_center, "is_cancelled": 0},
"posting_date", order_by="posting_date desc")
if last_gle_date:
if getdate(self.valid_from) <= getdate(last_gle_date):
frappe.throw(_("Valid From must be after {0} as last GL Entry against the cost center {1} posted on this date")
.format(last_gle_date, self.main_cost_center), InvalidDateError)
def validate_backdated_allocation(self):
# Check if there are any future existing allocation records against the main cost center
# If exists, warn the user about it
future_allocation = frappe.db.get_value("Cost Center Allocation", filters = {
"main_cost_center": self.main_cost_center,
"valid_from": (">=", self.valid_from),
"name": ("!=", self.name),
"docstatus": 1
}, fieldname=['valid_from', 'name'], order_by='valid_from', as_dict=1)
if future_allocation:
frappe.msgprint(_("Another Cost Center Allocation record {0} applicable from {1}, hence this allocation will be applicable upto {2}")
.format(frappe.bold(future_allocation.name), frappe.bold(format_date(future_allocation.valid_from)),
frappe.bold(format_date(add_days(future_allocation.valid_from, -1)))),
title=_("Warning!"), indicator="orange", alert=1
)
def validate_main_cost_center(self):
# Main cost center itself cannot be entered in child table
if self.main_cost_center in [d.cost_center for d in self.allocation_percentages]:
frappe.throw(_("Main Cost Center {0} cannot be entered in the child table")
.format(self.main_cost_center), MainCostCenterCantBeChild)
# If main cost center is used for allocation under any other cost center,
# allocation cannot be done against it
parent = frappe.db.get_value("Cost Center Allocation Percentage", filters = {
"cost_center": self.main_cost_center,
"docstatus": 1
}, fieldname='parent')
if parent:
frappe.throw(_("{0} cannot be used as a Main Cost Center because it has been used as child in Cost Center Allocation {1}")
.format(self.main_cost_center, parent), InvalidMainCostCenter)
def validate_child_cost_centers(self):
# Check if child cost center is used as main cost center in any existing allocation
main_cost_centers = [d.main_cost_center for d in
frappe.get_all("Cost Center Allocation", {'docstatus': 1}, 'main_cost_center')]
for d in self.allocation_percentages:
if d.cost_center in main_cost_centers:
frappe.throw(_("Cost Center {0} cannot be used for allocation as it is used as main cost center in other allocation record.")
.format(d.cost_center), InvalidChildCostCenter)

View File

@ -0,0 +1,156 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import unittest
import frappe
from frappe.utils import add_days, today
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.cost_center_allocation.cost_center_allocation import (
InvalidChildCostCenter,
InvalidDateError,
InvalidMainCostCenter,
MainCostCenterCantBeChild,
WrongPercentageAllocation,
)
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
class TestCostCenterAllocation(unittest.TestCase):
def setUp(self):
cost_centers = ["Main Cost Center 1", "Main Cost Center 2", "Sub Cost Center 1", "Sub Cost Center 2"]
for cc in cost_centers:
create_cost_center(cost_center_name=cc, company="_Test Company")
def test_gle_based_on_cost_center_allocation(self):
cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 60,
"Sub Cost Center 2 - _TC": 40
}
)
jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100,
cost_center = "Main Cost Center 1 - _TC", submit=True)
expected_values = [
["Sub Cost Center 1 - _TC", 0.0, 60],
["Sub Cost Center 2 - _TC", 0.0, 40]
]
gle = frappe.qb.DocType("GL Entry")
gl_entries = (
frappe.qb.from_(gle)
.select(gle.cost_center, gle.debit, gle.credit)
.where(gle.voucher_type == 'Journal Entry')
.where(gle.voucher_no == jv.name)
.where(gle.account == 'Sales - _TC')
.orderby(gle.cost_center)
).run(as_dict=1)
self.assertTrue(gl_entries)
for i, gle in enumerate(gl_entries):
self.assertEqual(expected_values[i][0], gle.cost_center)
self.assertEqual(expected_values[i][1], gle.debit)
self.assertEqual(expected_values[i][2], gle.credit)
cca.cancel()
jv.cancel()
def test_main_cost_center_cant_be_child(self):
# Main cost center itself cannot be entered in child table
cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 60,
"Main Cost Center 1 - _TC": 40
}, save=False
)
self.assertRaises(MainCostCenterCantBeChild, cca.save)
def test_invalid_main_cost_center(self):
# If main cost center is used for allocation under any other cost center,
# allocation cannot be done against it
cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 60,
"Sub Cost Center 2 - _TC": 40
}
)
cca2 = create_cost_center_allocation("_Test Company", "Sub Cost Center 1 - _TC",
{
"Sub Cost Center 2 - _TC": 100
}, save=False
)
self.assertRaises(InvalidMainCostCenter, cca2.save)
cca1.cancel()
def test_if_child_cost_center_has_any_allocation_record(self):
# Check if any child cost center is used as main cost center in any other existing allocation
cca1 = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 60,
"Sub Cost Center 2 - _TC": 40
}
)
cca2 = create_cost_center_allocation("_Test Company", "Main Cost Center 2 - _TC",
{
"Main Cost Center 1 - _TC": 60,
"Sub Cost Center 1 - _TC": 40
}, save=False
)
self.assertRaises(InvalidChildCostCenter, cca2.save)
cca1.cancel()
def test_total_percentage(self):
cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 40,
"Sub Cost Center 2 - _TC": 40
}, save=False
)
self.assertRaises(WrongPercentageAllocation, cca.save)
def test_valid_from_based_on_existing_gle(self):
# GLE posted against Sub Cost Center 1 on today
jv = make_journal_entry("_Test Cash - _TC", "Sales - _TC", 100,
cost_center = "Main Cost Center 1 - _TC", posting_date=today(), submit=True)
# try to set valid from as yesterday
cca = create_cost_center_allocation("_Test Company", "Main Cost Center 1 - _TC",
{
"Sub Cost Center 1 - _TC": 60,
"Sub Cost Center 2 - _TC": 40
}, valid_from=add_days(today(), -1), save=False
)
self.assertRaises(InvalidDateError, cca.save)
jv.cancel()
def create_cost_center_allocation(company, main_cost_center, allocation_percentages,
valid_from=None, valid_upto=None, save=True, submit=True):
doc = frappe.new_doc("Cost Center Allocation")
doc.main_cost_center = main_cost_center
doc.company = company
doc.valid_from = valid_from or today()
doc.valid_upto = valid_upto
for cc, percentage in allocation_percentages.items():
doc.append("allocation_percentages", {
"cost_center": cc,
"percentage": percentage
})
if save:
doc.save()
if submit:
doc.submit()
return doc

View File

@ -1,12 +1,13 @@
{ {
"actions": [], "actions": [],
"creation": "2020-03-19 12:34:01.500390", "allow_rename": 1,
"creation": "2022-01-13 20:07:30.096306",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"cost_center", "cost_center",
"percentage_allocation" "percentage"
], ],
"fields": [ "fields": [
{ {
@ -18,23 +19,23 @@
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "percentage_allocation", "fieldname": "percentage",
"fieldtype": "Float", "fieldtype": "Percent",
"in_list_view": 1, "in_list_view": 1,
"label": "Percentage Allocation", "label": "Percentage (%)",
"reqd": 1 "reqd": 1
} }
], ],
"index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2020-03-19 12:54:43.674655", "modified": "2022-02-01 22:22:31.589523",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Distributed Cost Center", "name": "Cost Center Allocation Percentage",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "states": []
} }

View File

@ -0,0 +1,9 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class CostCenterAllocationPercentage(Document):
pass

View File

@ -39,9 +39,6 @@ def test_create_test_data():
"selling_cost_center": "Main - _TC", "selling_cost_center": "Main - _TC",
"income_account": "Sales - _TC" "income_account": "Sales - _TC"
}], }],
"show_in_website": 1,
"route":"-test-tesla-car",
"website_warehouse": "Stores - _TC"
}) })
item.insert() item.insert()
# create test item price # create test item price

View File

@ -291,7 +291,7 @@ class PaymentRequest(Document):
if not status: if not status:
return return
shopping_cart_settings = frappe.get_doc("Shopping Cart Settings") shopping_cart_settings = frappe.get_doc("E Commerce Settings")
if status in ["Authorized", "Completed"]: if status in ["Authorized", "Completed"]:
redirect_to = None redirect_to = None
@ -435,13 +435,13 @@ def get_existing_payment_request_amount(ref_dt, ref_dn):
""", (ref_dt, ref_dn)) """, (ref_dt, ref_dn))
return flt(existing_payment_request_amount[0][0]) if existing_payment_request_amount else 0 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""" """return gateway and payment account of default payment gateway"""
if args.get("payment_gateway_account"): if args.get("payment_gateway_account"):
return get_payment_gateway_account(args.get("payment_gateway_account")) return get_payment_gateway_account(args.get("payment_gateway_account"))
if args.order_type == "Shopping Cart": 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) return get_payment_gateway_account(payment_gateway_account)
gateway_account = get_payment_gateway_account({"is_default": 1}) gateway_account = get_payment_gateway_account({"is_default": 1})

View File

@ -42,7 +42,6 @@ class POSInvoice(SalesInvoice):
self.validate_serialised_or_batched_item() self.validate_serialised_or_batched_item()
self.validate_stock_availablility() self.validate_stock_availablility()
self.validate_return_items_qty() self.validate_return_items_qty()
self.validate_non_stock_items()
self.set_status() self.set_status()
self.set_account_for_mode_of_payment() self.set_account_for_mode_of_payment()
self.validate_pos() self.validate_pos()
@ -158,22 +157,39 @@ class POSInvoice(SalesInvoice):
frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.") frappe.throw(_("Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no.")
.format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable")) .format(item.idx, bold_delivered_serial_nos), title=_("Item Unavailable"))
def validate_invalid_serial_nos(self, item):
serial_nos = get_serial_nos(item.serial_no)
error_msg = []
invalid_serials, msg = "", ""
for serial_no in serial_nos:
if not frappe.db.exists('Serial No', serial_no):
invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no
msg = (_("Row #{}: Following Serial numbers for item {} are <b>Invalid</b>: {}").format(item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials)))
if invalid_serials:
error_msg.append(msg)
if error_msg:
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_stock_availablility(self): def validate_stock_availablility(self):
if self.is_return or self.docstatus != 1: if self.is_return or self.docstatus != 1:
return return
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'): 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: if d.serial_no:
self.validate_pos_reserved_serial_nos(d) self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d) self.validate_delivered_serial_nos(d)
self.validate_invalid_serial_nos(d)
elif d.batch_no: elif d.batch_no:
self.validate_pos_reserved_batch_qty(d) self.validate_pos_reserved_batch_qty(d)
else: else:
if allow_negative_stock: if allow_negative_stock:
return 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) item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0: if flt(available_stock) <= 0:
@ -244,14 +260,6 @@ class POSInvoice(SalesInvoice):
.format(d.idx, bold_serial_no, bold_return_against) .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): def validate_mode_of_payment(self):
if len(self.payments) == 0: if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice.")) frappe.throw(_("At least one mode of payment is required for POS invoice."))
@ -491,12 +499,18 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist() @frappe.whitelist()
def get_stock_availability(item_code, warehouse): def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'): if frappe.db.get_value('Item', item_code, 'is_stock_item'):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse) bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_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: else:
is_stock_item = False
if frappe.db.exists('Product Bundle', item_code): 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): def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code) product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)

View File

@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
pos2.insert() pos2.insert()
self.assertRaises(frappe.ValidationError, pos2.submit) self.assertRaises(frappe.ValidationError, pos2.submit)
def test_invalid_serial_no_validation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
se = make_serialized_item(company='_Test Company',
target_warehouse="Stores - _TC", cost_center='Main - _TC', expense_account='Cost of Goods Sold - _TC')
serial_nos = se.get("items")[0].serial_no + 'wrong'
pos = create_pos_invoice(company='_Test Company', debit_to='Debtors - _TC',
account_for_change_amount='Cash - _TC', warehouse='Stores - _TC', income_account='Sales - _TC',
expense_account='Cost of Goods Sold - _TC', cost_center='Main - _TC',
item=se.get("items")[0].item_code, rate=1000, qty=2, do_not_save=1)
pos.get('items')[0].has_serial_no = 1
pos.get('items')[0].serial_no = serial_nos
pos.insert()
self.assertRaises(frappe.ValidationError, pos.submit)
def test_loyalty_points(self): def test_loyalty_points(self):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import ( from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points, get_loyalty_program_details_with_points,

View File

@ -628,6 +628,26 @@ class TestPricingRule(unittest.TestCase):
for doc in [si, si1]: for doc in [si, si1]:
doc.delete() doc.delete()
def test_multiple_pricing_rules_with_min_qty(self):
make_pricing_rule(discount_percentage=20, selling=1, priority=1, min_qty=4,
apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 1")
make_pricing_rule(discount_percentage=10, selling=1, priority=2, min_qty=4,
apply_multiple_pricing_rules=1, title="_Test Pricing Rule with Min Qty - 2")
si = create_sales_invoice(do_not_submit=True, customer="_Test Customer 1", qty=1, currency="USD")
item = si.items[0]
item.stock_qty = 1
si.save()
self.assertFalse(item.discount_percentage)
item.qty = 5
item.stock_qty = 5
si.save()
self.assertEqual(item.discount_percentage, 30)
si.delete()
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 1")
frappe.delete_doc_if_exists("Pricing Rule", "_Test Pricing Rule with Min Qty - 2")
test_dependencies = ["Campaign"] test_dependencies = ["Campaign"]
def make_pricing_rule(**args): def make_pricing_rule(**args):

View File

@ -73,7 +73,7 @@ def sorted_by_priority(pricing_rules, args, doc=None):
for key in sorted(pricing_rule_dict): for key in sorted(pricing_rule_dict):
pricing_rules_list.extend(pricing_rule_dict.get(key)) pricing_rules_list.extend(pricing_rule_dict.get(key))
return pricing_rules_list or pricing_rules return pricing_rules_list
def filter_pricing_rule_based_on_condition(pricing_rules, doc=None): def filter_pricing_rule_based_on_condition(pricing_rules, doc=None):
filtered_pricing_rules = [] filtered_pricing_rules = []

View File

@ -548,6 +548,10 @@ class PurchaseInvoice(BuyingController):
exchange_rate_map, net_rate_map = get_purchase_document_details(self) 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')) 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"): for item in self.get("items"):
if flt(item.base_net_amount): if flt(item.base_net_amount):
@ -643,19 +647,23 @@ class PurchaseInvoice(BuyingController):
else: else:
amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount")) 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 provisional_accounting_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 item.purchase_receipt: 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 # 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, 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, '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: 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(): if not self.is_internal_transfer():
gl_entries.append(self.get_gl_dict({ gl_entries.append(self.get_gl_dict({

View File

@ -11,12 +11,17 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account 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.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.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.controllers.accounts_controller import get_payment_terms from erpnext.controllers.accounts_controller import get_payment_terms
from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item 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 ( from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_taxes, get_taxes,
make_purchase_receipt, make_purchase_receipt,
@ -1147,8 +1152,6 @@ class TestPurchaseInvoice(unittest.TestCase):
def test_purchase_invoice_advance_taxes(self): def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry 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 # create a new supplier to test
supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier', supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier',
@ -1221,6 +1224,45 @@ class TestPurchaseInvoice(unittest.TestCase):
payment_entry.load_from_db() payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0) 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): def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql("""select account, debit, credit, posting_date gl_entries = frappe.db.sql("""select account, debit, credit, posting_date
from `tabGL Entry` from `tabGL Entry`

View File

@ -1,6 +1,8 @@
{% include "erpnext/regional/india/taxes.js" %} {% include "erpnext/regional/india/taxes.js" %}
{% include "erpnext/regional/india/e_invoice/einvoice.js" %}
erpnext.setup_auto_gst_taxation('Sales Invoice'); erpnext.setup_auto_gst_taxation('Sales Invoice');
erpnext.setup_einvoice_actions('Sales Invoice')
frappe.ui.form.on("Sales Invoice", { frappe.ui.form.on("Sales Invoice", {
setup: function(frm) { setup: function(frm) {

View File

@ -36,4 +36,139 @@ frappe.listview_settings['Sales Invoice'].onload = function (list_view) {
}; };
list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false); list_view.page.add_actions_menu_item(__('Generate E-Way Bill JSON'), action, false);
const generate_irns = () => {
const docnames = list_view.get_checked_items(true);
if (docnames && docnames.length) {
frappe.call({
method: 'erpnext.regional.india.e_invoice.utils.generate_einvoices',
args: { docnames },
freeze: true,
freeze_message: __('Generating E-Invoices...')
});
} else {
frappe.msgprint({
message: __('Please select at least one sales invoice to generate IRN'),
title: __('No Invoice Selected'),
indicator: 'red'
});
}
};
const cancel_irns = () => {
const docnames = list_view.get_checked_items(true);
const fields = [
{
"label": "Reason",
"fieldname": "reason",
"fieldtype": "Select",
"reqd": 1,
"default": "1-Duplicate",
"options": ["1-Duplicate", "2-Data Entry Error", "3-Order Cancelled", "4-Other"]
},
{
"label": "Remark",
"fieldname": "remark",
"fieldtype": "Data",
"reqd": 1
}
];
const d = new frappe.ui.Dialog({
title: __("Cancel IRN"),
fields: fields,
primary_action: function() {
const data = d.get_values();
frappe.call({
method: 'erpnext.regional.india.e_invoice.utils.cancel_irns',
args: {
doctype: list_view.doctype,
docnames,
reason: data.reason.split('-')[0],
remark: data.remark
},
freeze: true,
freeze_message: __('Cancelling E-Invoices...'),
});
d.hide();
},
primary_action_label: __('Submit')
});
d.show();
};
let einvoicing_enabled = false;
frappe.db.get_single_value("E Invoice Settings", "enable").then(enabled => {
einvoicing_enabled = enabled;
});
list_view.$result.on("change", "input[type=checkbox]", () => {
if (einvoicing_enabled) {
const docnames = list_view.get_checked_items(true);
// show/hide e-invoicing actions when no sales invoices are checked
if (docnames && docnames.length) {
// prevent adding actions twice if e-invoicing action group already exists
if (list_view.page.get_inner_group_button(__('E-Invoicing')).length == 0) {
list_view.page.add_inner_button(__('Generate IRNs'), generate_irns, __('E-Invoicing'));
list_view.page.add_inner_button(__('Cancel IRNs'), cancel_irns, __('E-Invoicing'));
}
} else {
list_view.page.remove_inner_button(__('Generate IRNs'), __('E-Invoicing'));
list_view.page.remove_inner_button(__('Cancel IRNs'), __('E-Invoicing'));
}
}
});
frappe.realtime.on("bulk_einvoice_generation_complete", (data) => {
const { failures, user, invoices } = data;
if (invoices.length != failures.length) {
frappe.msgprint({
message: __('{0} e-invoices generated successfully', [invoices.length]),
title: __('Bulk E-Invoice Generation Complete'),
indicator: 'orange'
});
}
if (failures && failures.length && user == frappe.session.user) {
let message = `
Failed to generate IRNs for following ${failures.length} sales invoices:
<ul style="padding-left: 20px; padding-top: 5px;">
${failures.map(d => `<li>${d.docname}</li>`).join('')}
</ul>
`;
frappe.msgprint({
message: message,
title: __('Bulk E-Invoice Generation Complete'),
indicator: 'orange'
});
}
});
frappe.realtime.on("bulk_einvoice_cancellation_complete", (data) => {
const { failures, user, invoices } = data;
if (invoices.length != failures.length) {
frappe.msgprint({
message: __('{0} e-invoices cancelled successfully', [invoices.length]),
title: __('Bulk E-Invoice Cancellation Complete'),
indicator: 'orange'
});
}
if (failures && failures.length && user == frappe.session.user) {
let message = `
Failed to cancel IRNs for following ${failures.length} sales invoices:
<ul style="padding-left: 20px; padding-top: 5px;">
${failures.map(d => `<li>${d.docname}</li>`).join('')}
</ul>
`;
frappe.msgprint({
message: message,
title: __('Bulk E-Invoice Cancellation Complete'),
indicator: 'orange'
});
}
});
}; };

View File

@ -469,7 +469,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
let row = frappe.get_doc(d.doctype, d.name) let row = frappe.get_doc(d.doctype, d.name)
set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail) set_timesheet_detail_rate(row.doctype, row.name, me.frm.doc.currency, row.timesheet_detail)
}); });
frm.trigger("calculate_timesheet_totals"); this.frm.trigger("calculate_timesheet_totals");
} }
} }
}; };

View File

@ -294,6 +294,8 @@ class SalesInvoice(SellingController):
def before_cancel(self): def before_cancel(self):
self.check_if_consolidated_invoice() self.check_if_consolidated_invoice()
super(SalesInvoice, self).before_cancel()
self.update_time_sheet(None) self.update_time_sheet(None)
def on_cancel(self): def on_cancel(self):

View File

@ -2100,6 +2100,54 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(data['billLists'][0]['actualFromStateCode'],7) self.assertEqual(data['billLists'][0]['actualFromStateCode'],7)
self.assertEqual(data['billLists'][0]['fromStateCode'],27) self.assertEqual(data['billLists'][0]['fromStateCode'],27)
def test_einvoice_submission_without_irn(self):
# init
einvoice_settings = frappe.get_doc('E Invoice Settings')
einvoice_settings.enable = 1
einvoice_settings.applicable_from = nowdate()
einvoice_settings.append('credentials', {
'company': '_Test Company',
'gstin': '27AAECE4835E1ZR',
'username': 'test',
'password': 'test'
})
einvoice_settings.save()
country = frappe.flags.country
frappe.flags.country = 'India'
si = make_sales_invoice_for_ewaybill()
self.assertRaises(frappe.ValidationError, si.submit)
si.irn = 'test_irn'
si.submit()
# reset
einvoice_settings = frappe.get_doc('E Invoice Settings')
einvoice_settings.enable = 0
frappe.flags.country = country
def test_einvoice_json(self):
from erpnext.regional.india.e_invoice.utils import make_einvoice, validate_totals
si = get_sales_invoice_for_e_invoice()
si.discount_amount = 100
si.save()
einvoice = make_einvoice(si)
self.assertTrue(einvoice['EwbDtls'])
validate_totals(einvoice)
si.apply_discount_on = 'Net Total'
si.save()
einvoice = make_einvoice(si)
validate_totals(einvoice)
[d.set('included_in_print_rate', 1) for d in si.taxes]
si.save()
einvoice = make_einvoice(si)
validate_totals(einvoice)
def test_item_tax_net_range(self): def test_item_tax_net_range(self):
item = create_item("T Shirt") item = create_item("T Shirt")

View File

@ -71,7 +71,8 @@ class ShippingRule(Document):
if doc.currency != doc.company_currency: if doc.currency != doc.company_currency:
shipping_amount = flt(shipping_amount / doc.conversion_rate, 2) shipping_amount = flt(shipping_amount / doc.conversion_rate, 2)
self.add_shipping_rule_to_tax_table(doc, shipping_amount) if shipping_amount:
self.add_shipping_rule_to_tax_table(doc, shipping_amount)
def get_shipping_amount_from_rules(self, value): def get_shipping_amount_from_rules(self, value):
for condition in self.get("conditions"): for condition in self.get("conditions"):

View File

@ -98,7 +98,7 @@ class TaxRule(Document):
def validate_use_for_shopping_cart(self): def validate_use_for_shopping_cart(self):
'''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one''' '''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one'''
if (not self.use_for_shopping_cart 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]})): and not frappe.db.get_value('Tax Rule', {'use_for_shopping_cart': 1, 'name': ['!=', self.name]})):
self.use_for_shopping_cart = 1 self.use_for_shopping_cart = 1

View File

@ -2,6 +2,8 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import copy
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
@ -51,49 +53,57 @@ def validate_accounting_period(gl_map):
.format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod) .format(frappe.bold(accounting_periods[0].name)), ClosedAccountingPeriod)
def process_gl_map(gl_map, merge_entries=True, precision=None): def process_gl_map(gl_map, merge_entries=True, precision=None):
if not gl_map:
return []
gl_map = distribute_gl_based_on_cost_center_allocation(gl_map, precision)
if merge_entries: if merge_entries:
gl_map = merge_similar_entries(gl_map, precision) gl_map = merge_similar_entries(gl_map, precision)
for entry in gl_map:
# toggle debit, credit if negative entry
if flt(entry.debit) < 0:
entry.credit = flt(entry.credit) - flt(entry.debit)
entry.debit = 0.0
if flt(entry.debit_in_account_currency) < 0: gl_map = toggle_debit_credit_if_negative(gl_map)
entry.credit_in_account_currency = \
flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency)
entry.debit_in_account_currency = 0.0
if flt(entry.credit) < 0:
entry.debit = flt(entry.debit) - flt(entry.credit)
entry.credit = 0.0
if flt(entry.credit_in_account_currency) < 0:
entry.debit_in_account_currency = \
flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency)
entry.credit_in_account_currency = 0.0
update_net_values(entry)
return gl_map return gl_map
def update_net_values(entry): def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
# In some scenarios net value needs to be shown in the ledger cost_center_allocation = get_cost_center_allocation_data(gl_map[0]["company"], gl_map[0]["posting_date"])
# This method updates net values as debit or credit if not cost_center_allocation:
if entry.post_net_value and entry.debit and entry.credit: return gl_map
if entry.debit > entry.credit:
entry.debit = entry.debit - entry.credit
entry.debit_in_account_currency = entry.debit_in_account_currency \
- entry.credit_in_account_currency
entry.credit = 0
entry.credit_in_account_currency = 0
else:
entry.credit = entry.credit - entry.debit
entry.credit_in_account_currency = entry.credit_in_account_currency \
- entry.debit_in_account_currency
entry.debit = 0 new_gl_map = []
entry.debit_in_account_currency = 0 for d in gl_map:
cost_center = d.get("cost_center")
if cost_center and cost_center_allocation.get(cost_center):
for sub_cost_center, percentage in cost_center_allocation.get(cost_center, {}).items():
gle = copy.deepcopy(d)
gle.cost_center = sub_cost_center
for field in ("debit", "credit", "debit_in_account_currency", "credit_in_company_currency"):
gle[field] = flt(flt(d.get(field)) * percentage / 100, precision)
new_gl_map.append(gle)
else:
new_gl_map.append(d)
return new_gl_map
def get_cost_center_allocation_data(company, posting_date):
par = frappe.qb.DocType("Cost Center Allocation")
child = frappe.qb.DocType("Cost Center Allocation Percentage")
records = (
frappe.qb.from_(par).inner_join(child).on(par.name == child.parent)
.select(par.main_cost_center, child.cost_center, child.percentage)
.where(par.docstatus == 1)
.where(par.company == company)
.where(par.valid_from <= posting_date)
.orderby(par.valid_from, order=frappe.qb.desc)
).run(as_dict=True)
cc_allocation = frappe._dict()
for d in records:
cc_allocation.setdefault(d.main_cost_center, frappe._dict())\
.setdefault(d.cost_center, d.percentage)
return cc_allocation
def merge_similar_entries(gl_map, precision=None): def merge_similar_entries(gl_map, precision=None):
merged_gl_map = [] merged_gl_map = []
@ -145,6 +155,49 @@ def check_if_in_list(gle, gl_map, dimensions=None):
if same_head: if same_head:
return e return e
def toggle_debit_credit_if_negative(gl_map):
for entry in gl_map:
# toggle debit, credit if negative entry
if flt(entry.debit) < 0:
entry.credit = flt(entry.credit) - flt(entry.debit)
entry.debit = 0.0
if flt(entry.debit_in_account_currency) < 0:
entry.credit_in_account_currency = \
flt(entry.credit_in_account_currency) - flt(entry.debit_in_account_currency)
entry.debit_in_account_currency = 0.0
if flt(entry.credit) < 0:
entry.debit = flt(entry.debit) - flt(entry.credit)
entry.credit = 0.0
if flt(entry.credit_in_account_currency) < 0:
entry.debit_in_account_currency = \
flt(entry.debit_in_account_currency) - flt(entry.credit_in_account_currency)
entry.credit_in_account_currency = 0.0
update_net_values(entry)
return gl_map
def update_net_values(entry):
# In some scenarios net value needs to be shown in the ledger
# This method updates net values as debit or credit
if entry.post_net_value and entry.debit and entry.credit:
if entry.debit > entry.credit:
entry.debit = entry.debit - entry.credit
entry.debit_in_account_currency = entry.debit_in_account_currency \
- entry.credit_in_account_currency
entry.credit = 0
entry.credit_in_account_currency = 0
else:
entry.credit = entry.credit - entry.debit
entry.credit_in_account_currency = entry.credit_in_account_currency \
- entry.debit_in_account_currency
entry.debit = 0
entry.debit_in_account_currency = 0
def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False): def save_entries(gl_map, adv_adj, update_outstanding, from_repost=False):
if not from_repost: if not from_repost:
validate_cwip_accounts(gl_map) validate_cwip_accounts(gl_map)

View File

@ -58,7 +58,7 @@ def _get_party_details(party=None, account=None, party_type="Customer", company=
frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError) frappe.throw(_("Not permitted for {0}").format(party), frappe.PermissionError)
party = frappe.get_doc(party_type, party) party = frappe.get_doc(party_type, party)
currency = party.default_currency if party.get("default_currency") else get_company_currency(company) currency = party.get("default_currency") or currency or get_company_currency(company)
party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address) party_address, shipping_address = set_address_details(party_details, party, party_type, doctype, company, party_address, company_address, shipping_address)
set_contact_details(party_details, party, party_type) set_contact_details(party_details, party, party_type)

View File

@ -0,0 +1,173 @@
{%- from "templates/print_formats/standard_macros.html" import add_header, render_field, print_value -%}
{%- set einvoice = json.loads(doc.signed_einvoice) -%}
<div class="page-break">
<div {% if print_settings.repeat_header_footer %} id="header-html" class="hidden-pdf" {% endif %}>
{% if letter_head and not no_letterhead %}
<div class="letter-head">{{ letter_head }}</div>
{% endif %}
<div class="print-heading">
<h2>E Invoice<br><small>{{ doc.name }}</small></h2>
</div>
</div>
{% if print_settings.repeat_header_footer %}
<div id="footer-html" class="visible-pdf">
{% if not no_letterhead and footer %}
<div class="letter-head-footer">
{{ footer }}
</div>
{% endif %}
<p class="text-center small page-number visible-pdf">
{{ _("Page {0} of {1}").format('<span class="page"></span>', '<span class="topage"></span>') }}
</p>
</div>
{% endif %}
<h5 class="font-bold" style="margin-top: 0px;">1. Transaction Details</h5>
<div class="row section-break" style="border-bottom: 1px solid #d1d8dd; padding-bottom: 10px;">
<div class="col-xs-8 column-break">
<div class="row data-field">
<div class="col-xs-4"><label>IRN</label></div>
<div class="col-xs-8 value">{{ einvoice.Irn }}</div>
</div>
<div class="row data-field">
<div class="col-xs-4"><label>Ack. No</label></div>
<div class="col-xs-8 value">{{ einvoice.AckNo }}</div>
</div>
<div class="row data-field">
<div class="col-xs-4"><label>Ack. Date</label></div>
<div class="col-xs-8 value">{{ frappe.utils.format_datetime(einvoice.AckDt, "dd/MM/yyyy hh:mm:ss") }}</div>
</div>
<div class="row data-field">
<div class="col-xs-4"><label>Category</label></div>
<div class="col-xs-8 value">{{ einvoice.TranDtls.SupTyp }}</div>
</div>
<div class="row data-field">
<div class="col-xs-4"><label>Document Type</label></div>
<div class="col-xs-8 value">{{ einvoice.DocDtls.Typ }}</div>
</div>
<div class="row data-field">
<div class="col-xs-4"><label>Document No</label></div>
<div class="col-xs-8 value">{{ einvoice.DocDtls.No }}</div>
</div>
</div>
<div class="col-xs-4 column-break">
<img src="{{ doc.qrcode_image }}" width="175px" style="float: right;">
</div>
</div>
<h5 class="font-bold" style="margin-top: 15px; margin-bottom: 10px;">2. Party Details</h5>
<div class="row section-break" style="border-bottom: 1px solid #d1d8dd; padding-bottom: 10px;">
{%- set seller = einvoice.SellerDtls -%}
<div class="col-xs-6 column-break">
<h5 style="margin-bottom: 5px;">Seller</h5>
<p>{{ seller.Gstin }}</p>
<p>{{ seller.LglNm }}</p>
<p>{{ seller.Addr1 }}</p>
{%- if seller.Addr2 -%} <p>{{ seller.Addr2 }}</p> {% endif %}
<p>{{ seller.Loc }}</p>
<p>{{ frappe.db.get_value("Address", doc.company_address, "gst_state") }} - {{ seller.Pin }}</p>
{%- if einvoice.ShipDtls -%}
{%- set shipping = einvoice.ShipDtls -%}
<h5 style="margin-bottom: 5px;">Shipped From</h5>
<p>{{ shipping.Gstin }}</p>
<p>{{ shipping.LglNm }}</p>
<p>{{ shipping.Addr1 }}</p>
{%- if shipping.Addr2 -%} <p>{{ shipping.Addr2 }}</p> {% endif %}
<p>{{ shipping.Loc }}</p>
<p>{{ frappe.db.get_value("Address", doc.shipping_address_name, "gst_state") }} - {{ shipping.Pin }}</p>
{% endif %}
</div>
{%- set buyer = einvoice.BuyerDtls -%}
<div class="col-xs-6 column-break">
<h5 style="margin-bottom: 5px;">Buyer</h5>
<p>{{ buyer.Gstin }}</p>
<p>{{ buyer.LglNm }}</p>
<p>{{ buyer.Addr1 }}</p>
{%- if buyer.Addr2 -%} <p>{{ buyer.Addr2 }}</p> {% endif %}
<p>{{ buyer.Loc }}</p>
<p>{{ frappe.db.get_value("Address", doc.customer_address, "gst_state") }} - {{ buyer.Pin }}</p>
{%- if einvoice.DispDtls -%}
{%- set dispatch = einvoice.DispDtls -%}
<h5 style="margin-bottom: 5px;">Dispatched From</h5>
{%- if dispatch.Gstin -%} <p>{{ dispatch.Gstin }}</p> {% endif %}
<p>{{ dispatch.LglNm }}</p>
<p>{{ dispatch.Addr1 }}</p>
{%- if dispatch.Addr2 -%} <p>{{ dispatch.Addr2 }}</p> {% endif %}
<p>{{ dispatch.Loc }}</p>
<p>{{ frappe.db.get_value("Address", doc.dispatch_address_name, "gst_state") }} - {{ dispatch.Pin }}</p>
{% endif %}
</div>
</div>
<div style="overflow-x: auto;">
<h5 class="font-bold" style="margin-top: 15px; margin-bottom: 10px;">3. Item Details</h5>
<table class="table table-bordered">
<thead>
<tr>
<th class="text-left" style="width: 3%;">Sr. No.</th>
<th class="text-left">Item</th>
<th class="text-left" style="width: 10%;">HSN Code</th>
<th class="text-left" style="width: 5%;">Qty</th>
<th class="text-left" style="width: 5%;">UOM</th>
<th class="text-left">Rate</th>
<th class="text-left" style="width: 5%;">Discount</th>
<th class="text-left">Taxable Amount</th>
<th class="text-left" style="width: 7%;">Tax Rate</th>
<th class="text-left" style="width: 5%;">Other Charges</th>
<th class="text-left">Total</th>
</tr>
</thead>
<tbody>
{% for item in einvoice.ItemList %}
<tr>
<td class="text-left" style="width: 3%;">{{ item.SlNo }}</td>
<td class="text-left">{{ item.PrdDesc }}</td>
<td class="text-left" style="width: 10%;">{{ item.HsnCd }}</td>
<td class="text-right" style="width: 5%;">{{ item.Qty }}</td>
<td class="text-left" style="width: 5%;">{{ item.Unit }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(item.UnitPrice, None, "INR") }}</td>
<td class="text-right" style="width: 5%;">{{ frappe.utils.fmt_money(item.Discount, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(item.AssAmt, None, "INR") }}</td>
<td class="text-right" style="width: 7%;">{{ item.GstRt + item.CesRt }} %</td>
<td class="text-right" style="width: 5%;">{{ frappe.utils.fmt_money(0, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(item.TotItemVal, None, "INR") }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div style="overflow-x: auto;">
<h5 class="font-bold" style="margin-bottom: 0px;">4. Value Details</h5>
<table class="table table-bordered">
<thead>
<tr>
<th class="text-left">Taxable Amount</th>
<th class="text-left">CGST</th>
<th class="text-left"">SGST</th>
<th class="text-left">IGST</th>
<th class="text-left">CESS</th>
<th class="text-left" style="width: 10%;">State CESS</th>
<th class="text-left">Discount</th>
<th class="text-left" style="width: 10%;">Other Charges</th>
<th class="text-left" style="width: 10%;">Round Off</th>
<th class="text-left">Total Value</th>
</tr>
</thead>
<tbody>
{%- set value_details = einvoice.ValDtls -%}
<tr>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.AssVal, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.CgstVal, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.SgstVal, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.IgstVal, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.CesVal, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(0, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.Discount, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.OthChrg, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.RndOffAmt, None, "INR") }}</td>
<td class="text-right">{{ frappe.utils.fmt_money(value_details.TotInvVal, None, "INR") }}</td>
</tr>
</tbody>
</table>
</div>
</div>

View File

@ -0,0 +1,24 @@
{
"align_labels_right": 1,
"creation": "2020-10-10 18:01:21.032914",
"custom_format": 0,
"default_print_language": "en-US",
"disabled": 1,
"doc_type": "Sales Invoice",
"docstatus": 0,
"doctype": "Print Format",
"font": "Default",
"html": "",
"idx": 0,
"line_breaks": 1,
"modified": "2020-10-23 19:54:40.634936",
"modified_by": "Administrator",
"module": "Accounts",
"name": "GST E-Invoice",
"owner": "Administrator",
"print_format_builder": 0,
"print_format_type": "Jinja",
"raw_printing": 0,
"show_section_headings": 1,
"standard": "Yes"
}

View File

@ -5,7 +5,6 @@
import frappe import frappe
from frappe import _, scrub from frappe import _, scrub
from frappe.utils import cint, flt from frappe.utils import cint, flt
from six import iteritems
from erpnext.accounts.party import get_partywise_advanced_payment_amount from erpnext.accounts.party import get_partywise_advanced_payment_amount
from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport
@ -40,7 +39,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if self.filters.show_gl_balance: if self.filters.show_gl_balance:
gl_balance_map = get_gl_balance(self.filters.report_date) gl_balance_map = get_gl_balance(self.filters.report_date)
for party, party_dict in iteritems(self.party_total): for party, party_dict in self.party_total.items():
if party_dict.outstanding == 0: if party_dict.outstanding == 0:
continue continue

View File

@ -29,18 +29,6 @@ def execute(filters=None):
dimension_items = cam_map.get(dimension) dimension_items = cam_map.get(dimension)
if dimension_items: if dimension_items:
data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, 0) data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, 0)
else:
DCC_allocation = frappe.db.sql('''SELECT parent, sum(percentage_allocation) as percentage_allocation
FROM `tabDistributed Cost Center`
WHERE cost_center IN %(dimension)s
AND parent NOT IN %(dimension)s
GROUP BY parent''',{'dimension':[dimension]})
if DCC_allocation:
filters['budget_against_filter'] = [DCC_allocation[0][0]]
ddc_cam_map = get_dimension_account_month_map(filters)
dimension_items = ddc_cam_map.get(DCC_allocation[0][0])
if dimension_items:
data = get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation[0][1])
chart = get_chart_data(filters, columns, data) chart = get_chart_data(filters, columns, data)

View File

@ -387,42 +387,15 @@ def set_gl_entries_by_account(
key: value key: value
}) })
distributed_cost_center_query = "" gl_entries = frappe.db.sql("""
if filters and filters.get('cost_center'): select posting_date, account, debit, credit, is_opening, fiscal_year,
distributed_cost_center_query = """ debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry`
UNION ALL
SELECT posting_date,
account,
debit*(DCC_allocation.percentage_allocation/100) as debit,
credit*(DCC_allocation.percentage_allocation/100) as credit,
is_opening,
fiscal_year,
debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency,
credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency,
account_currency
FROM `tabGL Entry`,
(
SELECT parent, sum(percentage_allocation) as percentage_allocation
FROM `tabDistributed Cost Center`
WHERE cost_center IN %(cost_center)s
AND parent NOT IN %(cost_center)s
GROUP BY parent
) as DCC_allocation
WHERE company=%(company)s
{additional_conditions}
AND posting_date <= %(to_date)s
AND is_cancelled = 0
AND cost_center = DCC_allocation.parent
""".format(additional_conditions=additional_conditions.replace("and cost_center in %(cost_center)s ", ''))
gl_entries = frappe.db.sql("""select posting_date, account, debit, credit, is_opening, fiscal_year, debit_in_account_currency, credit_in_account_currency, account_currency from `tabGL Entry`
where company=%(company)s where company=%(company)s
{additional_conditions} {additional_conditions}
and posting_date <= %(to_date)s and posting_date <= %(to_date)s
and is_cancelled = 0 and is_cancelled = 0""".format(
{distributed_cost_center_query}""".format( additional_conditions=additional_conditions), gl_filters, as_dict=True
additional_conditions=additional_conditions, )
distributed_cost_center_query=distributed_cost_center_query), gl_filters, as_dict=True) #nosec
if filters and filters.get('presentation_currency'): if filters and filters.get('presentation_currency'):
convert_to_presentation_currency(gl_entries, get_currency(filters), filters.get('company')) convert_to_presentation_currency(gl_entries, get_currency(filters), filters.get('company'))

View File

@ -176,44 +176,7 @@ def get_gl_entries(filters, accounting_dimensions):
if accounting_dimensions: if accounting_dimensions:
dimension_fields = ', '.join(accounting_dimensions) + ',' dimension_fields = ', '.join(accounting_dimensions) + ','
distributed_cost_center_query = "" gl_entries = frappe.db.sql("""
if filters and filters.get('cost_center'):
select_fields_with_percentage = """, debit*(DCC_allocation.percentage_allocation/100) as debit,
credit*(DCC_allocation.percentage_allocation/100) as credit,
debit_in_account_currency*(DCC_allocation.percentage_allocation/100) as debit_in_account_currency,
credit_in_account_currency*(DCC_allocation.percentage_allocation/100) as credit_in_account_currency """
distributed_cost_center_query = """
UNION ALL
SELECT name as gl_entry,
posting_date,
account,
party_type,
party,
voucher_type,
voucher_no, {dimension_fields}
cost_center, project,
against_voucher_type,
against_voucher,
account_currency,
remarks, against,
is_opening, `tabGL Entry`.creation {select_fields_with_percentage}
FROM `tabGL Entry`,
(
SELECT parent, sum(percentage_allocation) as percentage_allocation
FROM `tabDistributed Cost Center`
WHERE cost_center IN %(cost_center)s
AND parent NOT IN %(cost_center)s
GROUP BY parent
) as DCC_allocation
WHERE company=%(company)s
{conditions}
AND posting_date <= %(to_date)s
AND cost_center = DCC_allocation.parent
""".format(dimension_fields=dimension_fields,select_fields_with_percentage=select_fields_with_percentage, conditions=get_conditions(filters).replace("and cost_center in %(cost_center)s ", ''))
gl_entries = frappe.db.sql(
"""
select select
name as gl_entry, posting_date, account, party_type, party, name as gl_entry, posting_date, account, party_type, party,
voucher_type, voucher_no, {dimension_fields} voucher_type, voucher_no, {dimension_fields}
@ -222,13 +185,11 @@ def get_gl_entries(filters, accounting_dimensions):
remarks, against, is_opening, creation {select_fields} remarks, against, is_opening, creation {select_fields}
from `tabGL Entry` from `tabGL Entry`
where company=%(company)s {conditions} where company=%(company)s {conditions}
{distributed_cost_center_query}
{order_by_statement} {order_by_statement}
""".format( """.format(
dimension_fields=dimension_fields, select_fields=select_fields, conditions=get_conditions(filters), distributed_cost_center_query=distributed_cost_center_query, dimension_fields=dimension_fields, select_fields=select_fields,
order_by_statement=order_by_statement conditions=get_conditions(filters), order_by_statement=order_by_statement
), ), filters, as_dict=1)
filters, as_dict=1)
if filters.get('presentation_currency'): if filters.get('presentation_currency'):
return convert_to_presentation_currency(gl_entries, currency_map, filters.get('company')) return convert_to_presentation_currency(gl_entries, currency_map, filters.get('company'))

View File

@ -109,7 +109,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name):
def prepare_data(accounts, filters, total_row, parent_children_map, based_on): def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
data = [] data = []
new_accounts = accounts
company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency") company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency")
for d in accounts: for d in accounts:
@ -123,19 +122,6 @@ def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
"currency": company_currency, "currency": company_currency,
"based_on": based_on "based_on": based_on
} }
if based_on == 'cost_center':
cost_center_doc = frappe.get_doc("Cost Center",d.name)
if not cost_center_doc.enable_distributed_cost_center:
DCC_allocation = frappe.db.sql("""SELECT parent, sum(percentage_allocation) as percentage_allocation
FROM `tabDistributed Cost Center`
WHERE cost_center IN %(cost_center)s
AND parent NOT IN %(cost_center)s
GROUP BY parent""",{'cost_center': [d.name]})
if DCC_allocation:
for account in new_accounts:
if account['name'] == DCC_allocation[0][0]:
for value in value_fields:
d[value] += account[value]*(DCC_allocation[0][1]/100)
for key in value_fields: for key in value_fields:
row[key] = flt(d.get(key, 0.0), 3) row[key] = flt(d.get(key, 0.0), 3)

View File

@ -1023,6 +1023,17 @@
"onboard": 0, "onboard": 0,
"type": "Link" "type": "Link"
}, },
{
"dependencies": "Cost Center",
"hidden": 0,
"is_query_report": 0,
"label": "Cost Center Allocation",
"link_count": 0,
"link_to": "Cost Center Allocation",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
},
{ {
"dependencies": "Cost Center", "dependencies": "Cost Center",
"hidden": 0, "hidden": 0,

View File

@ -108,6 +108,10 @@ frappe.ui.form.on('Asset', {
frm.trigger("create_asset_repair"); frm.trigger("create_asset_repair");
}, __("Manage")); }, __("Manage"));
frm.add_custom_button(__("Split Asset"), function() {
frm.trigger("split_asset");
}, __("Manage"));
if (frm.doc.status != 'Fully Depreciated') { if (frm.doc.status != 'Fully Depreciated') {
frm.add_custom_button(__("Adjust Asset Value"), function() { frm.add_custom_button(__("Adjust Asset Value"), function() {
frm.trigger("create_asset_value_adjustment"); frm.trigger("create_asset_value_adjustment");
@ -322,6 +326,43 @@ frappe.ui.form.on('Asset', {
}); });
}, },
split_asset: function(frm) {
const title = __('Split Asset');
const fields = [
{
fieldname: 'split_qty',
fieldtype: 'Int',
label: __('Split Qty'),
reqd: 1
}
];
let dialog = new frappe.ui.Dialog({
title: title,
fields: fields
});
dialog.set_primary_action(__('Split'), function() {
const dialog_data = dialog.get_values();
frappe.call({
args: {
"asset_name": frm.doc.name,
"split_qty": cint(dialog_data.split_qty)
},
method: "erpnext.assets.doctype.asset.asset.split_asset",
callback: function(r) {
let doclist = frappe.model.sync(r.message);
frappe.set_route("Form", doclist[0].doctype, doclist[0].name);
}
});
dialog.hide();
});
dialog.show();
},
create_asset_value_adjustment: function(frm) { create_asset_value_adjustment: function(frm) {
frappe.call({ frappe.call({
args: { args: {

View File

@ -3,7 +3,7 @@
"allow_import": 1, "allow_import": 1,
"allow_rename": 1, "allow_rename": 1,
"autoname": "naming_series:", "autoname": "naming_series:",
"creation": "2016-03-01 17:01:27.920130", "creation": "2022-01-18 02:26:55.975005",
"doctype": "DocType", "doctype": "DocType",
"document_type": "Document", "document_type": "Document",
"engine": "InnoDB", "engine": "InnoDB",
@ -23,6 +23,7 @@
"asset_name", "asset_name",
"asset_category", "asset_category",
"location", "location",
"split_from",
"custodian", "custodian",
"department", "department",
"disposal_date", "disposal_date",
@ -142,6 +143,7 @@
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"fetch_from": "item_code.image",
"fieldname": "image", "fieldname": "image",
"fieldtype": "Attach Image", "fieldtype": "Attach Image",
"hidden": 1, "hidden": 1,
@ -482,6 +484,13 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Finance Books" "label": "Finance Books"
}, },
{
"fieldname": "split_from",
"fieldtype": "Link",
"label": "Split From",
"options": "Asset",
"read_only": 1
},
{ {
"fieldname": "asset_quantity", "fieldname": "asset_quantity",
"fieldtype": "Int", "fieldtype": "Int",
@ -509,7 +518,7 @@
"link_fieldname": "asset" "link_fieldname": "asset"
} }
], ],
"modified": "2022-01-18 12:57:36.741192", "modified": "2022-01-30 20:19:24.680027",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset", "name": "Asset",

View File

@ -38,7 +38,8 @@ class Asset(AccountsController):
self.validate_item() self.validate_item()
self.validate_cost_center() self.validate_cost_center()
self.set_missing_values() self.set_missing_values()
self.prepare_depreciation_data() if not self.split_from:
self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount() self.validate_gross_and_purchase_amount()
if self.get("schedules"): if self.get("schedules"):
self.validate_expected_value_after_useful_life() self.validate_expected_value_after_useful_life()
@ -202,143 +203,143 @@ class Asset(AccountsController):
start = self.clear_depreciation_schedule() start = self.clear_depreciation_schedule()
for finance_book in self.get('finance_books'): for finance_book in self.get('finance_books'):
self.validate_asset_finance_books(finance_book) self._make_depreciation_schedule(finance_book, start, date_of_sale)
# value_after_depreciation - current Asset value def _make_depreciation_schedule(self, finance_book, start, date_of_sale):
if self.docstatus == 1 and finance_book.value_after_depreciation: self.validate_asset_finance_books(finance_book)
value_after_depreciation = flt(finance_book.value_after_depreciation)
else:
value_after_depreciation = (flt(self.gross_purchase_amount) -
flt(self.opening_accumulated_depreciation))
finance_book.value_after_depreciation = value_after_depreciation value_after_depreciation = self._get_value_after_depreciation(finance_book)
finance_book.value_after_depreciation = value_after_depreciation
number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
cint(self.number_of_depreciations_booked) cint(self.number_of_depreciations_booked)
has_pro_rata = self.check_is_pro_rata(finance_book) has_pro_rata = self.check_is_pro_rata(finance_book)
if has_pro_rata:
number_of_pending_depreciations += 1
if has_pro_rata: skip_row = False
number_of_pending_depreciations += 1
skip_row = False for n in range(start[finance_book.idx-1], number_of_pending_depreciations):
# If depreciation is already completed (for double declining balance)
if skip_row: continue
for n in range(start[finance_book.idx-1], number_of_pending_depreciations): depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
# If depreciation is already completed (for double declining balance)
if skip_row: continue
depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1:
schedule_date = add_months(finance_book.depreciation_start_date,
n * cint(finance_book.frequency_of_depreciation))
if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: # schedule date will be a year later from start date
schedule_date = add_months(finance_book.depreciation_start_date, # so monthly schedule date is calculated by removing 11 months from it
n * cint(finance_book.frequency_of_depreciation)) monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
# schedule date will be a year later from start date # if asset is being sold
# so monthly schedule date is calculated by removing 11 months from it if date_of_sale:
monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) from_date = self.get_from_date(finance_book.finance_book)
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
# if asset is being sold from_date, date_of_sale)
if date_of_sale:
from_date = self.get_from_date(finance_book.finance_book)
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
from_date, date_of_sale)
if depreciation_amount > 0:
self.append("schedules", {
"schedule_date": date_of_sale,
"depreciation_amount": depreciation_amount,
"depreciation_method": finance_book.depreciation_method,
"finance_book": finance_book.finance_book,
"finance_book_id": finance_book.idx
})
break
# For first row
if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
from_date, finance_book.depreciation_start_date)
# For first depr schedule date will be the start date
# so monthly schedule date is calculated by removing month difference between use date and start date
monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
# For last row
elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
if not self.flags.increase_in_asset_life:
# In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
self.to_date = add_months(self.available_for_use_date,
(n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
depreciation_amount_without_pro_rata = depreciation_amount
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
depreciation_amount, schedule_date, self.to_date)
depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
depreciation_amount, finance_book.finance_book)
monthly_schedule_date = add_months(schedule_date, 1)
schedule_date = add_days(schedule_date, days)
last_schedule_date = schedule_date
if not depreciation_amount: continue
value_after_depreciation -= flt(depreciation_amount,
self.precision("gross_purchase_amount"))
# Adjust depreciation amount in the last period based on the expected value after useful life
if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
and value_after_depreciation != finance_book.expected_value_after_useful_life)
or value_after_depreciation < finance_book.expected_value_after_useful_life):
depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
skip_row = True
if depreciation_amount > 0: if depreciation_amount > 0:
# With monthly depreciation, each depreciation is divided by months remaining until next date self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method,
if self.allow_monthly_depreciation: finance_book.finance_book, finance_book.idx)
# month range is 1 to 12
# In pro rata case, for first and last depreciation, month range would be different
month_range = months \
if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
else finance_book.frequency_of_depreciation
for r in range(month_range): break
if (has_pro_rata and n == 0):
# For first entry of monthly depr # For first row
if r == 0: if has_pro_rata and not self.opening_accumulated_depreciation and n==0:
days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date) from_date = add_days(self.available_for_use_date, -1) # needed to calc depr amount for available_for_use_date too
per_day_amt = depreciation_amount / days depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
depreciation_amount_for_current_month = per_day_amt * days_until_first_depr from_date, finance_book.depreciation_start_date)
depreciation_amount -= depreciation_amount_for_current_month
date = monthly_schedule_date # For first depr schedule date will be the start date
amount = depreciation_amount_for_current_month # so monthly schedule date is calculated by removing month difference between use date and start date
else: monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1)
date = add_months(monthly_schedule_date, r)
amount = depreciation_amount / (month_range - 1) # For last row
elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1: elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1:
# For last entry of monthly depr if not self.flags.increase_in_asset_life:
date = last_schedule_date # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission
amount = depreciation_amount / month_range self.to_date = add_months(self.available_for_use_date,
(n + self.number_of_depreciations_booked) * cint(finance_book.frequency_of_depreciation))
depreciation_amount_without_pro_rata = depreciation_amount
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book,
depreciation_amount, schedule_date, self.to_date)
depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata,
depreciation_amount, finance_book.finance_book)
monthly_schedule_date = add_months(schedule_date, 1)
schedule_date = add_days(schedule_date, days)
last_schedule_date = schedule_date
if not depreciation_amount: continue
value_after_depreciation -= flt(depreciation_amount,
self.precision("gross_purchase_amount"))
# Adjust depreciation amount in the last period based on the expected value after useful life
if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1
and value_after_depreciation != finance_book.expected_value_after_useful_life)
or value_after_depreciation < finance_book.expected_value_after_useful_life):
depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life)
skip_row = True
if depreciation_amount > 0:
# With monthly depreciation, each depreciation is divided by months remaining until next date
if self.allow_monthly_depreciation:
# month range is 1 to 12
# In pro rata case, for first and last depreciation, month range would be different
month_range = months \
if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \
else finance_book.frequency_of_depreciation
for r in range(month_range):
if (has_pro_rata and n == 0):
# For first entry of monthly depr
if r == 0:
days_until_first_depr = date_diff(monthly_schedule_date, self.available_for_use_date)
per_day_amt = depreciation_amount / days
depreciation_amount_for_current_month = per_day_amt * days_until_first_depr
depreciation_amount -= depreciation_amount_for_current_month
date = monthly_schedule_date
amount = depreciation_amount_for_current_month
else: else:
date = add_months(monthly_schedule_date, r) date = add_months(monthly_schedule_date, r)
amount = depreciation_amount / month_range amount = depreciation_amount / (month_range - 1)
elif (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) and r == cint(month_range) - 1:
# For last entry of monthly depr
date = last_schedule_date
amount = depreciation_amount / month_range
else:
date = add_months(monthly_schedule_date, r)
amount = depreciation_amount / month_range
self.append("schedules", { self._add_depreciation_row(date, amount, finance_book.depreciation_method,
"schedule_date": date, finance_book.finance_book, finance_book.idx)
"depreciation_amount": amount, else:
"depreciation_method": finance_book.depreciation_method, self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method,
"finance_book": finance_book.finance_book, finance_book.finance_book, finance_book.idx)
"finance_book_id": finance_book.idx
}) def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id):
else: self.append("schedules", {
self.append("schedules", { "schedule_date": schedule_date,
"schedule_date": schedule_date, "depreciation_amount": depreciation_amount,
"depreciation_amount": depreciation_amount, "depreciation_method": depreciation_method,
"depreciation_method": finance_book.depreciation_method, "finance_book": finance_book,
"finance_book": finance_book.finance_book, "finance_book_id": finance_book_id
"finance_book_id": finance_book.idx })
})
def _get_value_after_depreciation(self, finance_book):
# value_after_depreciation - current Asset value
if self.docstatus == 1 and finance_book.value_after_depreciation:
value_after_depreciation = flt(finance_book.value_after_depreciation)
else:
value_after_depreciation = (flt(self.gross_purchase_amount) -
flt(self.opening_accumulated_depreciation))
return value_after_depreciation
# depreciation schedules need to be cleared before modification due to increase in asset life/asset sales # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales
# JE: Journal Entry, FB: Finance Book # JE: Journal Entry, FB: Finance Book
@ -348,7 +349,6 @@ class Asset(AccountsController):
depr_schedule = [] depr_schedule = []
for schedule in self.get('schedules'): for schedule in self.get('schedules'):
# to update start when there are JEs linked with all the schedule rows corresponding to an FB # to update start when there are JEs linked with all the schedule rows corresponding to an FB
if len(start) == (int(schedule.finance_book_id) - 2): if len(start) == (int(schedule.finance_book_id) - 2):
start.append(num_of_depreciations_completed) start.append(num_of_depreciations_completed)
@ -924,3 +924,113 @@ def get_depreciation_amount(asset, depreciable_value, row):
depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100)) depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100))
return depreciation_amount return depreciation_amount
@frappe.whitelist()
def split_asset(asset_name, split_qty):
asset = frappe.get_doc("Asset", asset_name)
split_qty = cint(split_qty)
if split_qty >= asset.asset_quantity:
frappe.throw(_("Split qty cannot be grater than or equal to asset qty"))
remaining_qty = asset.asset_quantity - split_qty
new_asset = create_new_asset_after_split(asset, split_qty)
update_existing_asset(asset, remaining_qty)
return new_asset
def update_existing_asset(asset, remaining_qty):
remaining_gross_purchase_amount = flt((asset.gross_purchase_amount * remaining_qty) / asset.asset_quantity)
opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * remaining_qty) / asset.asset_quantity)
frappe.db.set_value("Asset", asset.name, {
'opening_accumulated_depreciation': opening_accumulated_depreciation,
'gross_purchase_amount': remaining_gross_purchase_amount,
'asset_quantity': remaining_qty
})
for finance_book in asset.get('finance_books'):
value_after_depreciation = flt((finance_book.value_after_depreciation * remaining_qty)/asset.asset_quantity)
expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * remaining_qty)/asset.asset_quantity)
frappe.db.set_value('Asset Finance Book', finance_book.name, 'value_after_depreciation', value_after_depreciation)
frappe.db.set_value('Asset Finance Book', finance_book.name, 'expected_value_after_useful_life', expected_value_after_useful_life)
accumulated_depreciation = 0
for term in asset.get('schedules'):
depreciation_amount = flt((term.depreciation_amount * remaining_qty)/asset.asset_quantity)
frappe.db.set_value('Depreciation Schedule', term.name, 'depreciation_amount', depreciation_amount)
accumulated_depreciation += depreciation_amount
frappe.db.set_value('Depreciation Schedule', term.name, 'accumulated_depreciation_amount', accumulated_depreciation)
def create_new_asset_after_split(asset, split_qty):
new_asset = frappe.copy_doc(asset)
new_gross_purchase_amount = flt((asset.gross_purchase_amount * split_qty) / asset.asset_quantity)
opening_accumulated_depreciation = flt((asset.opening_accumulated_depreciation * split_qty) / asset.asset_quantity)
new_asset.gross_purchase_amount = new_gross_purchase_amount
new_asset.opening_accumulated_depreciation = opening_accumulated_depreciation
new_asset.asset_quantity = split_qty
new_asset.split_from = asset.name
accumulated_depreciation = 0
for finance_book in new_asset.get('finance_books'):
finance_book.value_after_depreciation = flt((finance_book.value_after_depreciation * split_qty)/asset.asset_quantity)
finance_book.expected_value_after_useful_life = flt((finance_book.expected_value_after_useful_life * split_qty)/asset.asset_quantity)
for term in new_asset.get('schedules'):
depreciation_amount = flt((term.depreciation_amount * split_qty)/asset.asset_quantity)
term.depreciation_amount = depreciation_amount
accumulated_depreciation += depreciation_amount
term.accumulated_depreciation_amount = accumulated_depreciation
new_asset.submit()
new_asset.set_status()
for term in new_asset.get('schedules'):
# Update references in JV
if term.journal_entry:
add_reference_in_jv_on_split(term.journal_entry, new_asset.name, asset.name, term.depreciation_amount)
return new_asset
def add_reference_in_jv_on_split(entry_name, new_asset_name, old_asset_name, depreciation_amount):
journal_entry = frappe.get_doc('Journal Entry', entry_name)
entries_to_add = []
idx = len(journal_entry.get('accounts')) + 1
for account in journal_entry.get('accounts'):
if account.reference_name == old_asset_name:
entries_to_add.append(frappe.copy_doc(account).as_dict())
if account.credit:
account.credit = account.credit - depreciation_amount
account.credit_in_account_currency = account.credit_in_account_currency - \
account.exchange_rate * depreciation_amount
elif account.debit:
account.debit = account.debit - depreciation_amount
account.debit_in_account_currency = account.debit_in_account_currency - \
account.exchange_rate * depreciation_amount
for entry in entries_to_add:
entry.reference_name = new_asset_name
if entry.credit:
entry.credit = depreciation_amount
entry.credit_in_account_currency = entry.exchange_rate * depreciation_amount
elif entry.debit:
entry.debit = depreciation_amount
entry.debit_in_account_currency = entry.exchange_rate * depreciation_amount
entry.idx = idx
idx += 1
journal_entry.append('accounts', entry)
journal_entry.flags.ignore_validate_update_after_submit = True
journal_entry.save()
# Repost GL Entries
journal_entry.docstatus = 2
journal_entry.make_gl_entries(1)
journal_entry.docstatus = 1
journal_entry.make_gl_entries()

View File

@ -7,7 +7,7 @@ import frappe
from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate from frappe.utils import add_days, add_months, cstr, flt, get_last_day, getdate, nowdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.assets.doctype.asset.asset import make_sales_invoice from erpnext.assets.doctype.asset.asset import make_sales_invoice, split_asset
from erpnext.assets.doctype.asset.depreciation import ( from erpnext.assets.doctype.asset.depreciation import (
post_depreciation_entries, post_depreciation_entries,
restore_asset, restore_asset,
@ -245,6 +245,57 @@ class TestAsset(AssetSetup):
si.cancel() si.cancel()
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated") self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Partially Depreciated")
def test_asset_splitting(self):
asset = create_asset(
calculate_depreciation = 1,
asset_quantity=10,
available_for_use_date = '2020-01-01',
purchase_date = '2020-01-01',
expected_value_after_useful_life = 0,
total_number_of_depreciations = 6,
number_of_depreciations_booked = 1,
frequency_of_depreciation = 10,
depreciation_start_date = '2021-01-01',
opening_accumulated_depreciation=20000,
gross_purchase_amount=120000,
submit = 1
)
post_depreciation_entries(date="2021-01-01")
self.assertEqual(asset.asset_quantity, 10)
self.assertEqual(asset.gross_purchase_amount, 120000)
self.assertEqual(asset.opening_accumulated_depreciation, 20000)
new_asset = split_asset(asset.name, 2)
asset.load_from_db()
self.assertEqual(new_asset.asset_quantity, 2)
self.assertEqual(new_asset.gross_purchase_amount, 24000)
self.assertEqual(new_asset.opening_accumulated_depreciation, 4000)
self.assertEqual(new_asset.split_from, asset.name)
self.assertEqual(new_asset.schedules[0].depreciation_amount, 4000)
self.assertEqual(new_asset.schedules[1].depreciation_amount, 4000)
self.assertEqual(asset.asset_quantity, 8)
self.assertEqual(asset.gross_purchase_amount, 96000)
self.assertEqual(asset.opening_accumulated_depreciation, 16000)
self.assertEqual(asset.schedules[0].depreciation_amount, 16000)
self.assertEqual(asset.schedules[1].depreciation_amount, 16000)
journal_entry = asset.schedules[0].journal_entry
jv = frappe.get_doc('Journal Entry', journal_entry)
self.assertEqual(jv.accounts[0].credit_in_account_currency, 16000)
self.assertEqual(jv.accounts[1].debit_in_account_currency, 16000)
self.assertEqual(jv.accounts[2].credit_in_account_currency, 4000)
self.assertEqual(jv.accounts[3].debit_in_account_currency, 4000)
self.assertEqual(jv.accounts[0].reference_name, asset.name)
self.assertEqual(jv.accounts[1].reference_name, asset.name)
self.assertEqual(jv.accounts[2].reference_name, new_asset.name)
self.assertEqual(jv.accounts[3].reference_name, new_asset.name)
def test_expense_head(self): def test_expense_head(self):
pr = make_purchase_receipt(item_code="Macbook Pro", pr = make_purchase_receipt(item_code="Macbook Pro",
qty=2, rate=200000.0, location="Test Location") qty=2, rate=200000.0, location="Test Location")
@ -1197,7 +1248,8 @@ def create_asset(**args):
"available_for_use_date": args.available_for_use_date or "2020-06-06", "available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location", "location": args.location or "Test Location",
"asset_owner": args.asset_owner or "Company", "asset_owner": args.asset_owner or "Company",
"is_existing_asset": args.is_existing_asset or 1 "is_existing_asset": args.is_existing_asset or 1,
"asset_quantity": args.get("asset_quantity") or 1
}) })
if asset.calculate_depreciation: if asset.calculate_depreciation:

View File

@ -131,28 +131,6 @@ class Supplier(TransactionBase):
if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name': if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name':
frappe.db.set(self, "supplier_name", newdn) 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.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters): def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters):

View File

@ -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"
}

View File

@ -167,9 +167,14 @@ class AccountsController(TransactionBase):
validate_regional(self) validate_regional(self)
validate_einvoice_fields(self)
if self.doctype != 'Material Request': if self.doctype != 'Material Request':
apply_pricing_rule_on_transaction(self) apply_pricing_rule_on_transaction(self)
def before_cancel(self):
validate_einvoice_fields(self)
def on_trash(self): def on_trash(self):
# delete sl and gl entries on deletion of transaction # delete sl and gl entries on deletion of transaction
if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'): if frappe.db.get_single_value('Accounts Settings', 'delete_linked_ledger_entries'):
@ -2151,3 +2156,7 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
@erpnext.allow_regional @erpnext.allow_regional
def validate_regional(doc): def validate_regional(doc):
pass pass
@erpnext.allow_regional
def validate_einvoice_fields(doc):
pass

View File

@ -132,13 +132,17 @@ class EmployeeBoardingController(Document):
def on_cancel(self): def on_cancel(self):
# delete task project # delete task project
for task in frappe.get_all('Task', filters={'project': self.project}): project = self.project
for task in frappe.get_all('Task', filters={'project': project}):
frappe.delete_doc('Task', task.name, force=1) frappe.delete_doc('Task', task.name, force=1)
frappe.delete_doc('Project', self.project, force=1) frappe.delete_doc('Project', project, force=1)
self.db_set('project', '') self.db_set('project', '')
for activity in self.activities: for activity in self.activities:
activity.db_set('task', '') activity.db_set('task', '')
frappe.msgprint(_('Linked Project {} and Tasks deleted.').format(
project), alert=True, indicator='blue')
@frappe.whitelist() @frappe.whitelist()
def get_onboarding_details(parent, parenttype): def get_onboarding_details(parent, parenttype):

View File

@ -132,7 +132,7 @@ def find_variant(template, args, variant_item_code=None):
conditions = " or ".join(conditions) 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] possible_variants = [i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code]
for variant in possible_variants: for variant in possible_variants:
@ -262,9 +262,8 @@ def generate_keyed_value_combinations(args):
def copy_attributes_to_variant(item, variant): def copy_attributes_to_variant(item, variant):
# copy non no-copy fields # copy non no-copy fields
exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website", exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website",
"show_variant_in_website", "opening_stock", "variant_of", "valuation_rate", "opening_stock", "variant_of", "valuation_rate"]
"has_variants", "attributes"]
if item.variant_based_on=='Manufacturer': if item.variant_based_on=='Manufacturer':
# don't copy manufacturer values if based on part no # don't copy manufacturer values if based on part no

View File

@ -249,6 +249,9 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
del filters['customer'] del filters['customer']
else: else:
del filters['supplier'] del filters['supplier']
else:
filters.pop('customer', None)
filters.pop('supplier', None)
description_cond = '' description_cond = ''

View File

@ -204,7 +204,7 @@ class SellingController(StockController):
valuation_rate_map = {} valuation_rate_map = {}
for item in self.items: for item in self.items:
if not item.item_code: if not item.item_code or item.is_free_item:
continue continue
last_purchase_rate, is_stock_item = frappe.get_cached_value( last_purchase_rate, is_stock_item = frappe.get_cached_value(
@ -251,7 +251,7 @@ class SellingController(StockController):
valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate valuation_rate_map[(rate.item_code, rate.warehouse)] = rate.valuation_rate
for item in self.items: for item in self.items:
if not item.item_code: if not item.item_code or item.is_free_item:
continue continue
last_valuation_rate = valuation_rate_map.get( last_valuation_rate = valuation_rate_map.get(

View File

@ -40,7 +40,10 @@ class StockController(AccountsController):
if self.docstatus == 2: if self.docstatus == 2:
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name) 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) warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus==1: if self.docstatus==1:
@ -77,17 +80,17 @@ class StockController(AccountsController):
.format(d.idx, get_link_to_form("Batch", d.get("batch_no")))) .format(d.idx, get_link_to_form("Batch", d.get("batch_no"))))
def clean_serial_nos(self): def clean_serial_nos(self):
from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string
for row in self.get("items"): for row in self.get("items"):
if hasattr(row, "serial_no") and row.serial_no: if hasattr(row, "serial_no") and row.serial_no:
# replace commas by linefeed # remove extra whitespace and store one serial no on each line
row.serial_no = row.serial_no.replace(",", "\n") row.serial_no = clean_serial_no_string(row.serial_no)
# strip preceeding and succeeding spaces for each SN for row in self.get('packed_items') or []:
# (SN could have valid spaces in between e.g. SN - 123 - 2021) if hasattr(row, "serial_no") and row.serial_no:
serial_no_list = row.serial_no.split("\n") # remove extra whitespace and store one serial no on each line
serial_no_list = [sn.strip() for sn in serial_no_list] row.serial_no = clean_serial_no_string(row.serial_no)
row.serial_no = "\n".join(serial_no_list)
def get_gl_entries(self, warehouse_account=None, default_expense_account=None, def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None): default_cost_center=None):

View File

@ -56,6 +56,12 @@ class TestQueries(unittest.TestCase):
bundled_stock_items = query(txt="_test product bundle item 5", filters={"is_stock_item": 1}) bundled_stock_items = query(txt="_test product bundle item 5", filters={"is_stock_item": 1})
self.assertEqual(len(bundled_stock_items), 0) 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): def test_bom_qury(self):
query = add_default_params(queries.bom, "BOM") query = add_default_params(queries.bom, "BOM")

View File

@ -5,7 +5,7 @@ frappe.ui.form.on('Campaign', {
refresh: function(frm) { refresh: function(frm) {
erpnext.toggle_naming_series(); erpnext.toggle_naming_series();
if (frm.doc.__islocal) { if (frm.is_new()) {
frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series"); frm.toggle_display("naming_series", frappe.boot.sysdefaults.campaign_naming_by=="Naming Series");
} else { } else {
cur_frm.add_custom_button(__("View Leads"), function() { cur_frm.add_custom_button(__("View Leads"), function() {

View File

@ -1,9 +1,10 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
# import frappe import frappe
from frappe.model.document import Document from frappe.model.document import Document
class CRMSettings(Document): class CRMSettings(Document):
pass def validate(self):
frappe.db.set_default("campaign_naming_by", self.get("campaign_naming_by", ""))

View File

@ -2,13 +2,14 @@
# For license information, please see license.txt # For license information, please see license.txt
from urllib.parse import urlencode
import frappe import frappe
import requests import requests
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import get_url_to_form from frappe.utils import get_url_to_form
from frappe.utils.file_manager import get_file_path from frappe.utils.file_manager import get_file_path
from six.moves.urllib.parse import urlencode
class LinkedInSettings(Document): class LinkedInSettings(Document):

View File

@ -24,6 +24,14 @@ frappe.ui.form.on("Opportunity", {
frm.trigger('set_contact_link'); frm.trigger('set_contact_link');
} }
}, },
validate: function(frm) {
if (frm.doc.status == "Lost" && !frm.doc.lost_reasons.length) {
frm.trigger('set_as_lost_dialog');
frappe.throw(__("Lost Reasons are required in case opportunity is Lost."));
}
},
contact_date: function(frm) { contact_date: function(frm) {
if(frm.doc.contact_date < frappe.datetime.now_datetime()){ if(frm.doc.contact_date < frappe.datetime.now_datetime()){
frm.set_value("contact_date", ""); frm.set_value("contact_date", "");
@ -82,7 +90,7 @@ frappe.ui.form.on("Opportunity", {
frm.trigger('setup_opportunity_from'); frm.trigger('setup_opportunity_from');
erpnext.toggle_naming_series(); erpnext.toggle_naming_series();
if(!doc.__islocal && doc.status!=="Lost") { if(!frm.is_new() && doc.status!=="Lost") {
if(doc.with_items){ if(doc.with_items){
frm.add_custom_button(__('Supplier Quotation'), frm.add_custom_button(__('Supplier Quotation'),
function() { function() {
@ -187,11 +195,11 @@ frappe.ui.form.on("Opportunity", {
change_form_labels: function(frm) { change_form_labels: function(frm) {
let company_currency = erpnext.get_currency(frm.doc.company); let company_currency = erpnext.get_currency(frm.doc.company);
frm.set_currency_labels(["base_opportunity_amount", "base_total", "base_grand_total"], company_currency); frm.set_currency_labels(["base_opportunity_amount", "base_total"], company_currency);
frm.set_currency_labels(["opportunity_amount", "total", "grand_total"], frm.doc.currency); frm.set_currency_labels(["opportunity_amount", "total"], frm.doc.currency);
// toggle fields // toggle fields
frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total", "base_grand_total"], frm.toggle_display(["conversion_rate", "base_opportunity_amount", "base_total"],
frm.doc.currency != company_currency); frm.doc.currency != company_currency);
}, },
@ -209,20 +217,15 @@ frappe.ui.form.on("Opportunity", {
}, },
calculate_total: function(frm) { calculate_total: function(frm) {
let total = 0, base_total = 0, grand_total = 0, base_grand_total = 0; let total = 0, base_total = 0;
frm.doc.items.forEach(item => { frm.doc.items.forEach(item => {
total += item.amount; total += item.amount;
base_total += item.base_amount; base_total += item.base_amount;
}) })
base_grand_total = base_total + frm.doc.base_opportunity_amount;
grand_total = total + frm.doc.opportunity_amount;
frm.set_value({ frm.set_value({
'total': flt(total), 'total': flt(total),
'base_total': flt(base_total), 'base_total': flt(base_total)
'grand_total': flt(grand_total),
'base_grand_total': flt(base_grand_total)
}); });
} }

View File

@ -42,10 +42,8 @@
"items", "items",
"section_break_32", "section_break_32",
"base_total", "base_total",
"base_grand_total",
"column_break_33", "column_break_33",
"total", "total",
"grand_total",
"contact_info", "contact_info",
"customer_address", "customer_address",
"address_display", "address_display",
@ -475,21 +473,6 @@
"fieldname": "column_break_33", "fieldname": "column_break_33",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{
"fieldname": "base_grand_total",
"fieldtype": "Currency",
"label": "Grand Total (Company Currency)",
"options": "Company:company:default_currency",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "grand_total",
"fieldtype": "Currency",
"label": "Grand Total",
"options": "currency",
"read_only": 1
},
{ {
"fieldname": "lost_detail_section", "fieldname": "lost_detail_section",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -510,7 +493,7 @@
"icon": "fa fa-info-sign", "icon": "fa fa-info-sign",
"idx": 195, "idx": 195,
"links": [], "links": [],
"modified": "2021-10-21 12:04:30.151379", "modified": "2022-01-29 19:32:26.382896",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "Opportunity", "name": "Opportunity",
@ -547,6 +530,7 @@
"show_name_in_global_search": 1, "show_name_in_global_search": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"subject_field": "title", "subject_field": "title",
"timeline_field": "party_name", "timeline_field": "party_name",
"title_field": "title", "title_field": "title",

View File

@ -69,8 +69,6 @@ class Opportunity(TransactionBase):
self.total = flt(total) self.total = flt(total)
self.base_total = flt(base_total) self.base_total = flt(base_total)
self.grand_total = flt(self.total) + flt(self.opportunity_amount)
self.base_grand_total = flt(self.base_total) + flt(self.base_opportunity_amount)
def make_new_lead_if_required(self): def make_new_lead_if_required(self):
"""Set lead against new opportunity""" """Set lead against new opportunity"""

View File

@ -3,6 +3,8 @@
frappe.ui.form.on('Prospect', { frappe.ui.form.on('Prospect', {
refresh (frm) { refresh (frm) {
frappe.dynamic_link = { doc: frm.doc, fieldname: "name", doctype: frm.doctype };
if (!frm.is_new() && frappe.boot.user.can_create.includes("Customer")) { if (!frm.is_new() && frappe.boot.user.can_create.includes("Customer")) {
frm.add_custom_button(__("Customer"), function() { frm.add_custom_button(__("Customer"), function() {
frappe.model.open_mapped_doc({ frappe.model.open_mapped_doc({

View File

@ -16,7 +16,7 @@
"documentation_url": "https://docs.erpnext.com/docs/user/manual/en/CRM", "documentation_url": "https://docs.erpnext.com/docs/user/manual/en/CRM",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"modified": "2020-07-08 14:05:42.644448", "modified": "2022-01-29 20:14:29.502145",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "CRM", "module": "CRM",
"name": "CRM", "name": "CRM",
@ -33,6 +33,9 @@
}, },
{ {
"step": "Create and Send Quotation" "step": "Create and Send Quotation"
},
{
"step": "CRM Settings"
} }
], ],
"subtitle": "Lead, Opportunity, Customer, and more.", "subtitle": "Lead, Opportunity, Customer, and more.",

View File

@ -5,7 +5,6 @@
"doctype": "Onboarding Step", "doctype": "Onboarding Step",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"is_mandatory": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2020-05-28 21:07:11.461172", "modified": "2020-05-28 21:07:11.461172",
@ -13,6 +12,7 @@
"name": "Create and Send Quotation", "name": "Create and Send Quotation",
"owner": "Administrator", "owner": "Administrator",
"reference_document": "Quotation", "reference_document": "Quotation",
"show_form_tour": 0,
"show_full_form": 1, "show_full_form": 1,
"title": "Create and Send Quotation", "title": "Create and Send Quotation",
"validate_action": 1 "validate_action": 1

View File

@ -5,7 +5,6 @@
"doctype": "Onboarding Step", "doctype": "Onboarding Step",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"is_mandatory": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2020-05-28 21:07:01.373403", "modified": "2020-05-28 21:07:01.373403",
@ -13,6 +12,7 @@
"name": "Create Lead", "name": "Create Lead",
"owner": "Administrator", "owner": "Administrator",
"reference_document": "Lead", "reference_document": "Lead",
"show_form_tour": 0,
"show_full_form": 1, "show_full_form": 1,
"title": "Create Lead", "title": "Create Lead",
"validate_action": 1 "validate_action": 1

View File

@ -5,7 +5,6 @@
"doctype": "Onboarding Step", "doctype": "Onboarding Step",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"is_mandatory": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2021-01-21 15:28:52.483839", "modified": "2021-01-21 15:28:52.483839",
@ -13,6 +12,7 @@
"name": "Create Opportunity", "name": "Create Opportunity",
"owner": "Administrator", "owner": "Administrator",
"reference_document": "Opportunity", "reference_document": "Opportunity",
"show_form_tour": 0,
"show_full_form": 1, "show_full_form": 1,
"title": "Create Opportunity", "title": "Create Opportunity",
"validate_action": 1 "validate_action": 1

View File

@ -0,0 +1,21 @@
{
"action": "Go to Page",
"creation": "2022-01-29 20:14:24.803844",
"description": "# CRM Settings\n\nCRM module\u2019s features are configurable as per your business needs. CRM Settings is the place where you can set your preferences for:\n- Campaign\n- Lead\n- Opportunity\n- Quotation",
"docstatus": 0,
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_single": 1,
"is_skipped": 0,
"modified": "2022-01-29 20:14:24.803844",
"modified_by": "Administrator",
"name": "CRM Settings",
"owner": "Administrator",
"path": "#crm-settings/CRM%20Settings",
"reference_document": "CRM Settings",
"show_form_tour": 0,
"show_full_form": 0,
"title": "CRM Settings",
"validate_action": 1
}

View File

@ -5,13 +5,13 @@
"doctype": "Onboarding Step", "doctype": "Onboarding Step",
"idx": 0, "idx": 0,
"is_complete": 0, "is_complete": 0,
"is_mandatory": 0,
"is_single": 0, "is_single": 0,
"is_skipped": 0, "is_skipped": 0,
"modified": "2020-05-14 17:28:16.448676", "modified": "2020-05-14 17:28:16.448676",
"modified_by": "Administrator", "modified_by": "Administrator",
"name": "Introduction to CRM", "name": "Introduction to CRM",
"owner": "Administrator", "owner": "Administrator",
"show_form_tour": 0,
"show_full_form": 0, "show_full_form": 0,
"title": "Introduction to CRM", "title": "Introduction to CRM",
"validate_action": 1, "validate_action": 1,

86
erpnext/e_commerce/api.py Normal file
View File

@ -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")

View File

@ -1,7 +1,7 @@
// 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 // For license information, please see license.txt
frappe.ui.form.on("Shopping Cart Settings", { frappe.ui.form.on("E Commerce Settings", {
onload: function(frm) { onload: function(frm) {
if(frm.doc.__onload && frm.doc.__onload.quotation_series) { if(frm.doc.__onload && frm.doc.__onload.quotation_series) {
frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series; frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
@ -23,6 +23,21 @@ frappe.ui.form.on("Shopping Cart Settings", {
</div>` </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) { enabled: function(frm) {
if (frm.doc.enabled === 1) { if (frm.doc.enabled === 1) {

View File

@ -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
}

View File

@ -1,25 +1,81 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # -*- coding: utf-8 -*-
# License: GNU General Public License v3. See license.txt # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import flt 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 ShoppingCartSetupError(frappe.ValidationError): pass
class ShoppingCartSettings(Document): class ECommerceSettings(Document):
def onload(self): def onload(self):
self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series") self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
self.is_redisearch_loaded = is_search_module_loaded()
def validate(self): def validate(self):
self.validate_field_filters()
self.validate_attribute_filters()
self.validate_checkout()
self.validate_search_index_fields()
if self.enabled: if self.enabled:
self.validate_price_list_exchange_rate() 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): def validate_price_list_exchange_rate(self):
"Check if exchange rate exists for Price List currency (to Company's currency)." "Check if exchange rate exists for Price List currency (to Company's currency)."
from erpnext.setup.utils import get_exchange_rate from erpnext.setup.utils import get_exchange_rate
@ -60,12 +116,23 @@ class ShoppingCartSettings(Document):
def get_shipping_rules(self, shipping_territory): def get_shipping_rules(self, shipping_territory):
return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule") 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): def validate_cart_settings(doc=None, method=None):
frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings").run_method("validate") frappe.get_doc("E Commerce Settings", "E Commerce Settings").run_method("validate")
def get_shopping_cart_settings(): def get_shopping_cart_settings():
if not getattr(frappe.local, "shopping_cart_settings", None): if not getattr(frappe.local, "shopping_cart_settings", None):
frappe.local.shopping_cart_settings = frappe.get_doc("Shopping Cart Settings", "Shopping Cart Settings") frappe.local.shopping_cart_settings = frappe.get_doc("E Commerce Settings", "E Commerce Settings")
return frappe.local.shopping_cart_settings return frappe.local.shopping_cart_settings

View File

@ -1,24 +1,21 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # -*- coding: utf-8 -*-
# License: GNU General Public License v3. See license.txt # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# For license information, please see license.txt
import unittest import unittest
import frappe 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, ShoppingCartSetupError,
) )
class TestShoppingCartSettings(unittest.TestCase): class TestECommerceSettings(unittest.TestCase):
def setUp(self): def setUp(self):
frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """) frappe.db.sql("""delete from `tabSingles` where doctype="Shipping Cart Settings" """)
def get_cart_settings(self): def get_cart_settings(self):
return frappe.get_doc({"doctype": "Shopping Cart Settings", return frappe.get_doc({"doctype": "E Commerce Settings",
"company": "_Test Company"}) "company": "_Test Company"})
# NOTE: Exchangrate API has all enabled currencies that ERPNext supports. # NOTE: Exchangrate API has all enabled currencies that ERPNext supports.
@ -34,15 +31,17 @@ class TestShoppingCartSettings(unittest.TestCase):
# cart_settings = self.get_cart_settings() # cart_settings = self.get_cart_settings()
# cart_settings.price_list = "_Test Price List Rest of the World" # 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 \ # from erpnext.setup.doctype.currency_exchange.test_currency_exchange import (
# currency_exchange_records # test_records as currency_exchange_records,
# )
# frappe.get_doc(currency_exchange_records[0]).insert() # 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): def test_tax_rule_validation(self):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0") frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 0")
frappe.db.commit() # nosemgrep
cart_settings = self.get_cart_settings() cart_settings = self.get_cart_settings()
cart_settings.enabled = 1 cart_settings.enabled = 1
@ -51,4 +50,13 @@ class TestShoppingCartSettings(unittest.TestCase):
frappe.db.sql("update `tabTax Rule` set use_for_shopping_cart = 1") 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"] test_dependencies = ["Tax Rule"]

View File

@ -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) {
// }
});

View File

@ -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
}

View File

@ -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)

View File

@ -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")

View File

@ -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
}

View File

@ -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

View File

@ -0,0 +1,7 @@
{% extends "templates/web.html" %}
{% block page_content %}
<h1>{{ title }}</h1>
{% endblock %}
<!-- this is a sample default web page template -->

View File

@ -0,0 +1,4 @@
<div>
<a href="{{ doc.route }}">{{ doc.title or doc.name }}</a>
</div>
<!-- this is a sample default list template -->

View File

@ -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"]

View File

@ -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);
}
});

View File

@ -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
}

View File

@ -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]

View File

@ -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"];
}
}
};

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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'])

View File

@ -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()

View File

@ -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) {
// }
});

View File

@ -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
}

Some files were not shown because too many files have changed in this diff Show More