Merge branch 'develop' into disable-item-tax-category

This commit is contained in:
Raffael Meyer 2022-02-04 18:10:39 +01:00 committed by GitHub
commit b5242df15b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
478 changed files with 17312 additions and 11435 deletions

View File

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

View File

@ -8,7 +8,10 @@ sudo apt-get install redis-server libcups2-dev
pip install frappe-bench
git clone https://github.com/frappe/frappe --branch "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}" --depth 1
frappeuser=${FRAPPE_USER:-"frappe"}
frappebranch=${FRAPPE_BRANCH:-${GITHUB_BASE_REF:-${GITHUB_REF##*/}}}
git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
mkdir ~/frappe-bench/sites/test_site

View File

@ -6,12 +6,23 @@ on:
- '**.js'
- '**.md'
- '**.html'
workflow_dispatch:
push:
branches: [ develop ]
paths-ignore:
- '**.js'
- '**.md'
workflow_dispatch:
inputs:
user:
description: 'user'
required: true
default: 'frappe'
type: string
branch:
description: 'Branch name'
default: 'develop'
required: false
type: string
concurrency:
group: server-mariadb-develop-${{ github.event.number }}
@ -95,6 +106,8 @@ jobs:
env:
DB: mariadb
TYPE: server
FRAPPE_USER: ${{ github.event.inputs.user }}
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
- name: Run Tests
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator --with-coverage

View File

@ -16,6 +16,7 @@ from frappe.utils.xlsxutils import ILLEGAL_CHARACTERS_RE, handle_html
from openpyxl.styles import Font
from openpyxl.utils import get_column_letter
INVALID_VALUES = ("", None)
class BankStatementImport(DataImport):
def __init__(self, *args, **kwargs):
@ -95,6 +96,18 @@ def download_errored_template(data_import_name):
data_import = frappe.get_doc("Bank Statement Import", data_import_name)
data_import.export_errored_rows()
def parse_data_from_template(raw_data):
data = []
for i, row in enumerate(raw_data):
if all(v in INVALID_VALUES for v in row):
# empty row
continue
data.append(row)
return data
def start_import(data_import, bank_account, import_file_path, google_sheets_url, bank, template_options):
"""This method runs in background job"""
@ -104,7 +117,8 @@ def start_import(data_import, bank_account, import_file_path, google_sheets_url,
file = import_file_path if import_file_path else google_sheets_url
import_file = ImportFile("Bank Transaction", file = file, import_type="Insert New Records")
data = import_file.raw_data
data = parse_data_from_template(import_file.raw_data)
if import_file_path:
add_bank_account(data, bank_account)

View File

@ -2,9 +2,10 @@
# For license information, please see license.txt
from functools import reduce
import frappe
from frappe.utils import flt
from six.moves import reduce
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) {
if (!frm.is_new()) {

View File

@ -16,9 +16,6 @@
"cb0",
"is_group",
"disabled",
"section_break_9",
"enable_distributed_cost_center",
"distributed_cost_center",
"lft",
"rgt",
"old_parent"
@ -122,31 +119,13 @@
"fieldname": "disabled",
"fieldtype": "Check",
"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",
"idx": 1,
"is_tree": 1,
"links": [],
"modified": "2020-06-17 16:09:30.025214",
"modified": "2022-01-31 13:22:58.916273",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Cost Center",
@ -189,5 +168,6 @@
"search_fields": "parent_cost_center, is_group",
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "ASC"
"sort_order": "ASC",
"states": []
}

View File

@ -4,7 +4,6 @@
import frappe
from frappe import _
from frappe.utils import cint
from frappe.utils.nestedset import NestedSet
from erpnext.accounts.utils import validate_field_number
@ -20,24 +19,6 @@ class CostCenter(NestedSet):
def validate(self):
self.validate_mandatory()
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):
if self.cost_center_name != self.company and not self.parent_cost_center:
@ -64,10 +45,10 @@ class CostCenter(NestedSet):
@frappe.whitelist()
def convert_ledger_to_group(self):
if cint(self.enable_distributed_cost_center):
frappe.throw(_("Cost Center with enabled distributed cost center can not be converted to group"))
if self.check_if_part_of_distributed_cost_center():
frappe.throw(_("Cost Center Already Allocated in a Distributed Cost Center cannot be converted to group"))
if self.if_allocation_exists_against_cost_center():
frappe.throw(_("Cost Center with Allocation records can not be converted to a group"))
if self.check_if_part_of_cost_center_allocation():
frappe.throw(_("Cost Center is a part of Cost Center Allocation, hence cannot be converted to a group"))
if self.check_gle_exists():
frappe.throw(_("Cost Center with existing transactions can not be converted to group"))
self.is_group = 1
@ -81,8 +62,17 @@ class CostCenter(NestedSet):
return frappe.db.sql("select name from `tabCost Center` where \
parent_cost_center = %s and docstatus != 2", self.name)
def check_if_part_of_distributed_cost_center(self):
return frappe.db.get_value("Distributed Cost Center", {"cost_center": self.name})
def if_allocation_exists_against_cost_center(self):
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):
# Add company abbr if not provided
@ -126,8 +116,4 @@ def on_doctype_update():
def get_name_with_number(new_account, account_number):
if account_number and not new_account[0].isdigit():
new_account = account_number + " - " + 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)
return new_account

View File

@ -23,33 +23,6 @@ class TestCostCenter(unittest.TestCase):
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):
args = frappe._dict(args)
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": [],
"creation": "2020-03-19 12:34:01.500390",
"allow_rename": 1,
"creation": "2022-01-13 20:07:30.096306",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"cost_center",
"percentage_allocation"
"percentage"
],
"fields": [
{
@ -18,23 +19,23 @@
"reqd": 1
},
{
"fieldname": "percentage_allocation",
"fieldtype": "Float",
"fieldname": "percentage",
"fieldtype": "Percent",
"in_list_view": 1,
"label": "Percentage Allocation",
"label": "Percentage (%)",
"reqd": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2020-03-19 12:54:43.674655",
"modified": "2022-02-01 22:22:31.589523",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Distributed Cost Center",
"name": "Cost Center Allocation Percentage",
"owner": "Administrator",
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
"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",
"income_account": "Sales - _TC"
}],
"show_in_website": 1,
"route":"-test-tesla-car",
"website_warehouse": "Stores - _TC"
})
item.insert()
# create test item price

View File

@ -135,7 +135,7 @@ class OpeningInvoiceCreationTool(Document):
default_uom = frappe.db.get_single_value("Stock Settings", "stock_uom") or _("Nos")
rate = flt(row.outstanding_amount) / flt(row.qty)
return frappe._dict({
item_dict = frappe._dict({
"uom": default_uom,
"rate": rate or 0.0,
"qty": row.qty,
@ -146,6 +146,13 @@ class OpeningInvoiceCreationTool(Document):
"cost_center": cost_center
})
for dimension in get_accounting_dimensions():
item_dict.update({
dimension: row.get(dimension)
})
return item_dict
item = get_item_dict()
invoice = frappe._dict({
@ -166,7 +173,7 @@ class OpeningInvoiceCreationTool(Document):
accounting_dimension = get_accounting_dimensions()
for dimension in accounting_dimension:
invoice.update({
dimension: item.get(dimension)
dimension: self.get(dimension) or item.get(dimension)
})
return invoice

View File

@ -7,21 +7,26 @@ import frappe
from frappe.cache_manager import clear_doctype_cache
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
disable_dimension,
)
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
test_dependencies = ["Customer", "Supplier"]
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
class TestOpeningInvoiceCreationTool(unittest.TestCase):
def setUp(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
create_dimension()
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None):
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None):
doc = frappe.get_single("Opening Invoice Creation Tool")
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
party_1=party_1, party_2=party_2, invoice_number=invoice_number)
party_1=party_1, party_2=party_2, invoice_number=invoice_number, department=department)
doc.update(args)
return doc.make_invoices()
@ -106,6 +111,19 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
doc = frappe.get_doc('Sales Invoice', inv)
doc.cancel()
def test_opening_invoice_with_accounting_dimension(self):
invoices = self.make_invoices(invoice_type="Sales", company="_Test Opening Invoice Company", department='Sales - _TOIC')
expected_value = {
"keys": ["customer", "outstanding_amount", "status", "department"],
0: ["_Test Customer", 300, "Overdue", "Sales - _TOIC"],
1: ["_Test Customer 1", 250, "Overdue", "Sales - _TOIC"],
}
self.check_expected_values(invoices, expected_value, invoice_type="Sales")
def tearDown(self):
disable_dimension()
def get_opening_invoice_creation_dict(**args):
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
company = args.get("company", "_Test Company")

View File

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

View File

@ -42,7 +42,6 @@ class POSInvoice(SalesInvoice):
self.validate_serialised_or_batched_item()
self.validate_stock_availablility()
self.validate_return_items_qty()
self.validate_non_stock_items()
self.set_status()
self.set_account_for_mode_of_payment()
self.validate_pos()
@ -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.")
.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):
if self.is_return or self.docstatus != 1:
return
allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
if is_service_item:
return
if d.serial_no:
self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d)
self.validate_invalid_serial_nos(d)
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
if allow_negative_stock:
return
available_stock = get_stock_availability(d.item_code, d.warehouse)
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty)
if flt(available_stock) <= 0:
@ -244,14 +260,6 @@ class POSInvoice(SalesInvoice):
.format(d.idx, bold_serial_no, bold_return_against)
)
def validate_non_stock_items(self):
for d in self.get("items"):
is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item")
if not is_stock_item:
if not frappe.db.exists('Product Bundle', d.item_code):
frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.")
.format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item"))
def validate_mode_of_payment(self):
if len(self.payments) == 0:
frappe.throw(_("At least one mode of payment is required for POS invoice."))
@ -491,12 +499,18 @@ class POSInvoice(SalesInvoice):
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
if frappe.db.get_value('Item', item_code, 'is_stock_item'):
is_stock_item = True
bin_qty = get_bin_qty(item_code, warehouse)
pos_sales_qty = get_pos_reserved_qty(item_code, warehouse)
return bin_qty - pos_sales_qty
return bin_qty - pos_sales_qty, is_stock_item
else:
is_stock_item = False
if frappe.db.exists('Product Bundle', item_code):
return get_bundle_availability(item_code, warehouse)
return get_bundle_availability(item_code, warehouse), is_stock_item
else:
# Is a service item
return 0, is_stock_item
def get_bundle_availability(bundle_item_code, warehouse):
product_bundle = frappe.get_doc('Product Bundle', bundle_item_code)

View File

@ -354,6 +354,24 @@ class TestPOSInvoice(unittest.TestCase):
pos2.insert()
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):
from erpnext.accounts.doctype.loyalty_program.loyalty_program import (
get_loyalty_program_details_with_points,

View File

@ -628,6 +628,26 @@ class TestPricingRule(unittest.TestCase):
for doc in [si, si1]:
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"]
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):
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):
filtered_pricing_rules = []

View File

@ -548,6 +548,10 @@ class PurchaseInvoice(BuyingController):
exchange_rate_map, net_rate_map = get_purchase_document_details(self)
enable_discount_accounting = cint(frappe.db.get_single_value('Accounts Settings', 'enable_discount_accounting'))
provisional_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, \
'enable_provisional_accounting_for_non_stock_items'))
purchase_receipt_doc_map = {}
for item in self.get("items"):
if flt(item.base_net_amount):
@ -643,19 +647,23 @@ class PurchaseInvoice(BuyingController):
else:
amount = flt(item.base_net_amount + item.item_tax_amount, item.precision("base_net_amount"))
auto_accounting_for_non_stock_items = cint(frappe.db.get_value('Company', self.company, 'enable_perpetual_inventory_for_non_stock_items'))
if auto_accounting_for_non_stock_items:
service_received_but_not_billed_account = self.get_company_default("service_received_but_not_billed")
if provisional_accounting_for_non_stock_items:
if item.purchase_receipt:
provisional_account = self.get_company_default("default_provisional_account")
purchase_receipt_doc = purchase_receipt_doc_map.get(item.purchase_receipt)
if not purchase_receipt_doc:
purchase_receipt_doc = frappe.get_doc("Purchase Receipt", item.purchase_receipt)
purchase_receipt_doc_map[item.purchase_receipt] = purchase_receipt_doc
# Post reverse entry for Stock-Received-But-Not-Billed if it is booked in Purchase Receipt
expense_booked_in_pr = frappe.db.get_value('GL Entry', {'is_cancelled': 0,
'voucher_type': 'Purchase Receipt', 'voucher_no': item.purchase_receipt, 'voucher_detail_no': item.pr_detail,
'account':service_received_but_not_billed_account}, ['name'])
'account':provisional_account}, ['name'])
if expense_booked_in_pr:
expense_account = service_received_but_not_billed_account
# Intentionally passing purchase invoice item to handle partial billing
purchase_receipt_doc.add_provisional_gl_entry(item, gl_entries, self.posting_date, reverse=1)
if not self.is_internal_transfer():
gl_entries.append(self.get_gl_dict({

View File

@ -11,12 +11,17 @@ from frappe.utils import add_days, cint, flt, getdate, nowdate, today
import erpnext
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
from erpnext.controllers.accounts_controller import get_payment_terms
from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.exceptions import InvalidCurrency
from erpnext.projects.doctype.project.test_project import make_project
from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as create_purchase_invoice_from_receipt,
)
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_taxes,
make_purchase_receipt,
@ -1147,8 +1152,6 @@ class TestPurchaseInvoice(unittest.TestCase):
def test_purchase_invoice_advance_taxes(self):
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.buying.doctype.purchase_order.purchase_order import get_mapped_purchase_invoice
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
# create a new supplier to test
supplier = create_supplier(supplier_name = '_Test TDS Advance Supplier',
@ -1221,6 +1224,45 @@ class TestPurchaseInvoice(unittest.TestCase):
payment_entry.load_from_db()
self.assertEqual(payment_entry.taxes[0].allocated_amount, 0)
def test_provisional_accounting_entry(self):
item = create_item("_Test Non Stock Item", is_stock_item=0)
provisional_account = create_account(account_name="Provision Account",
parent_account="Current Liabilities - _TC", company="_Test Company")
company = frappe.get_doc('Company', '_Test Company')
company.enable_provisional_accounting_for_non_stock_items = 1
company.default_provisional_account = provisional_account
company.save()
pr = make_purchase_receipt(item_code="_Test Non Stock Item", posting_date=add_days(nowdate(), -2))
pi = create_purchase_invoice_from_receipt(pr.name)
pi.set_posting_time = 1
pi.posting_date = add_days(pr.posting_date, -1)
pi.items[0].expense_account = 'Cost of Goods Sold - _TC'
pi.save()
pi.submit()
# Check GLE for Purchase Invoice
expected_gle = [
['Cost of Goods Sold - _TC', 250, 0, add_days(pr.posting_date, -1)],
['Creditors - _TC', 0, 250, add_days(pr.posting_date, -1)]
]
check_gl_entries(self, pi.name, expected_gle, pi.posting_date)
expected_gle_for_purchase_receipt = [
["Provision Account - _TC", 250, 0, pr.posting_date],
["_Test Account Cost for Goods Sold - _TC", 0, 250, pr.posting_date],
["Provision Account - _TC", 0, 250, pi.posting_date],
["_Test Account Cost for Goods Sold - _TC", 250, 0, pi.posting_date]
]
check_gl_entries(self, pr.name, expected_gle_for_purchase_receipt, pr.posting_date)
company.enable_provisional_accounting_for_non_stock_items = 0
company.save()
def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
gl_entries = frappe.db.sql("""select account, debit, credit, posting_date
from `tabGL Entry`

View File

@ -1,6 +1,8 @@
{% include "erpnext/regional/india/taxes.js" %}
{% include "erpnext/regional/india/e_invoice/einvoice.js" %}
erpnext.setup_auto_gst_taxation('Sales Invoice');
erpnext.setup_einvoice_actions('Sales Invoice')
frappe.ui.form.on("Sales Invoice", {
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);
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)
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

@ -43,6 +43,7 @@ from erpnext.setup.doctype.company.company import update_company_current_month_s
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
from erpnext.stock.utils import calculate_mapped_packed_items_return
form_grid_templates = {
"items": "templates/form_grid/item_grid.html"
@ -293,6 +294,8 @@ class SalesInvoice(SellingController):
def before_cancel(self):
self.check_if_consolidated_invoice()
super(SalesInvoice, self).before_cancel()
self.update_time_sheet(None)
def on_cancel(self):
@ -728,8 +731,11 @@ class SalesInvoice(SellingController):
def update_packing_list(self):
if cint(self.update_stock) == 1:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
if cint(self.is_return) and self.return_against:
calculate_mapped_packed_items_return(self)
else:
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self)
else:
self.set('packed_items', [])

View File

@ -2100,6 +2100,54 @@ class TestSalesInvoice(unittest.TestCase):
self.assertEqual(data['billLists'][0]['actualFromStateCode'],7)
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):
item = create_item("T Shirt")
@ -2192,9 +2240,9 @@ class TestSalesInvoice(unittest.TestCase):
asset.load_from_db()
expected_values = [
["2020-06-30", 1311.48, 1311.48],
["2021-06-30", 20000.0, 21311.48],
["2021-09-30", 5041.1, 26352.58]
["2020-06-30", 1366.12, 1366.12],
["2021-06-30", 20000.0, 21366.12],
["2021-09-30", 5041.1, 26407.22]
]
for i, schedule in enumerate(asset.schedules):
@ -2242,12 +2290,12 @@ class TestSalesInvoice(unittest.TestCase):
asset.load_from_db()
expected_values = [
["2020-06-30", 1311.48, 1311.48, True],
["2021-06-30", 20000.0, 21311.48, True],
["2022-06-30", 20000.0, 41311.48, False],
["2023-06-30", 20000.0, 61311.48, False],
["2024-06-30", 20000.0, 81311.48, False],
["2025-06-06", 18688.52, 100000.0, False]
["2020-06-30", 1366.12, 1366.12, True],
["2021-06-30", 20000.0, 21366.12, True],
["2022-06-30", 20000.0, 41366.12, False],
["2023-06-30", 20000.0, 61366.12, False],
["2024-06-30", 20000.0, 81366.12, False],
["2025-06-06", 18633.88, 100000.0, False]
]
for i, schedule in enumerate(asset.schedules):

View File

@ -71,7 +71,8 @@ class ShippingRule(Document):
if doc.currency != doc.company_currency:
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):
for condition in self.get("conditions"):

View File

@ -98,7 +98,7 @@ class TaxRule(Document):
def validate_use_for_shopping_cart(self):
'''If shopping cart is enabled and no tax rule exists for shopping cart, enable this one'''
if (not self.use_for_shopping_cart
and cint(frappe.db.get_single_value('Shopping Cart Settings', 'enabled'))
and cint(frappe.db.get_single_value('E Commerce Settings', 'enabled'))
and not frappe.db.get_value('Tax Rule', {'use_for_shopping_cart': 1, 'name': ['!=', self.name]})):
self.use_for_shopping_cart = 1

View File

@ -2,6 +2,8 @@
# License: GNU General Public License v3. See license.txt
import copy
import frappe
from frappe import _
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)
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:
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:
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)
gl_map = toggle_debit_credit_if_negative(gl_map)
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
def distribute_gl_based_on_cost_center_allocation(gl_map, precision=None):
cost_center_allocation = get_cost_center_allocation_data(gl_map[0]["company"], gl_map[0]["posting_date"])
if not cost_center_allocation:
return gl_map
entry.debit = 0
entry.debit_in_account_currency = 0
new_gl_map = []
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):
merged_gl_map = []
@ -145,6 +155,49 @@ def check_if_in_list(gle, gl_map, dimensions=None):
if same_head:
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):
if not from_repost:
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)
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)
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
from frappe import _, scrub
from frappe.utils import cint, flt
from six import iteritems
from erpnext.accounts.party import get_partywise_advanced_payment_amount
from erpnext.accounts.report.accounts_receivable.accounts_receivable import ReceivablePayableReport
@ -40,7 +39,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
if self.filters.show_gl_balance:
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:
continue

View File

@ -29,18 +29,6 @@ def execute(filters=None):
dimension_items = cam_map.get(dimension)
if dimension_items:
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)

View File

@ -17,10 +17,42 @@ from erpnext.stock.doctype.item.test_item import create_item
class TestDeferredRevenueAndExpense(unittest.TestCase):
@classmethod
def setUpClass(self):
clear_old_entries()
clear_accounts_and_items()
create_company()
self.maxDiff = None
def clear_old_entries(self):
sinv = qb.DocType("Sales Invoice")
sinv_item = qb.DocType("Sales Invoice Item")
pinv = qb.DocType("Purchase Invoice")
pinv_item = qb.DocType("Purchase Invoice Item")
# delete existing invoices with deferred items
deferred_invoices = (
qb.from_(sinv)
.join(sinv_item)
.on(sinv.name == sinv_item.parent)
.select(sinv.name)
.where(sinv_item.enable_deferred_revenue == 1)
.run()
)
if deferred_invoices:
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
deferred_invoices = (
qb.from_(pinv)
.join(pinv_item)
.on(pinv.name == pinv_item.parent)
.select(pinv.name)
.where(pinv_item.enable_deferred_expense == 1)
.run()
)
if deferred_invoices:
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
def test_deferred_revenue(self):
self.clear_old_entries()
# created deferred expense accounts, if not found
deferred_revenue_account = create_account(
account_name="Deferred Revenue",
@ -108,6 +140,8 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
self.assertEqual(report.period_total, expected)
def test_deferred_expense(self):
self.clear_old_entries()
# created deferred expense accounts, if not found
deferred_expense_account = create_account(
account_name="Deferred Expense",
@ -198,6 +232,91 @@ class TestDeferredRevenueAndExpense(unittest.TestCase):
]
self.assertEqual(report.period_total, expected)
def test_zero_months(self):
self.clear_old_entries()
# created deferred expense accounts, if not found
deferred_revenue_account = create_account(
account_name="Deferred Revenue",
parent_account="Current Liabilities - _CD",
company="_Test Company DR",
)
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
acc_settings.book_deferred_entries_based_on = "Months"
acc_settings.save()
customer = frappe.new_doc("Customer")
customer.customer_name = "_Test Customer DR"
customer.type = "Individual"
customer.insert()
item = create_item(
"_Test Internet Subscription",
is_stock_item=0,
warehouse="All Warehouses - _CD",
company="_Test Company DR",
)
item.enable_deferred_revenue = 1
item.deferred_revenue_account = deferred_revenue_account
item.no_of_months = 0
item.save()
si = create_sales_invoice(
item=item.name,
company="_Test Company DR",
customer="_Test Customer DR",
debit_to="Debtors - _CD",
posting_date="2021-05-01",
parent_cost_center="Main - _CD",
cost_center="Main - _CD",
do_not_submit=True,
rate=300,
price_list_rate=300,
)
si.items[0].enable_deferred_revenue = 1
si.items[0].deferred_revenue_account = deferred_revenue_account
si.items[0].income_account = "Sales - _CD"
si.save()
si.submit()
pda = frappe.get_doc(
dict(
doctype="Process Deferred Accounting",
posting_date=nowdate(),
start_date="2021-05-01",
end_date="2021-08-01",
type="Income",
company="_Test Company DR",
)
)
pda.insert()
pda.submit()
# execute report
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
self.filters = frappe._dict(
{
"company": frappe.defaults.get_user_default("Company"),
"filter_based_on": "Date Range",
"period_start_date": "2021-05-01",
"period_end_date": "2021-08-01",
"from_fiscal_year": fiscal_year.year,
"to_fiscal_year": fiscal_year.year,
"periodicity": "Monthly",
"type": "Revenue",
"with_upcoming_postings": False,
}
)
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
report.run()
expected = [
{"key": "may_2021", "total": 300.0, "actual": 300.0},
{"key": "jun_2021", "total": 0, "actual": 0},
{"key": "jul_2021", "total": 0, "actual": 0},
{"key": "aug_2021", "total": 0, "actual": 0},
]
self.assertEqual(report.period_total, expected)
def create_company():
company = frappe.db.exists("Company", "_Test Company DR")
@ -209,15 +328,11 @@ def create_company():
company.insert()
def clear_old_entries():
def clear_accounts_and_items():
item = qb.DocType("Item")
account = qb.DocType("Account")
customer = qb.DocType("Customer")
supplier = qb.DocType("Supplier")
sinv = qb.DocType("Sales Invoice")
sinv_item = qb.DocType("Sales Invoice Item")
pinv = qb.DocType("Purchase Invoice")
pinv_item = qb.DocType("Purchase Invoice Item")
qb.from_(account).delete().where(
(account.account_name == "Deferred Revenue")
@ -228,26 +343,3 @@ def clear_old_entries():
).run()
qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
# delete existing invoices with deferred items
deferred_invoices = (
qb.from_(sinv)
.join(sinv_item)
.on(sinv.name == sinv_item.parent)
.select(sinv.name)
.where(sinv_item.enable_deferred_revenue == 1)
.run()
)
if deferred_invoices:
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
deferred_invoices = (
qb.from_(pinv)
.join(pinv_item)
.on(pinv.name == pinv_item.parent)
.select(pinv.name)
.where(pinv_item.enable_deferred_expense == 1)
.run()
)
if deferred_invoices:
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()

View File

@ -387,42 +387,15 @@ def set_gl_entries_by_account(
key: value
})
distributed_cost_center_query = ""
if filters and filters.get('cost_center'):
distributed_cost_center_query = """
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`
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
{additional_conditions}
and posting_date <= %(to_date)s
and is_cancelled = 0
{distributed_cost_center_query}""".format(
additional_conditions=additional_conditions,
distributed_cost_center_query=distributed_cost_center_query), gl_filters, as_dict=True) #nosec
and is_cancelled = 0""".format(
additional_conditions=additional_conditions), gl_filters, as_dict=True
)
if filters and filters.get('presentation_currency'):
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:
dimension_fields = ', '.join(accounting_dimensions) + ','
distributed_cost_center_query = ""
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(
"""
gl_entries = frappe.db.sql("""
select
name as gl_entry, posting_date, account, party_type, party,
voucher_type, voucher_no, {dimension_fields}
@ -222,13 +185,11 @@ def get_gl_entries(filters, accounting_dimensions):
remarks, against, is_opening, creation {select_fields}
from `tabGL Entry`
where company=%(company)s {conditions}
{distributed_cost_center_query}
{order_by_statement}
""".format(
dimension_fields=dimension_fields, select_fields=select_fields, conditions=get_conditions(filters), distributed_cost_center_query=distributed_cost_center_query,
order_by_statement=order_by_statement
),
filters, as_dict=1)
""".format(
dimension_fields=dimension_fields, select_fields=select_fields,
conditions=get_conditions(filters), order_by_statement=order_by_statement
), filters, as_dict=1)
if filters.get('presentation_currency'):
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):
data = []
new_accounts = accounts
company_currency = frappe.get_cached_value('Company', filters.get("company"), "default_currency")
for d in accounts:
@ -123,19 +122,6 @@ def prepare_data(accounts, filters, total_row, parent_children_map, based_on):
"currency": company_currency,
"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:
row[key] = flt(d.get(key, 0.0), 3)

View File

@ -1023,6 +1023,17 @@
"onboard": 0,
"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",
"hidden": 0,

View File

@ -108,6 +108,10 @@ frappe.ui.form.on('Asset', {
frm.trigger("create_asset_repair");
}, __("Manage"));
frm.add_custom_button(__("Split Asset"), function() {
frm.trigger("split_asset");
}, __("Manage"));
if (frm.doc.status != 'Fully Depreciated') {
frm.add_custom_button(__("Adjust Asset Value"), function() {
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) {
frappe.call({
args: {

View File

@ -3,7 +3,7 @@
"allow_import": 1,
"allow_rename": 1,
"autoname": "naming_series:",
"creation": "2016-03-01 17:01:27.920130",
"creation": "2022-01-18 02:26:55.975005",
"doctype": "DocType",
"document_type": "Document",
"engine": "InnoDB",
@ -23,6 +23,7 @@
"asset_name",
"asset_category",
"location",
"split_from",
"custodian",
"department",
"disposal_date",
@ -35,6 +36,7 @@
"available_for_use_date",
"column_break_23",
"gross_purchase_amount",
"asset_quantity",
"purchase_date",
"section_break_23",
"calculate_depreciation",
@ -141,6 +143,7 @@
},
{
"allow_on_submit": 1,
"fetch_from": "item_code.image",
"fieldname": "image",
"fieldtype": "Attach Image",
"hidden": 1,
@ -480,6 +483,19 @@
"fieldname": "section_break_36",
"fieldtype": "Section Break",
"label": "Finance Books"
},
{
"fieldname": "split_from",
"fieldtype": "Link",
"label": "Split From",
"options": "Asset",
"read_only": 1
},
{
"fieldname": "asset_quantity",
"fieldtype": "Int",
"label": "Asset Quantity",
"read_only_depends_on": "eval:!doc.is_existing_asset"
}
],
"idx": 72,
@ -502,10 +518,11 @@
"link_fieldname": "asset"
}
],
"modified": "2021-06-24 14:58:51.097908",
"modified": "2022-01-30 20:19:24.680027",
"modified_by": "Administrator",
"module": "Assets",
"name": "Asset",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
@ -542,6 +559,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "asset_name",
"track_changes": 1
}

View File

@ -36,8 +36,10 @@ class Asset(AccountsController):
self.validate_asset_values()
self.validate_asset_and_reference()
self.validate_item()
self.validate_cost_center()
self.set_missing_values()
self.prepare_depreciation_data()
if not self.split_from:
self.prepare_depreciation_data()
self.validate_gross_and_purchase_amount()
if self.get("schedules"):
self.validate_expected_value_after_useful_life()
@ -95,6 +97,19 @@ class Asset(AccountsController):
elif item.is_stock_item:
frappe.throw(_("Item {0} must be a non-stock item").format(self.item_code))
def validate_cost_center(self):
if not self.cost_center: return
cost_center_company = frappe.db.get_value('Cost Center', self.cost_center, 'company')
if cost_center_company != self.company:
frappe.throw(
_("Selected Cost Center {} doesn't belongs to {}").format(
frappe.bold(self.cost_center),
frappe.bold(self.company)
),
title=_("Invalid Cost Center")
)
def validate_in_use_date(self):
if not self.available_for_use_date:
frappe.throw(_("Available for use date is required"))
@ -188,142 +203,143 @@ class Asset(AccountsController):
start = self.clear_depreciation_schedule()
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
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))
def _make_depreciation_schedule(self, finance_book, start, date_of_sale):
self.validate_asset_finance_books(finance_book)
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) - \
cint(self.number_of_depreciations_booked)
number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \
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:
number_of_pending_depreciations += 1
skip_row = False
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):
# If depreciation is already completed (for double declining balance)
if skip_row: continue
depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book)
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 = add_months(finance_book.depreciation_start_date,
n * cint(finance_book.frequency_of_depreciation))
# schedule date will be a year later from start date
# so monthly schedule date is calculated by removing 11 months from it
monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
# schedule date will be a year later from start date
# so monthly schedule date is calculated by removing 11 months from it
monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1)
# if asset is being sold
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:
depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount,
self.available_for_use_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 asset is being sold
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:
# 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
self._add_depreciation_row(date_of_sale, depreciation_amount, finance_book.depreciation_method,
finance_book.finance_book, finance_book.idx)
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:
date = add_months(monthly_schedule_date, r)
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
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:
# 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:
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", {
"schedule_date": date,
"depreciation_amount": amount,
"depreciation_method": finance_book.depreciation_method,
"finance_book": finance_book.finance_book,
"finance_book_id": finance_book.idx
})
else:
self.append("schedules", {
"schedule_date": schedule_date,
"depreciation_amount": depreciation_amount,
"depreciation_method": finance_book.depreciation_method,
"finance_book": finance_book.finance_book,
"finance_book_id": finance_book.idx
})
self._add_depreciation_row(date, amount, finance_book.depreciation_method,
finance_book.finance_book, finance_book.idx)
else:
self._add_depreciation_row(schedule_date, depreciation_amount, finance_book.depreciation_method,
finance_book.finance_book, finance_book.idx)
def _add_depreciation_row(self, schedule_date, depreciation_amount, depreciation_method, finance_book, finance_book_id):
self.append("schedules", {
"schedule_date": schedule_date,
"depreciation_amount": depreciation_amount,
"depreciation_method": depreciation_method,
"finance_book": finance_book,
"finance_book_id": finance_book_id
})
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
# JE: Journal Entry, FB: Finance Book
@ -333,7 +349,6 @@ class Asset(AccountsController):
depr_schedule = []
for schedule in self.get('schedules'):
# 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):
start.append(num_of_depreciations_completed)
@ -374,7 +389,9 @@ class Asset(AccountsController):
if from_date:
return from_date
return self.available_for_use_date
# since depr for available_for_use_date is not yet booked
return add_days(self.available_for_use_date, -1)
# if it returns True, depreciation_amount will not be equal for the first and last rows
def check_is_pro_rata(self, row):
@ -907,3 +924,113 @@ def get_depreciation_amount(asset, depreciable_value, row):
depreciation_amount = flt(depreciable_value * (flt(row.rate_of_depreciation) / 100))
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 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 (
post_depreciation_entries,
restore_asset,
@ -134,6 +134,29 @@ class TestAsset(AssetSetup):
pr.cancel()
self.assertEqual(asset.docstatus, 2)
def test_purchase_of_grouped_asset(self):
create_fixed_asset_item("Rack", is_grouped_asset=1)
pr = make_purchase_receipt(item_code="Rack", qty=3, rate=100000.0, location="Test Location")
asset_name = frappe.db.get_value("Asset", {"purchase_receipt": pr.name}, 'name')
asset = frappe.get_doc('Asset', asset_name)
self.assertEqual(asset.asset_quantity, 3)
asset.calculate_depreciation = 1
month_end_date = get_last_day(nowdate())
purchase_date = nowdate() if nowdate() != month_end_date else add_days(nowdate(), -15)
asset.available_for_use_date = purchase_date
asset.purchase_date = purchase_date
asset.append("finance_books", {
"expected_value_after_useful_life": 10000,
"depreciation_method": "Straight Line",
"total_number_of_depreciations": 3,
"frequency_of_depreciation": 10,
"depreciation_start_date": month_end_date
})
asset.submit()
def test_is_fixed_asset_set(self):
asset = create_asset(is_existing_asset = 1)
doc = frappe.new_doc('Purchase Invoice')
@ -207,9 +230,9 @@ class TestAsset(AssetSetup):
self.assertEqual(frappe.db.get_value("Asset", asset.name, "status"), "Sold")
expected_gle = (
("_Test Accumulated Depreciations - _TC", 20392.16, 0.0),
("_Test Accumulated Depreciations - _TC", 20490.2, 0.0),
("_Test Fixed Asset - _TC", 0.0, 100000.0),
("_Test Gain/Loss on Asset Disposal - _TC", 54607.84, 0.0),
("_Test Gain/Loss on Asset Disposal - _TC", 54509.8, 0.0),
("Debtors - _TC", 25000.0, 0.0)
)
@ -222,6 +245,57 @@ class TestAsset(AssetSetup):
si.cancel()
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):
pr = make_purchase_receipt(item_code="Macbook Pro",
qty=2, rate=200000.0, location="Test Location")
@ -491,10 +565,10 @@ class TestDepreciationMethods(AssetSetup):
)
expected_schedules = [
["2030-12-31", 27534.25, 27534.25],
["2031-12-31", 30000.0, 57534.25],
["2032-12-31", 30000.0, 87534.25],
["2033-01-30", 2465.75, 90000.0]
['2030-12-31', 27616.44, 27616.44],
['2031-12-31', 30000.0, 57616.44],
['2032-12-31', 30000.0, 87616.44],
['2033-01-30', 2383.56, 90000.0]
]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@ -544,10 +618,10 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0)
expected_schedules = [
["2030-12-31", 28493.15, 28493.15],
["2031-12-31", 35753.43, 64246.58],
["2032-12-31", 17876.71, 82123.29],
["2033-06-06", 5376.71, 87500.0]
['2030-12-31', 28630.14, 28630.14],
['2031-12-31', 35684.93, 64315.07],
['2032-12-31', 17842.47, 82157.54],
['2033-06-06', 5342.46, 87500.0]
]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@ -580,10 +654,10 @@ class TestDepreciationMethods(AssetSetup):
self.assertEqual(asset.finance_books[0].rate_of_depreciation, 50.0)
expected_schedules = [
["2030-12-31", 11780.82, 11780.82],
["2031-12-31", 44109.59, 55890.41],
["2032-12-31", 22054.8, 77945.21],
["2033-07-12", 9554.79, 87500.0]
["2030-12-31", 11849.32, 11849.32],
["2031-12-31", 44075.34, 55924.66],
["2032-12-31", 22037.67, 77962.33],
["2033-07-12", 9537.67, 87500.0]
]
schedules = [[cstr(d.schedule_date), flt(d.depreciation_amount, 2), flt(d.accumulated_depreciation_amount, 2)]
@ -621,7 +695,7 @@ class TestDepreciationBasics(AssetSetup):
asset = create_asset(
item_code = "Macbook Pro",
calculate_depreciation = 1,
available_for_use_date = getdate("2019-12-31"),
available_for_use_date = getdate("2020-01-01"),
total_number_of_depreciations = 3,
expected_value_after_useful_life = 10000,
depreciation_start_date = getdate("2020-07-01"),
@ -632,7 +706,7 @@ class TestDepreciationBasics(AssetSetup):
["2020-07-01", 15000, 15000],
["2021-07-01", 30000, 45000],
["2022-07-01", 30000, 75000],
["2022-12-31", 15000, 90000]
["2023-01-01", 15000, 90000]
]
for i, schedule in enumerate(asset.schedules):
@ -1109,6 +1183,7 @@ class TestDepreciationBasics(AssetSetup):
self.assertEqual(gle, expected_gle)
self.assertEqual(asset.get("value_after_depreciation"), 0)
def test_expected_value_change(self):
"""
tests if changing `expected_value_after_useful_life`
@ -1130,6 +1205,15 @@ class TestDepreciationBasics(AssetSetup):
asset.reload()
self.assertEquals(asset.finance_books[0].value_after_depreciation, 98000.0)
def test_asset_cost_center(self):
asset = create_asset(is_existing_asset = 1, do_not_save=1)
asset.cost_center = "Main - WP"
self.assertRaises(frappe.ValidationError, asset.submit)
asset.cost_center = "Main - _TC"
asset.submit()
def create_asset_data():
if not frappe.db.exists("Asset Category", "Computers"):
create_asset_category()
@ -1164,7 +1248,8 @@ def create_asset(**args):
"available_for_use_date": args.available_for_use_date or "2020-06-06",
"location": args.location or "Test Location",
"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:
@ -1202,13 +1287,13 @@ def create_asset_category():
})
asset_category.insert()
def create_fixed_asset_item():
def create_fixed_asset_item(item_code=None, auto_create_assets=1, is_grouped_asset=0):
meta = frappe.get_meta('Asset')
naming_series = meta.get_field("naming_series").options.splitlines()[0] or 'ACC-ASS-.YYYY.-'
try:
frappe.get_doc({
item = frappe.get_doc({
"doctype": "Item",
"item_code": "Macbook Pro",
"item_code": item_code or "Macbook Pro",
"item_name": "Macbook Pro",
"description": "Macbook Pro Retina Display",
"asset_category": "Computers",
@ -1216,11 +1301,14 @@ def create_fixed_asset_item():
"stock_uom": "Nos",
"is_stock_item": 0,
"is_fixed_asset": 1,
"auto_create_assets": 1,
"auto_create_assets": auto_create_assets,
"is_grouped_asset": is_grouped_asset,
"asset_naming_series": naming_series
}).insert()
})
item.insert()
except frappe.DuplicateEntryError:
pass
return item
def set_depreciation_settings_in_company():
company = frappe.get_doc("Company", "_Test Company")

View File

@ -131,28 +131,6 @@ class Supplier(TransactionBase):
if frappe.defaults.get_global_default('supp_master_name') == 'Supplier Name':
frappe.db.set(self, "supplier_name", newdn)
def create_onboarding_docs(self, args):
company = frappe.defaults.get_defaults().get('company') or \
frappe.db.get_single_value('Global Defaults', 'default_company')
for i in range(1, args.get('max_count')):
supplier = args.get('supplier_name_' + str(i))
if supplier:
try:
doc = frappe.get_doc({
'doctype': self.doctype,
'supplier_name': supplier,
'supplier_group': _('Local'),
'company': company
}).insert()
if args.get('supplier_email_' + str(i)):
from erpnext.selling.doctype.customer.customer import create_contact
create_contact(supplier, 'Supplier',
doc.name, args.get('supplier_email_' + str(i)))
except frappe.NameError:
pass
@frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs
def get_supplier_primary_contact(doctype, txt, searchfield, start, page_len, filters):

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_einvoice_fields(self)
if self.doctype != 'Material Request':
apply_pricing_rule_on_transaction(self)
def before_cancel(self):
validate_einvoice_fields(self)
def on_trash(self):
# delete sl and gl entries on deletion of transaction
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
def validate_regional(doc):
pass
@erpnext.allow_regional
def validate_einvoice_fields(doc):
pass

View File

@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting):
# set contact and address details for supplier, if they are not mentioned
if getattr(self, "supplier", None):
self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions,
doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'),
fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template')))
self.update_if_missing(
get_party_details(
self.supplier,
party_type="Supplier",
doctype=self.doctype,
company=self.company,
party_address=self.get("supplier_address"),
shipping_address=self.get('shipping_address'),
fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'),
ignore_permissions=self.flags.ignore_permissions
)
)
self.set_missing_item_details(for_validate)
@ -554,10 +563,13 @@ class BuyingController(StockController, Subcontracting):
# Check for asset naming series
if item_data.get('asset_naming_series'):
created_assets = []
for qty in range(cint(d.qty)):
asset = self.make_asset(d)
if item_data.get('is_grouped_asset'):
asset = self.make_asset(d, is_grouped_asset=True)
created_assets.append(asset)
else:
for qty in range(cint(d.qty)):
asset = self.make_asset(d)
created_assets.append(asset)
if len(created_assets) > 5:
# dont show asset form links if more than 5 assets are created
@ -580,14 +592,18 @@ class BuyingController(StockController, Subcontracting):
for message in messages:
frappe.msgprint(message, title="Success", indicator="green")
def make_asset(self, row):
def make_asset(self, row, is_grouped_asset=False):
if not row.asset_location:
frappe.throw(_("Row {0}: Enter location for the asset item {1}").format(row.idx, row.item_code))
item_data = frappe.db.get_value('Item',
row.item_code, ['asset_naming_series', 'asset_category'], as_dict=1)
purchase_amount = flt(row.base_rate + row.item_tax_amount)
if is_grouped_asset:
purchase_amount = flt(row.base_amount + row.item_tax_amount)
else:
purchase_amount = flt(row.base_rate + row.item_tax_amount)
asset = frappe.get_doc({
'doctype': 'Asset',
'item_code': row.item_code,
@ -601,6 +617,7 @@ class BuyingController(StockController, Subcontracting):
'calculate_depreciation': 1,
'purchase_receipt_amount': purchase_amount,
'gross_purchase_amount': purchase_amount,
'asset_quantity': row.qty if is_grouped_asset else 0,
'purchase_receipt': self.name if self.doctype == 'Purchase Receipt' else None,
'purchase_invoice': self.name if self.doctype == 'Purchase Invoice' else None
})
@ -687,7 +704,7 @@ class BuyingController(StockController, Subcontracting):
def get_asset_item_details(asset_items):
asset_items_data = {}
for d in frappe.get_all('Item', fields = ["name", "auto_create_assets", "asset_naming_series"],
for d in frappe.get_all('Item', fields = ["name", "auto_create_assets", "asset_naming_series", "is_grouped_asset"],
filters = {'name': ('in', asset_items)}):
asset_items_data.setdefault(d.name, d)

View File

@ -132,13 +132,17 @@ class EmployeeBoardingController(Document):
def on_cancel(self):
# 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('Project', self.project, force=1)
frappe.delete_doc('Project', project, force=1)
self.db_set('project', '')
for activity in self.activities:
activity.db_set('task', '')
frappe.msgprint(_('Linked Project {} and Tasks deleted.').format(
project), alert=True, indicator='blue')
@frappe.whitelist()
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)
from erpnext.portal.product_configurator.utils import get_item_codes_by_attributes
from erpnext.e_commerce.variant_selector.utils import get_item_codes_by_attributes
possible_variants = [i for i in get_item_codes_by_attributes(args, template) if i != variant_item_code]
for variant in possible_variants:
@ -262,9 +262,8 @@ def generate_keyed_value_combinations(args):
def copy_attributes_to_variant(item, variant):
# copy non no-copy fields
exclude_fields = ["naming_series", "item_code", "item_name", "show_in_website",
"show_variant_in_website", "opening_stock", "variant_of", "valuation_rate",
"has_variants", "attributes"]
exclude_fields = ["naming_series", "item_code", "item_name", "published_in_website",
"opening_stock", "variant_of", "valuation_rate"]
if item.variant_based_on=='Manufacturer':
# don't copy manufacturer values if based on part no

View File

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

View File

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

View File

@ -40,7 +40,10 @@ class StockController(AccountsController):
if self.docstatus == 2:
make_reverse_gl_entries(voucher_type=self.doctype, voucher_no=self.name)
if cint(erpnext.is_perpetual_inventory_enabled(self.company)):
provisional_accounting_for_non_stock_items = \
cint(frappe.db.get_value('Company', self.company, 'enable_provisional_accounting_for_non_stock_items'))
if cint(erpnext.is_perpetual_inventory_enabled(self.company)) or provisional_accounting_for_non_stock_items:
warehouse_account = get_warehouse_account_map(self.company)
if self.docstatus==1:
@ -77,17 +80,17 @@ class StockController(AccountsController):
.format(d.idx, get_link_to_form("Batch", d.get("batch_no"))))
def clean_serial_nos(self):
from erpnext.stock.doctype.serial_no.serial_no import clean_serial_no_string
for row in self.get("items"):
if hasattr(row, "serial_no") and row.serial_no:
# replace commas by linefeed
row.serial_no = row.serial_no.replace(",", "\n")
# remove extra whitespace and store one serial no on each line
row.serial_no = clean_serial_no_string(row.serial_no)
# strip preceeding and succeeding spaces for each SN
# (SN could have valid spaces in between e.g. SN - 123 - 2021)
serial_no_list = row.serial_no.split("\n")
serial_no_list = [sn.strip() for sn in serial_no_list]
row.serial_no = "\n".join(serial_no_list)
for row in self.get('packed_items') or []:
if hasattr(row, "serial_no") and row.serial_no:
# remove extra whitespace and store one serial no on each line
row.serial_no = clean_serial_no_string(row.serial_no)
def get_gl_entries(self, warehouse_account=None, default_expense_account=None,
default_cost_center=None):
@ -256,11 +259,7 @@ class StockController(AccountsController):
for d in self.items:
if not d.batch_no: continue
serial_nos = [sr.name for sr in frappe.get_all("Serial No",
{'batch_no': d.batch_no, 'status': 'Inactive'})]
if serial_nos:
frappe.db.set_value("Serial No", { 'name': ['in', serial_nos] }, "batch_no", None)
frappe.db.set_value("Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None)
d.batch_no = None
d.db_set("batch_no", 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})
self.assertEqual(len(bundled_stock_items), 0)
# empty customer/supplier should be stripped of instead of failure
query(txt="", filters={"customer": None})
query(txt="", filters={"customer": ""})
query(txt="", filters={"supplier": None})
query(txt="", filters={"supplier": ""})
def test_bom_qury(self):
query = add_default_params(queries.bom, "BOM")

View File

@ -5,7 +5,7 @@ frappe.ui.form.on('Campaign', {
refresh: function(frm) {
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");
} else {
cur_frm.add_custom_button(__("View Leads"), function() {

View File

@ -1,9 +1,10 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
import frappe
from frappe.model.document import 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
from urllib.parse import urlencode
import frappe
import requests
from frappe import _
from frappe.model.document import Document
from frappe.utils import get_url_to_form
from frappe.utils.file_manager import get_file_path
from six.moves.urllib.parse import urlencode
class LinkedInSettings(Document):

View File

@ -24,6 +24,14 @@ frappe.ui.form.on("Opportunity", {
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) {
if(frm.doc.contact_date < frappe.datetime.now_datetime()){
frm.set_value("contact_date", "");
@ -82,7 +90,7 @@ frappe.ui.form.on("Opportunity", {
frm.trigger('setup_opportunity_from');
erpnext.toggle_naming_series();
if(!doc.__islocal && doc.status!=="Lost") {
if(!frm.is_new() && doc.status!=="Lost") {
if(doc.with_items){
frm.add_custom_button(__('Supplier Quotation'),
function() {
@ -187,11 +195,11 @@ frappe.ui.form.on("Opportunity", {
change_form_labels: function(frm) {
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(["opportunity_amount", "total", "grand_total"], frm.doc.currency);
frm.set_currency_labels(["base_opportunity_amount", "base_total"], company_currency);
frm.set_currency_labels(["opportunity_amount", "total"], frm.doc.currency);
// 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);
},
@ -209,20 +217,15 @@ frappe.ui.form.on("Opportunity", {
},
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 => {
total += item.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({
'total': flt(total),
'base_total': flt(base_total),
'grand_total': flt(grand_total),
'base_grand_total': flt(base_grand_total)
'base_total': flt(base_total)
});
}

View File

@ -42,10 +42,8 @@
"items",
"section_break_32",
"base_total",
"base_grand_total",
"column_break_33",
"total",
"grand_total",
"contact_info",
"customer_address",
"address_display",
@ -475,21 +473,6 @@
"fieldname": "column_break_33",
"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",
"fieldtype": "Section Break",
@ -510,7 +493,7 @@
"icon": "fa fa-info-sign",
"idx": 195,
"links": [],
"modified": "2021-10-21 12:04:30.151379",
"modified": "2022-01-29 19:32:26.382896",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity",
@ -547,6 +530,7 @@
"show_name_in_global_search": 1,
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"subject_field": "title",
"timeline_field": "party_name",
"title_field": "title",

View File

@ -69,8 +69,6 @@ class Opportunity(TransactionBase):
self.total = flt(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):
"""Set lead against new opportunity"""

View File

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

View File

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

View File

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

View File

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

View File

@ -5,7 +5,6 @@
"doctype": "Onboarding Step",
"idx": 0,
"is_complete": 0,
"is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2021-01-21 15:28:52.483839",
@ -13,6 +12,7 @@
"name": "Create Opportunity",
"owner": "Administrator",
"reference_document": "Opportunity",
"show_form_tour": 0,
"show_full_form": 1,
"title": "Create Opportunity",
"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",
"idx": 0,
"is_complete": 0,
"is_mandatory": 0,
"is_single": 0,
"is_skipped": 0,
"modified": "2020-05-14 17:28:16.448676",
"modified_by": "Administrator",
"name": "Introduction to CRM",
"owner": "Administrator",
"show_form_tour": 0,
"show_full_form": 0,
"title": "Introduction to CRM",
"validate_action": 1,

View File

@ -1,35 +0,0 @@
data = {
'desktop_icons': [
'Restaurant',
'Hotels',
'Accounts',
'Buying',
'Stock',
'HR',
'Project',
'ToDo'
],
'restricted_roles': [
'Restaurant Manager',
'Hotel Manager',
'Hotel Reservation User'
],
'custom_fields': {
'Sales Invoice': [
{
'fieldname': 'restaurant', 'fieldtype': 'Link', 'options': 'Restaurant',
'insert_after': 'customer_name', 'label': 'Restaurant',
},
{
'fieldname': 'restaurant_table', 'fieldtype': 'Link', 'options': 'Restaurant Table',
'insert_after': 'restaurant', 'label': 'Restaurant Table',
}
],
'Price List': [
{
'fieldname':'restaurant_menu', 'fieldtype':'Link', 'options':'Restaurant Menu', 'label':'Restaurant Menu',
'insert_after':'currency'
}
]
}
}

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
// 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
frappe.ui.form.on("Shopping Cart Settings", {
frappe.ui.form.on("E Commerce Settings", {
onload: function(frm) {
if(frm.doc.__onload && frm.doc.__onload.quotation_series) {
frm.fields_dict.quotation_series.df.options = frm.doc.__onload.quotation_series;
@ -23,6 +23,21 @@ frappe.ui.form.on("Shopping Cart Settings", {
</div>`
);
}
frappe.model.with_doctype("Item", () => {
const web_item_meta = frappe.get_meta('Website Item');
const valid_fields = web_item_meta.fields.filter(
df => ["Link", "Table MultiSelect"].includes(df.fieldtype) && !df.hidden
).map(df => ({ label: df.label, value: df.fieldname }));
frm.fields_dict.filter_fields.grid.update_docfield_property(
'fieldname', 'fieldtype', 'Select'
);
frm.fields_dict.filter_fields.grid.update_docfield_property(
'fieldname', 'options', valid_fields
);
});
},
enabled: function(frm) {
if (frm.doc.enabled === 1) {

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
# License: GNU General Public License v3. See license.txt
# -*- coding: utf-8 -*-
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import 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 ShoppingCartSettings(Document):
class ECommerceSettings(Document):
def onload(self):
self.get("__onload").quotation_series = frappe.get_meta("Quotation").get_options("naming_series")
self.is_redisearch_loaded = is_search_module_loaded()
def validate(self):
self.validate_field_filters()
self.validate_attribute_filters()
self.validate_checkout()
self.validate_search_index_fields()
if self.enabled:
self.validate_price_list_exchange_rate()
frappe.clear_document_cache("E Commerce Settings", "E Commerce Settings")
def validate_field_filters(self):
if not (self.enable_field_filters and self.filter_fields):
return
item_meta = frappe.get_meta("Item")
valid_fields = [df.fieldname for df in item_meta.fields if df.fieldtype in ["Link", "Table MultiSelect"]]
for f in self.filter_fields:
if f.fieldname not in valid_fields:
frappe.throw(_("Filter Fields Row #{0}: Fieldname <b>{1}</b> must be of type 'Link' or 'Table MultiSelect'").format(f.idx, f.fieldname))
def validate_attribute_filters(self):
if not (self.enable_attribute_filters and self.filter_attributes):
return
# if attribute filters are enabled, hide_variants should be disabled
self.hide_variants = 0
def validate_checkout(self):
if self.enable_checkout and not self.payment_gateway_account:
self.enable_checkout = 0
def validate_search_index_fields(self):
if not self.search_index_fields:
return
fields = self.search_index_fields.replace(' ', '')
fields = unique(fields.strip(',').split(',')) # Remove extra ',' and remove duplicates
# All fields should be indexable
allowed_indexable_fields = get_indexable_web_fields()
if not (set(fields).issubset(allowed_indexable_fields)):
invalid_fields = list(set(fields).difference(allowed_indexable_fields))
num_invalid_fields = len(invalid_fields)
invalid_fields = comma_and(invalid_fields)
if num_invalid_fields > 1:
frappe.throw(_("{0} are not valid options for Search Index Field.").format(frappe.bold(invalid_fields)))
else:
frappe.throw(_("{0} is not a valid option for Search Index Field.").format(frappe.bold(invalid_fields)))
self.search_index_fields = ','.join(fields)
def validate_price_list_exchange_rate(self):
"Check if exchange rate exists for Price List currency (to Company's currency)."
from erpnext.setup.utils import get_exchange_rate
@ -60,12 +116,23 @@ class ShoppingCartSettings(Document):
def get_shipping_rules(self, shipping_territory):
return self.get_name_from_territory(shipping_territory, "shipping_rules", "shipping_rule")
def on_change(self):
old_doc = self.get_doc_before_save()
if old_doc:
old_fields = old_doc.search_index_fields
new_fields = self.search_index_fields
# if search index fields get changed
if not (new_fields == old_fields):
create_website_items_index()
def validate_cart_settings(doc=None, method=None):
frappe.get_doc("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():
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

View File

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

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
}

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