Merge branch 'develop' of https://github.com/frappe/erpnext into dev_remove_regional_france
This commit is contained in:
commit
7657b721bc
2
.github/helper/install.sh
vendored
2
.github/helper/install.sh
vendored
@ -68,6 +68,6 @@ if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
|
||||
wait $wkpid
|
||||
|
||||
bench start &> bench_run_logs.txt &
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
|
64
.github/workflows/patch.yml
vendored
64
.github/workflows/patch.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.3
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
@ -45,9 +45,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: "actions/setup-python@v4"
|
||||
with:
|
||||
python-version: |
|
||||
3.7
|
||||
3.10
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
@ -102,40 +100,60 @@ jobs:
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
wget https://erpnext.com/files/v10-erpnext.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v10-erpnext.sql.gz
|
||||
bench remove-app payments --force
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
wget https://erpnext.com/files/v13-erpnext.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
for version in $(seq 12 13)
|
||||
do
|
||||
echo "Updating to v$version"
|
||||
branch_name="version-$version-hotfix"
|
||||
|
||||
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
|
||||
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
|
||||
function update_to_version() {
|
||||
version=$1
|
||||
|
||||
git -C "apps/frappe" checkout -q -f $branch_name
|
||||
git -C "apps/erpnext" checkout -q -f $branch_name
|
||||
branch_name="version-$version-hotfix"
|
||||
echo "Updating to v$version"
|
||||
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench setup env --python python3.7
|
||||
bench pip install -e ./apps/payments
|
||||
bench pip install -e ./apps/erpnext
|
||||
# Fetch and checkout branches
|
||||
git -C "apps/frappe" fetch --depth 1 upstream $branch_name:$branch_name
|
||||
git -C "apps/erpnext" fetch --depth 1 upstream $branch_name:$branch_name
|
||||
git -C "apps/frappe" checkout -q -f $branch_name
|
||||
git -C "apps/erpnext" checkout -q -f $branch_name
|
||||
|
||||
bench --site test_site migrate
|
||||
done
|
||||
# Resetup env and install apps
|
||||
pgrep honcho | xargs kill
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
}
|
||||
|
||||
update_to_version 14
|
||||
|
||||
echo "Updating to latest version"
|
||||
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
|
||||
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
|
||||
|
||||
pgrep honcho | xargs kill
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env --python python3.10
|
||||
bench pip install -e ./apps/payments
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
bench --site test_site install-app payments
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cd ~/frappe-bench
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ./*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
4
.github/workflows/server-tests-mariadb.yml
vendored
4
.github/workflows/server-tests-mariadb.yml
vendored
@ -123,6 +123,10 @@ jobs:
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
|
@ -137,9 +137,6 @@ frappe.ui.form.on("Account", {
|
||||
args: {
|
||||
old: frm.doc.name,
|
||||
new: data.name,
|
||||
is_group: frm.doc.is_group,
|
||||
root_type: frm.doc.root_type,
|
||||
company: frm.doc.company,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (!r.exc) {
|
||||
|
@ -18,6 +18,10 @@ class BalanceMismatchError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAccountMergeError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class Account(NestedSet):
|
||||
nsm_parent_field = "parent_account"
|
||||
|
||||
@ -460,25 +464,34 @@ def update_account_number(name, account_name, account_number=None, from_descenda
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def merge_account(old, new, is_group, root_type, company):
|
||||
def merge_account(old, new):
|
||||
# Validate properties before merging
|
||||
new_account = frappe.get_cached_doc("Account", new)
|
||||
old_account = frappe.get_cached_doc("Account", old)
|
||||
|
||||
if not new_account:
|
||||
throw(_("Account {0} does not exist").format(new))
|
||||
|
||||
if (new_account.is_group, new_account.root_type, new_account.company) != (
|
||||
cint(is_group),
|
||||
root_type,
|
||||
company,
|
||||
if (
|
||||
cint(new_account.is_group),
|
||||
new_account.root_type,
|
||||
new_account.company,
|
||||
cstr(new_account.account_currency),
|
||||
) != (
|
||||
cint(old_account.is_group),
|
||||
old_account.root_type,
|
||||
old_account.company,
|
||||
cstr(old_account.account_currency),
|
||||
):
|
||||
throw(
|
||||
_(
|
||||
"""Merging is only possible if following properties are same in both records. Is Group, Root Type, Company"""
|
||||
)
|
||||
msg=_(
|
||||
"""Merging is only possible if following properties are same in both records. Is Group, Root Type, Company and Account Currency"""
|
||||
),
|
||||
title=("Invalid Accounts"),
|
||||
exc=InvalidAccountMergeError,
|
||||
)
|
||||
|
||||
if is_group and new_account.parent_account == old:
|
||||
if old_account.is_group and new_account.parent_account == old:
|
||||
new_account.db_set("parent_account", frappe.get_cached_value("Account", old, "parent_account"))
|
||||
|
||||
frappe.rename_doc("Account", old, new, merge=1, force=1)
|
||||
|
@ -1,289 +0,0 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
"""
|
||||
Import chart of accounts from OpenERP sources
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
import frappe
|
||||
from frappe.utils.csvutils import read_csv_content
|
||||
|
||||
path = "/Users/nabinhait/projects/odoo/addons"
|
||||
|
||||
accounts = {}
|
||||
charts = {}
|
||||
all_account_types = []
|
||||
all_roots = {}
|
||||
|
||||
|
||||
def go():
|
||||
global accounts, charts
|
||||
default_account_types = get_default_account_types()
|
||||
|
||||
country_dirs = []
|
||||
for basepath, folders, files in os.walk(path):
|
||||
basename = os.path.basename(basepath)
|
||||
if basename.startswith("l10n_"):
|
||||
country_dirs.append(basename)
|
||||
|
||||
for country_dir in country_dirs:
|
||||
accounts, charts = {}, {}
|
||||
country_path = os.path.join(path, country_dir)
|
||||
manifest = ast.literal_eval(open(os.path.join(country_path, "__openerp__.py")).read())
|
||||
data_files = (
|
||||
manifest.get("data", []) + manifest.get("init_xml", []) + manifest.get("update_xml", [])
|
||||
)
|
||||
files_path = [os.path.join(country_path, d) for d in data_files]
|
||||
xml_roots = get_xml_roots(files_path)
|
||||
csv_content = get_csv_contents(files_path)
|
||||
prefix = country_dir if csv_content else None
|
||||
account_types = get_account_types(
|
||||
xml_roots.get("account.account.type", []), csv_content.get("account.account.type", []), prefix
|
||||
)
|
||||
account_types.update(default_account_types)
|
||||
|
||||
if xml_roots:
|
||||
make_maps_for_xml(xml_roots, account_types, country_dir)
|
||||
|
||||
if csv_content:
|
||||
make_maps_for_csv(csv_content, account_types, country_dir)
|
||||
make_account_trees()
|
||||
make_charts()
|
||||
|
||||
create_all_roots_file()
|
||||
|
||||
|
||||
def get_default_account_types():
|
||||
default_types_root = []
|
||||
default_types_root.append(
|
||||
ET.parse(os.path.join(path, "account", "data", "data_account_type.xml")).getroot()
|
||||
)
|
||||
return get_account_types(default_types_root, None, prefix="account")
|
||||
|
||||
|
||||
def get_xml_roots(files_path):
|
||||
xml_roots = frappe._dict()
|
||||
for filepath in files_path:
|
||||
fname = os.path.basename(filepath)
|
||||
if fname.endswith(".xml"):
|
||||
tree = ET.parse(filepath)
|
||||
root = tree.getroot()
|
||||
for node in root[0].findall("record"):
|
||||
if node.get("model") in [
|
||||
"account.account.template",
|
||||
"account.chart.template",
|
||||
"account.account.type",
|
||||
]:
|
||||
xml_roots.setdefault(node.get("model"), []).append(root)
|
||||
break
|
||||
return xml_roots
|
||||
|
||||
|
||||
def get_csv_contents(files_path):
|
||||
csv_content = {}
|
||||
for filepath in files_path:
|
||||
fname = os.path.basename(filepath)
|
||||
for file_type in ["account.account.template", "account.account.type", "account.chart.template"]:
|
||||
if fname.startswith(file_type) and fname.endswith(".csv"):
|
||||
with open(filepath, "r") as csvfile:
|
||||
try:
|
||||
csv_content.setdefault(file_type, []).append(read_csv_content(csvfile.read()))
|
||||
except Exception as e:
|
||||
continue
|
||||
return csv_content
|
||||
|
||||
|
||||
def get_account_types(root_list, csv_content, prefix=None):
|
||||
types = {}
|
||||
account_type_map = {
|
||||
"cash": "Cash",
|
||||
"bank": "Bank",
|
||||
"tr_cash": "Cash",
|
||||
"tr_bank": "Bank",
|
||||
"receivable": "Receivable",
|
||||
"tr_receivable": "Receivable",
|
||||
"account rec": "Receivable",
|
||||
"payable": "Payable",
|
||||
"tr_payable": "Payable",
|
||||
"equity": "Equity",
|
||||
"stocks": "Stock",
|
||||
"stock": "Stock",
|
||||
"tax": "Tax",
|
||||
"tr_tax": "Tax",
|
||||
"tax-out": "Tax",
|
||||
"tax-in": "Tax",
|
||||
"charges_personnel": "Chargeable",
|
||||
"fixed asset": "Fixed Asset",
|
||||
"cogs": "Cost of Goods Sold",
|
||||
}
|
||||
for root in root_list:
|
||||
for node in root[0].findall("record"):
|
||||
if node.get("model") == "account.account.type":
|
||||
data = {}
|
||||
for field in node.findall("field"):
|
||||
if (
|
||||
field.get("name") == "code"
|
||||
and field.text.lower() != "none"
|
||||
and account_type_map.get(field.text)
|
||||
):
|
||||
data["account_type"] = account_type_map[field.text]
|
||||
|
||||
node_id = prefix + "." + node.get("id") if prefix else node.get("id")
|
||||
types[node_id] = data
|
||||
|
||||
if csv_content and csv_content[0][0] == "id":
|
||||
for row in csv_content[1:]:
|
||||
row_dict = dict(zip(csv_content[0], row))
|
||||
data = {}
|
||||
if row_dict.get("code") and account_type_map.get(row_dict["code"]):
|
||||
data["account_type"] = account_type_map[row_dict["code"]]
|
||||
if data and data.get("id"):
|
||||
node_id = prefix + "." + data.get("id") if prefix else data.get("id")
|
||||
types[node_id] = data
|
||||
return types
|
||||
|
||||
|
||||
def make_maps_for_xml(xml_roots, account_types, country_dir):
|
||||
"""make maps for `charts` and `accounts`"""
|
||||
for model, root_list in xml_roots.items():
|
||||
for root in root_list:
|
||||
for node in root[0].findall("record"):
|
||||
if node.get("model") == "account.account.template":
|
||||
data = {}
|
||||
for field in node.findall("field"):
|
||||
if field.get("name") == "name":
|
||||
data["name"] = field.text
|
||||
if field.get("name") == "parent_id":
|
||||
parent_id = field.get("ref") or field.get("eval")
|
||||
data["parent_id"] = parent_id
|
||||
|
||||
if field.get("name") == "user_type":
|
||||
value = field.get("ref")
|
||||
if account_types.get(value, {}).get("account_type"):
|
||||
data["account_type"] = account_types[value]["account_type"]
|
||||
if data["account_type"] not in all_account_types:
|
||||
all_account_types.append(data["account_type"])
|
||||
|
||||
data["children"] = []
|
||||
accounts[node.get("id")] = data
|
||||
|
||||
if node.get("model") == "account.chart.template":
|
||||
data = {}
|
||||
for field in node.findall("field"):
|
||||
if field.get("name") == "name":
|
||||
data["name"] = field.text
|
||||
if field.get("name") == "account_root_id":
|
||||
data["account_root_id"] = field.get("ref")
|
||||
data["id"] = country_dir
|
||||
charts.setdefault(node.get("id"), {}).update(data)
|
||||
|
||||
|
||||
def make_maps_for_csv(csv_content, account_types, country_dir):
|
||||
for content in csv_content.get("account.account.template", []):
|
||||
for row in content[1:]:
|
||||
data = dict(zip(content[0], row))
|
||||
account = {
|
||||
"name": data.get("name"),
|
||||
"parent_id": data.get("parent_id:id") or data.get("parent_id/id"),
|
||||
"children": [],
|
||||
}
|
||||
user_type = data.get("user_type/id") or data.get("user_type:id")
|
||||
if account_types.get(user_type, {}).get("account_type"):
|
||||
account["account_type"] = account_types[user_type]["account_type"]
|
||||
if account["account_type"] not in all_account_types:
|
||||
all_account_types.append(account["account_type"])
|
||||
|
||||
accounts[data.get("id")] = account
|
||||
if not account.get("parent_id") and data.get("chart_template_id:id"):
|
||||
chart_id = data.get("chart_template_id:id")
|
||||
charts.setdefault(chart_id, {}).update({"account_root_id": data.get("id")})
|
||||
|
||||
for content in csv_content.get("account.chart.template", []):
|
||||
for row in content[1:]:
|
||||
if row:
|
||||
data = dict(zip(content[0], row))
|
||||
charts.setdefault(data.get("id"), {}).update(
|
||||
{
|
||||
"account_root_id": data.get("account_root_id:id") or data.get("account_root_id/id"),
|
||||
"name": data.get("name"),
|
||||
"id": country_dir,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def make_account_trees():
|
||||
"""build tree hierarchy"""
|
||||
for id in accounts.keys():
|
||||
account = accounts[id]
|
||||
|
||||
if account.get("parent_id"):
|
||||
if accounts.get(account["parent_id"]):
|
||||
# accounts[account["parent_id"]]["children"].append(account)
|
||||
accounts[account["parent_id"]][account["name"]] = account
|
||||
del account["parent_id"]
|
||||
del account["name"]
|
||||
|
||||
# remove empty children
|
||||
for id in accounts.keys():
|
||||
if "children" in accounts[id] and not accounts[id].get("children"):
|
||||
del accounts[id]["children"]
|
||||
|
||||
|
||||
def make_charts():
|
||||
"""write chart files in app/setup/doctype/company/charts"""
|
||||
for chart_id in charts:
|
||||
src = charts[chart_id]
|
||||
if not src.get("name") or not src.get("account_root_id"):
|
||||
continue
|
||||
|
||||
if not src["account_root_id"] in accounts:
|
||||
continue
|
||||
|
||||
filename = src["id"][5:] + "_" + chart_id
|
||||
|
||||
print("building " + filename)
|
||||
chart = {}
|
||||
chart["name"] = src["name"]
|
||||
chart["country_code"] = src["id"][5:]
|
||||
chart["tree"] = accounts[src["account_root_id"]]
|
||||
|
||||
for key, val in chart["tree"].items():
|
||||
if key in ["name", "parent_id"]:
|
||||
chart["tree"].pop(key)
|
||||
if type(val) == dict:
|
||||
val["root_type"] = ""
|
||||
if chart:
|
||||
fpath = os.path.join(
|
||||
"erpnext", "erpnext", "accounts", "doctype", "account", "chart_of_accounts", filename + ".json"
|
||||
)
|
||||
|
||||
with open(fpath, "r") as chartfile:
|
||||
old_content = chartfile.read()
|
||||
if not old_content or (
|
||||
json.loads(old_content).get("is_active", "No") == "No"
|
||||
and json.loads(old_content).get("disabled", "No") == "No"
|
||||
):
|
||||
with open(fpath, "w") as chartfile:
|
||||
chartfile.write(json.dumps(chart, indent=4, sort_keys=True))
|
||||
|
||||
all_roots.setdefault(filename, chart["tree"].keys())
|
||||
|
||||
|
||||
def create_all_roots_file():
|
||||
with open("all_roots.txt", "w") as f:
|
||||
for filename, roots in sorted(all_roots.items()):
|
||||
f.write(filename)
|
||||
f.write("\n----------------------\n")
|
||||
for r in sorted(roots):
|
||||
f.write(r.encode("utf-8"))
|
||||
f.write("\n")
|
||||
f.write("\n\n\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
go()
|
@ -7,7 +7,11 @@ import unittest
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
|
||||
from erpnext.accounts.doctype.account.account import merge_account, update_account_number
|
||||
from erpnext.accounts.doctype.account.account import (
|
||||
InvalidAccountMergeError,
|
||||
merge_account,
|
||||
update_account_number,
|
||||
)
|
||||
from erpnext.stock import get_company_default_inventory_account, get_warehouse_account
|
||||
|
||||
test_dependencies = ["Company"]
|
||||
@ -47,49 +51,53 @@ class TestAccount(unittest.TestCase):
|
||||
frappe.delete_doc("Account", "1211-11-4 - 6 - Debtors 1 - Test - - _TC")
|
||||
|
||||
def test_merge_account(self):
|
||||
if not frappe.db.exists("Account", "Current Assets - _TC"):
|
||||
acc = frappe.new_doc("Account")
|
||||
acc.account_name = "Current Assets"
|
||||
acc.is_group = 1
|
||||
acc.parent_account = "Application of Funds (Assets) - _TC"
|
||||
acc.company = "_Test Company"
|
||||
acc.insert()
|
||||
if not frappe.db.exists("Account", "Securities and Deposits - _TC"):
|
||||
acc = frappe.new_doc("Account")
|
||||
acc.account_name = "Securities and Deposits"
|
||||
acc.parent_account = "Current Assets - _TC"
|
||||
acc.is_group = 1
|
||||
acc.company = "_Test Company"
|
||||
acc.insert()
|
||||
if not frappe.db.exists("Account", "Earnest Money - _TC"):
|
||||
acc = frappe.new_doc("Account")
|
||||
acc.account_name = "Earnest Money"
|
||||
acc.parent_account = "Securities and Deposits - _TC"
|
||||
acc.company = "_Test Company"
|
||||
acc.insert()
|
||||
if not frappe.db.exists("Account", "Cash In Hand - _TC"):
|
||||
acc = frappe.new_doc("Account")
|
||||
acc.account_name = "Cash In Hand"
|
||||
acc.is_group = 1
|
||||
acc.parent_account = "Current Assets - _TC"
|
||||
acc.company = "_Test Company"
|
||||
acc.insert()
|
||||
if not frappe.db.exists("Account", "Accumulated Depreciation - _TC"):
|
||||
acc = frappe.new_doc("Account")
|
||||
acc.account_name = "Accumulated Depreciation"
|
||||
acc.parent_account = "Fixed Assets - _TC"
|
||||
acc.company = "_Test Company"
|
||||
acc.account_type = "Accumulated Depreciation"
|
||||
acc.insert()
|
||||
create_account(
|
||||
account_name="Current Assets",
|
||||
is_group=1,
|
||||
parent_account="Application of Funds (Assets) - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
create_account(
|
||||
account_name="Securities and Deposits",
|
||||
is_group=1,
|
||||
parent_account="Current Assets - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
create_account(
|
||||
account_name="Earnest Money",
|
||||
parent_account="Securities and Deposits - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
create_account(
|
||||
account_name="Cash In Hand",
|
||||
is_group=1,
|
||||
parent_account="Current Assets - _TC",
|
||||
company="_Test Company",
|
||||
)
|
||||
|
||||
create_account(
|
||||
account_name="Receivable INR",
|
||||
parent_account="Current Assets - _TC",
|
||||
company="_Test Company",
|
||||
account_currency="INR",
|
||||
)
|
||||
|
||||
create_account(
|
||||
account_name="Receivable USD",
|
||||
parent_account="Current Assets - _TC",
|
||||
company="_Test Company",
|
||||
account_currency="USD",
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Account", "Securities and Deposits - _TC")
|
||||
parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account")
|
||||
|
||||
self.assertEqual(parent, "Securities and Deposits - _TC")
|
||||
|
||||
merge_account(
|
||||
"Securities and Deposits - _TC", "Cash In Hand - _TC", doc.is_group, doc.root_type, doc.company
|
||||
)
|
||||
merge_account("Securities and Deposits - _TC", "Cash In Hand - _TC")
|
||||
|
||||
parent = frappe.db.get_value("Account", "Earnest Money - _TC", "parent_account")
|
||||
|
||||
# Parent account of the child account changes after merging
|
||||
@ -98,30 +106,28 @@ class TestAccount(unittest.TestCase):
|
||||
# Old account doesn't exist after merging
|
||||
self.assertFalse(frappe.db.exists("Account", "Securities and Deposits - _TC"))
|
||||
|
||||
doc = frappe.get_doc("Account", "Current Assets - _TC")
|
||||
|
||||
# Raise error as is_group property doesn't match
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
InvalidAccountMergeError,
|
||||
merge_account,
|
||||
"Current Assets - _TC",
|
||||
"Accumulated Depreciation - _TC",
|
||||
doc.is_group,
|
||||
doc.root_type,
|
||||
doc.company,
|
||||
)
|
||||
|
||||
doc = frappe.get_doc("Account", "Capital Stock - _TC")
|
||||
|
||||
# Raise error as root_type property doesn't match
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
InvalidAccountMergeError,
|
||||
merge_account,
|
||||
"Capital Stock - _TC",
|
||||
"Softwares - _TC",
|
||||
doc.is_group,
|
||||
doc.root_type,
|
||||
doc.company,
|
||||
)
|
||||
|
||||
# Raise error as currency doesn't match
|
||||
self.assertRaises(
|
||||
InvalidAccountMergeError,
|
||||
merge_account,
|
||||
"Receivable INR - _TC",
|
||||
"Receivable USD - _TC",
|
||||
)
|
||||
|
||||
def test_account_sync(self):
|
||||
@ -400,11 +406,20 @@ def create_account(**kwargs):
|
||||
"Account", filters={"account_name": kwargs.get("account_name"), "company": kwargs.get("company")}
|
||||
)
|
||||
if account:
|
||||
return account
|
||||
account = frappe.get_doc("Account", account)
|
||||
account.update(
|
||||
dict(
|
||||
is_group=kwargs.get("is_group", 0),
|
||||
parent_account=kwargs.get("parent_account"),
|
||||
)
|
||||
)
|
||||
account.save()
|
||||
return account.name
|
||||
else:
|
||||
account = frappe.get_doc(
|
||||
dict(
|
||||
doctype="Account",
|
||||
is_group=kwargs.get("is_group", 0),
|
||||
account_name=kwargs.get("account_name"),
|
||||
account_type=kwargs.get("account_type"),
|
||||
parent_account=kwargs.get("parent_account"),
|
||||
|
@ -265,20 +265,21 @@ def get_dimension_with_children(doctype, dimensions):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dimensions(with_cost_center_and_project=False):
|
||||
dimension_filters = frappe.db.sql(
|
||||
"""
|
||||
SELECT label, fieldname, document_type
|
||||
FROM `tabAccounting Dimension`
|
||||
WHERE disabled = 0
|
||||
""",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
default_dimensions = frappe.db.sql(
|
||||
"""SELECT p.fieldname, c.company, c.default_dimension
|
||||
FROM `tabAccounting Dimension Detail` c, `tabAccounting Dimension` p
|
||||
WHERE c.parent = p.name""",
|
||||
as_dict=1,
|
||||
c = frappe.qb.DocType("Accounting Dimension Detail")
|
||||
p = frappe.qb.DocType("Accounting Dimension")
|
||||
dimension_filters = (
|
||||
frappe.qb.from_(p)
|
||||
.select(p.label, p.fieldname, p.document_type)
|
||||
.where(p.disabled == 0)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
default_dimensions = (
|
||||
frappe.qb.from_(c)
|
||||
.inner_join(p)
|
||||
.on(c.parent == p.name)
|
||||
.select(p.fieldname, c.company, c.default_dimension)
|
||||
.run(as_dict=1)
|
||||
)
|
||||
|
||||
if isinstance(with_cost_center_and_project, str):
|
||||
|
@ -84,12 +84,22 @@ def create_dimension():
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
if not frappe.db.exists("Accounting Dimension", {"document_type": "Department"}):
|
||||
frappe.get_doc(
|
||||
dimension = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Accounting Dimension",
|
||||
"document_type": "Department",
|
||||
}
|
||||
).insert()
|
||||
)
|
||||
dimension.append(
|
||||
"dimension_defaults",
|
||||
{
|
||||
"company": "_Test Company",
|
||||
"reference_document": "Department",
|
||||
"default_dimension": "_Test Department - _TC",
|
||||
},
|
||||
)
|
||||
dimension.insert()
|
||||
dimension.save()
|
||||
else:
|
||||
dimension = frappe.get_doc("Accounting Dimension", "Department")
|
||||
dimension.disabled = 0
|
||||
|
@ -13,6 +13,7 @@
|
||||
"account_type",
|
||||
"account_subtype",
|
||||
"column_break_7",
|
||||
"disabled",
|
||||
"is_default",
|
||||
"is_company_account",
|
||||
"company",
|
||||
@ -199,10 +200,16 @@
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "Branch Code"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2022-05-04 15:49:42.620630",
|
||||
"modified": "2023-09-22 21:31:34.763977",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
|
@ -7,7 +7,9 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.utils import cint, flt
|
||||
from pypika.terms import Parameter
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
from erpnext.accounts.doctype.bank_transaction.bank_transaction import get_total_allocated_amount
|
||||
@ -15,7 +17,7 @@ from erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_s
|
||||
get_amounts_not_reflected_in_system,
|
||||
get_entries,
|
||||
)
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
from erpnext.accounts.utils import get_account_currency, get_balance_on
|
||||
|
||||
|
||||
class BankReconciliationTool(Document):
|
||||
@ -283,68 +285,68 @@ def auto_reconcile_vouchers(
|
||||
to_reference_date=None,
|
||||
):
|
||||
frappe.flags.auto_reconcile_vouchers = True
|
||||
document_types = ["payment_entry", "journal_entry"]
|
||||
reconciled, partially_reconciled = set(), set()
|
||||
|
||||
bank_transactions = get_bank_transactions(bank_account)
|
||||
matched_transaction = []
|
||||
for transaction in bank_transactions:
|
||||
linked_payments = get_linked_payments(
|
||||
transaction.name,
|
||||
document_types,
|
||||
["payment_entry", "journal_entry"],
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date,
|
||||
from_reference_date,
|
||||
to_reference_date,
|
||||
)
|
||||
vouchers = []
|
||||
for r in linked_payments:
|
||||
vouchers.append(
|
||||
{
|
||||
"payment_doctype": r[1],
|
||||
"payment_name": r[2],
|
||||
"amount": r[4],
|
||||
}
|
||||
)
|
||||
transaction = frappe.get_doc("Bank Transaction", transaction.name)
|
||||
account = frappe.db.get_value("Bank Account", transaction.bank_account, "account")
|
||||
matched_trans = 0
|
||||
for voucher in vouchers:
|
||||
gl_entry = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
dict(
|
||||
account=account, voucher_type=voucher["payment_doctype"], voucher_no=voucher["payment_name"]
|
||||
),
|
||||
["credit", "debit"],
|
||||
as_dict=1,
|
||||
)
|
||||
gl_amount, transaction_amount = (
|
||||
(gl_entry.credit, transaction.deposit)
|
||||
if gl_entry.credit > 0
|
||||
else (gl_entry.debit, transaction.withdrawal)
|
||||
)
|
||||
allocated_amount = gl_amount if gl_amount >= transaction_amount else transaction_amount
|
||||
transaction.append(
|
||||
"payment_entries",
|
||||
{
|
||||
"payment_document": voucher["payment_doctype"],
|
||||
"payment_entry": voucher["payment_name"],
|
||||
"allocated_amount": allocated_amount,
|
||||
|
||||
if not linked_payments:
|
||||
continue
|
||||
|
||||
vouchers = list(
|
||||
map(
|
||||
lambda entry: {
|
||||
"payment_doctype": entry.get("doctype"),
|
||||
"payment_name": entry.get("name"),
|
||||
"amount": entry.get("paid_amount"),
|
||||
},
|
||||
linked_payments,
|
||||
)
|
||||
matched_transaction.append(str(transaction.name))
|
||||
transaction.save()
|
||||
transaction.update_allocations()
|
||||
matched_transaction_len = len(set(matched_transaction))
|
||||
if matched_transaction_len == 0:
|
||||
frappe.msgprint(_("No matching references found for auto reconciliation"))
|
||||
elif matched_transaction_len == 1:
|
||||
frappe.msgprint(_("{0} transaction is reconcilied").format(matched_transaction_len))
|
||||
else:
|
||||
frappe.msgprint(_("{0} transactions are reconcilied").format(matched_transaction_len))
|
||||
)
|
||||
|
||||
updated_transaction = reconcile_vouchers(transaction.name, json.dumps(vouchers))
|
||||
|
||||
if updated_transaction.status == "Reconciled":
|
||||
reconciled.add(updated_transaction.name)
|
||||
elif flt(transaction.unallocated_amount) != flt(updated_transaction.unallocated_amount):
|
||||
# Partially reconciled (status = Unreconciled & unallocated amount changed)
|
||||
partially_reconciled.add(updated_transaction.name)
|
||||
|
||||
alert_message, indicator = get_auto_reconcile_message(partially_reconciled, reconciled)
|
||||
frappe.msgprint(title=_("Auto Reconciliation"), msg=alert_message, indicator=indicator)
|
||||
|
||||
frappe.flags.auto_reconcile_vouchers = False
|
||||
return reconciled, partially_reconciled
|
||||
|
||||
return frappe.get_doc("Bank Transaction", transaction.name)
|
||||
|
||||
def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
"""Returns alert message and indicator for auto reconciliation depending on result state."""
|
||||
alert_message, indicator = "", "blue"
|
||||
if not partially_reconciled and not reconciled:
|
||||
alert_message = _("No matches occurred via auto reconciliation")
|
||||
return alert_message, indicator
|
||||
|
||||
indicator = "green"
|
||||
if reconciled:
|
||||
alert_message += _("{0} Transaction(s) Reconciled").format(len(reconciled))
|
||||
alert_message += "<br>"
|
||||
|
||||
if partially_reconciled:
|
||||
alert_message += _("{0} {1} Partially Reconciled").format(
|
||||
len(partially_reconciled),
|
||||
_("Transactions") if len(partially_reconciled) > 1 else _("Transaction"),
|
||||
)
|
||||
|
||||
return alert_message, indicator
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -390,19 +392,13 @@ def subtract_allocations(gl_account, vouchers):
|
||||
"Look up & subtract any existing Bank Transaction allocations"
|
||||
copied = []
|
||||
for voucher in vouchers:
|
||||
rows = get_total_allocated_amount(voucher[1], voucher[2])
|
||||
amount = None
|
||||
for row in rows:
|
||||
if row["gl_account"] == gl_account:
|
||||
amount = row["total"]
|
||||
break
|
||||
rows = get_total_allocated_amount(voucher.get("doctype"), voucher.get("name"))
|
||||
filtered_row = list(filter(lambda row: row.get("gl_account") == gl_account, rows))
|
||||
|
||||
if amount:
|
||||
l = list(voucher)
|
||||
l[3] -= amount
|
||||
copied.append(tuple(l))
|
||||
else:
|
||||
copied.append(voucher)
|
||||
if amount := None if not filtered_row else filtered_row[0]["total"]:
|
||||
voucher["paid_amount"] -= amount
|
||||
|
||||
copied.append(voucher)
|
||||
return copied
|
||||
|
||||
|
||||
@ -418,6 +414,18 @@ def check_matching(
|
||||
to_reference_date,
|
||||
):
|
||||
exact_match = True if "exact_match" in document_types else False
|
||||
queries = get_queries(
|
||||
bank_account,
|
||||
company,
|
||||
transaction,
|
||||
document_types,
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date,
|
||||
from_reference_date,
|
||||
to_reference_date,
|
||||
exact_match,
|
||||
)
|
||||
|
||||
filters = {
|
||||
"amount": transaction.unallocated_amount,
|
||||
@ -429,30 +437,15 @@ def check_matching(
|
||||
}
|
||||
|
||||
matching_vouchers = []
|
||||
for query in queries:
|
||||
matching_vouchers.extend(frappe.db.sql(query, filters, as_dict=True))
|
||||
|
||||
# get matching vouchers from all the apps
|
||||
for method_name in frappe.get_hooks("get_matching_vouchers_for_bank_reconciliation"):
|
||||
matching_vouchers.extend(
|
||||
frappe.get_attr(method_name)(
|
||||
bank_account,
|
||||
company,
|
||||
transaction,
|
||||
document_types,
|
||||
from_date,
|
||||
to_date,
|
||||
filter_by_reference_date,
|
||||
from_reference_date,
|
||||
to_reference_date,
|
||||
exact_match,
|
||||
filters,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
return sorted(matching_vouchers, key=lambda x: x[0], reverse=True) if matching_vouchers else []
|
||||
return (
|
||||
sorted(matching_vouchers, key=lambda x: x["rank"], reverse=True) if matching_vouchers else []
|
||||
)
|
||||
|
||||
|
||||
def get_matching_vouchers_for_bank_reconciliation(
|
||||
def get_queries(
|
||||
bank_account,
|
||||
company,
|
||||
transaction,
|
||||
@ -463,7 +456,6 @@ def get_matching_vouchers_for_bank_reconciliation(
|
||||
from_reference_date,
|
||||
to_reference_date,
|
||||
exact_match,
|
||||
filters,
|
||||
):
|
||||
# get queries to get matching vouchers
|
||||
account_from_to = "paid_to" if transaction.deposit > 0.0 else "paid_from"
|
||||
@ -488,17 +480,7 @@ def get_matching_vouchers_for_bank_reconciliation(
|
||||
or []
|
||||
)
|
||||
|
||||
vouchers = []
|
||||
|
||||
for query in queries:
|
||||
vouchers.extend(
|
||||
frappe.db.sql(
|
||||
query,
|
||||
filters,
|
||||
)
|
||||
)
|
||||
|
||||
return vouchers
|
||||
return queries
|
||||
|
||||
|
||||
def get_matching_queries(
|
||||
@ -515,6 +497,8 @@ def get_matching_queries(
|
||||
to_reference_date,
|
||||
):
|
||||
queries = []
|
||||
currency = get_account_currency(bank_account)
|
||||
|
||||
if "payment_entry" in document_types:
|
||||
query = get_pe_matching_query(
|
||||
exact_match,
|
||||
@ -541,12 +525,12 @@ def get_matching_queries(
|
||||
queries.append(query)
|
||||
|
||||
if transaction.deposit > 0.0 and "sales_invoice" in document_types:
|
||||
query = get_si_matching_query(exact_match)
|
||||
query = get_si_matching_query(exact_match, currency)
|
||||
queries.append(query)
|
||||
|
||||
if transaction.withdrawal > 0.0:
|
||||
if "purchase_invoice" in document_types:
|
||||
query = get_pi_matching_query(exact_match)
|
||||
query = get_pi_matching_query(exact_match, currency)
|
||||
queries.append(query)
|
||||
|
||||
if "bank_transaction" in document_types:
|
||||
@ -560,33 +544,48 @@ def get_bt_matching_query(exact_match, transaction):
|
||||
# get matching bank transaction query
|
||||
# find bank transactions in the same bank account with opposite sign
|
||||
# same bank account must have same company and currency
|
||||
bt = frappe.qb.DocType("Bank Transaction")
|
||||
|
||||
field = "deposit" if transaction.withdrawal > 0.0 else "withdrawal"
|
||||
amount_equality = getattr(bt, field) == transaction.unallocated_amount
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else getattr(bt, field) > 0.0
|
||||
|
||||
return f"""
|
||||
ref_rank = (
|
||||
frappe.qb.terms.Case().when(bt.reference_number == transaction.reference_number, 1).else_(0)
|
||||
)
|
||||
unallocated_rank = (
|
||||
frappe.qb.terms.Case().when(bt.unallocated_amount == transaction.unallocated_amount, 1).else_(0)
|
||||
)
|
||||
|
||||
SELECT
|
||||
(CASE WHEN reference_number = %(reference_no)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN {field} = %(amount)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN ( party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
|
||||
+ CASE WHEN unallocated_amount = %(amount)s THEN 1 ELSE 0 END
|
||||
+ 1) AS rank,
|
||||
'Bank Transaction' AS doctype,
|
||||
name,
|
||||
unallocated_amount AS paid_amount,
|
||||
reference_number AS reference_no,
|
||||
date AS reference_date,
|
||||
party,
|
||||
party_type,
|
||||
date AS posting_date,
|
||||
currency
|
||||
FROM
|
||||
`tabBank Transaction`
|
||||
WHERE
|
||||
status != 'Reconciled'
|
||||
AND name != '{transaction.name}'
|
||||
AND bank_account = '{transaction.bank_account}'
|
||||
AND {field} {'= %(amount)s' if exact_match else '> 0.0'}
|
||||
"""
|
||||
party_condition = (
|
||||
(bt.party_type == transaction.party_type)
|
||||
& (bt.party == transaction.party)
|
||||
& bt.party.isnotnull()
|
||||
)
|
||||
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(bt)
|
||||
.select(
|
||||
(ref_rank + amount_rank + party_rank + unallocated_rank + 1).as_("rank"),
|
||||
ConstantColumn("Bank Transaction").as_("doctype"),
|
||||
bt.name,
|
||||
bt.unallocated_amount.as_("paid_amount"),
|
||||
bt.reference_number.as_("reference_no"),
|
||||
bt.date.as_("reference_date"),
|
||||
bt.party,
|
||||
bt.party_type,
|
||||
bt.date.as_("posting_date"),
|
||||
bt.currency,
|
||||
)
|
||||
.where(bt.status != "Reconciled")
|
||||
.where(bt.name != transaction.name)
|
||||
.where(bt.bank_account == transaction.bank_account)
|
||||
.where(amount_condition)
|
||||
.where(bt.docstatus == 1)
|
||||
)
|
||||
return str(query)
|
||||
|
||||
|
||||
def get_pe_matching_query(
|
||||
@ -600,45 +599,56 @@ def get_pe_matching_query(
|
||||
to_reference_date,
|
||||
):
|
||||
# get matching payment entries query
|
||||
if transaction.deposit > 0.0:
|
||||
currency_field = "paid_to_account_currency as currency"
|
||||
else:
|
||||
currency_field = "paid_from_account_currency as currency"
|
||||
filter_by_date = f"AND posting_date between '{from_date}' and '{to_date}'"
|
||||
order_by = " posting_date"
|
||||
filter_by_reference_no = ""
|
||||
to_from = "to" if transaction.deposit > 0.0 else "from"
|
||||
currency_field = f"paid_{to_from}_account_currency"
|
||||
payment_type = "Receive" if transaction.deposit > 0.0 else "Pay"
|
||||
pe = frappe.qb.DocType("Payment Entry")
|
||||
|
||||
ref_condition = pe.reference_no == transaction.reference_number
|
||||
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
|
||||
|
||||
amount_equality = pe.paid_amount == transaction.unallocated_amount
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else pe.paid_amount > 0.0
|
||||
|
||||
party_condition = (
|
||||
(pe.party_type == transaction.party_type)
|
||||
& (pe.party == transaction.party)
|
||||
& pe.party.isnotnull()
|
||||
)
|
||||
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
|
||||
|
||||
filter_by_date = pe.posting_date.between(from_date, to_date)
|
||||
if cint(filter_by_reference_date):
|
||||
filter_by_date = f"AND reference_date between '{from_reference_date}' and '{to_reference_date}'"
|
||||
order_by = " reference_date"
|
||||
filter_by_date = pe.reference_date.between(from_reference_date, to_reference_date)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(pe)
|
||||
.select(
|
||||
(ref_rank + amount_rank + party_rank + 1).as_("rank"),
|
||||
ConstantColumn("Payment Entry").as_("doctype"),
|
||||
pe.name,
|
||||
pe.paid_amount,
|
||||
pe.reference_no,
|
||||
pe.reference_date,
|
||||
pe.party,
|
||||
pe.party_type,
|
||||
pe.posting_date,
|
||||
getattr(pe, currency_field).as_("currency"),
|
||||
)
|
||||
.where(pe.docstatus == 1)
|
||||
.where(pe.payment_type.isin([payment_type, "Internal Transfer"]))
|
||||
.where(pe.clearance_date.isnull())
|
||||
.where(getattr(pe, account_from_to) == Parameter("%(bank_account)s"))
|
||||
.where(amount_condition)
|
||||
.where(filter_by_date)
|
||||
.orderby(pe.reference_date if cint(filter_by_reference_date) else pe.posting_date)
|
||||
)
|
||||
|
||||
if frappe.flags.auto_reconcile_vouchers == True:
|
||||
filter_by_reference_no = f"AND reference_no = '{transaction.reference_number}'"
|
||||
return f"""
|
||||
SELECT
|
||||
(CASE WHEN reference_no=%(reference_no)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN (party_type = %(party_type)s AND party = %(party)s ) THEN 1 ELSE 0 END
|
||||
+ CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
|
||||
+ 1 ) AS rank,
|
||||
'Payment Entry' as doctype,
|
||||
name,
|
||||
paid_amount,
|
||||
reference_no,
|
||||
reference_date,
|
||||
party,
|
||||
party_type,
|
||||
posting_date,
|
||||
{currency_field}
|
||||
FROM
|
||||
`tabPayment Entry`
|
||||
WHERE
|
||||
docstatus = 1
|
||||
AND payment_type IN (%(payment_type)s, 'Internal Transfer')
|
||||
AND ifnull(clearance_date, '') = ""
|
||||
AND {account_from_to} = %(bank_account)s
|
||||
AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
|
||||
{filter_by_date}
|
||||
{filter_by_reference_no}
|
||||
order by{order_by}
|
||||
"""
|
||||
query = query.where(ref_condition)
|
||||
|
||||
return str(query)
|
||||
|
||||
|
||||
def get_je_matching_query(
|
||||
@ -655,100 +665,121 @@ def get_je_matching_query(
|
||||
# So one bank could have both types of bank accounts like asset and liability
|
||||
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
|
||||
cr_or_dr = "credit" if transaction.withdrawal > 0.0 else "debit"
|
||||
filter_by_date = f"AND je.posting_date between '{from_date}' and '{to_date}'"
|
||||
order_by = " je.posting_date"
|
||||
filter_by_reference_no = ""
|
||||
je = frappe.qb.DocType("Journal Entry")
|
||||
jea = frappe.qb.DocType("Journal Entry Account")
|
||||
|
||||
ref_condition = je.cheque_no == transaction.reference_number
|
||||
ref_rank = frappe.qb.terms.Case().when(ref_condition, 1).else_(0)
|
||||
|
||||
amount_field = f"{cr_or_dr}_in_account_currency"
|
||||
amount_equality = getattr(jea, amount_field) == transaction.unallocated_amount
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
|
||||
filter_by_date = je.posting_date.between(from_date, to_date)
|
||||
if cint(filter_by_reference_date):
|
||||
filter_by_date = f"AND je.cheque_date between '{from_reference_date}' and '{to_reference_date}'"
|
||||
order_by = " je.cheque_date"
|
||||
if frappe.flags.auto_reconcile_vouchers == True:
|
||||
filter_by_reference_no = f"AND je.cheque_no = '{transaction.reference_number}'"
|
||||
return f"""
|
||||
SELECT
|
||||
(CASE WHEN je.cheque_no=%(reference_no)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN jea.{cr_or_dr}_in_account_currency = %(amount)s THEN 1 ELSE 0 END
|
||||
+ 1) AS rank ,
|
||||
'Journal Entry' AS doctype,
|
||||
filter_by_date = je.cheque_date.between(from_reference_date, to_reference_date)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(jea)
|
||||
.join(je)
|
||||
.on(jea.parent == je.name)
|
||||
.select(
|
||||
(ref_rank + amount_rank + 1).as_("rank"),
|
||||
ConstantColumn("Journal Entry").as_("doctype"),
|
||||
je.name,
|
||||
jea.{cr_or_dr}_in_account_currency AS paid_amount,
|
||||
je.cheque_no AS reference_no,
|
||||
je.cheque_date AS reference_date,
|
||||
je.pay_to_recd_from AS party,
|
||||
getattr(jea, amount_field).as_("paid_amount"),
|
||||
je.cheque_no.as_("reference_no"),
|
||||
je.cheque_date.as_("reference_date"),
|
||||
je.pay_to_recd_from.as_("party"),
|
||||
jea.party_type,
|
||||
je.posting_date,
|
||||
jea.account_currency AS currency
|
||||
FROM
|
||||
`tabJournal Entry Account` AS jea
|
||||
JOIN
|
||||
`tabJournal Entry` AS je
|
||||
ON
|
||||
jea.parent = je.name
|
||||
WHERE
|
||||
je.docstatus = 1
|
||||
AND je.voucher_type NOT IN ('Opening Entry')
|
||||
AND (je.clearance_date IS NULL OR je.clearance_date='0000-00-00')
|
||||
AND jea.account = %(bank_account)s
|
||||
AND jea.{cr_or_dr}_in_account_currency {'= %(amount)s' if exact_match else '> 0.0'}
|
||||
AND je.docstatus = 1
|
||||
{filter_by_date}
|
||||
{filter_by_reference_no}
|
||||
order by {order_by}
|
||||
"""
|
||||
jea.account_currency.as_("currency"),
|
||||
)
|
||||
.where(je.docstatus == 1)
|
||||
.where(je.voucher_type != "Opening Entry")
|
||||
.where(je.clearance_date.isnull())
|
||||
.where(jea.account == Parameter("%(bank_account)s"))
|
||||
.where(amount_equality if exact_match else getattr(jea, amount_field) > 0.0)
|
||||
.where(je.docstatus == 1)
|
||||
.where(filter_by_date)
|
||||
.orderby(je.cheque_date if cint(filter_by_reference_date) else je.posting_date)
|
||||
)
|
||||
|
||||
if frappe.flags.auto_reconcile_vouchers == True:
|
||||
query = query.where(ref_condition)
|
||||
|
||||
return str(query)
|
||||
|
||||
|
||||
def get_si_matching_query(exact_match):
|
||||
def get_si_matching_query(exact_match, currency):
|
||||
# get matching sales invoice query
|
||||
return f"""
|
||||
SELECT
|
||||
( CASE WHEN si.customer = %(party)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN sip.amount = %(amount)s THEN 1 ELSE 0 END
|
||||
+ 1 ) AS rank,
|
||||
'Sales Invoice' as doctype,
|
||||
si = frappe.qb.DocType("Sales Invoice")
|
||||
sip = frappe.qb.DocType("Sales Invoice Payment")
|
||||
|
||||
amount_equality = sip.amount == Parameter("%(amount)s")
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else sip.amount > 0.0
|
||||
|
||||
party_condition = si.customer == Parameter("%(party)s")
|
||||
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sip)
|
||||
.join(si)
|
||||
.on(sip.parent == si.name)
|
||||
.select(
|
||||
(party_rank + amount_rank + 1).as_("rank"),
|
||||
ConstantColumn("Sales Invoice").as_("doctype"),
|
||||
si.name,
|
||||
sip.amount as paid_amount,
|
||||
'' as reference_no,
|
||||
'' as reference_date,
|
||||
si.customer as party,
|
||||
'Customer' as party_type,
|
||||
sip.amount.as_("paid_amount"),
|
||||
ConstantColumn("").as_("reference_no"),
|
||||
ConstantColumn("").as_("reference_date"),
|
||||
si.customer.as_("party"),
|
||||
ConstantColumn("Customer").as_("party_type"),
|
||||
si.posting_date,
|
||||
si.currency
|
||||
si.currency,
|
||||
)
|
||||
.where(si.docstatus == 1)
|
||||
.where(sip.clearance_date.isnull())
|
||||
.where(sip.account == Parameter("%(bank_account)s"))
|
||||
.where(amount_condition)
|
||||
.where(si.currency == currency)
|
||||
)
|
||||
|
||||
FROM
|
||||
`tabSales Invoice Payment` as sip
|
||||
JOIN
|
||||
`tabSales Invoice` as si
|
||||
ON
|
||||
sip.parent = si.name
|
||||
WHERE
|
||||
si.docstatus = 1
|
||||
AND (sip.clearance_date is null or sip.clearance_date='0000-00-00')
|
||||
AND sip.account = %(bank_account)s
|
||||
AND sip.amount {'= %(amount)s' if exact_match else '> 0.0'}
|
||||
"""
|
||||
return str(query)
|
||||
|
||||
|
||||
def get_pi_matching_query(exact_match):
|
||||
def get_pi_matching_query(exact_match, currency):
|
||||
# get matching purchase invoice query when they are also used as payment entries (is_paid)
|
||||
return f"""
|
||||
SELECT
|
||||
( CASE WHEN supplier = %(party)s THEN 1 ELSE 0 END
|
||||
+ CASE WHEN paid_amount = %(amount)s THEN 1 ELSE 0 END
|
||||
+ 1 ) AS rank,
|
||||
'Purchase Invoice' as doctype,
|
||||
name,
|
||||
paid_amount,
|
||||
'' as reference_no,
|
||||
'' as reference_date,
|
||||
supplier as party,
|
||||
'Supplier' as party_type,
|
||||
posting_date,
|
||||
currency
|
||||
FROM
|
||||
`tabPurchase Invoice`
|
||||
WHERE
|
||||
docstatus = 1
|
||||
AND is_paid = 1
|
||||
AND ifnull(clearance_date, '') = ""
|
||||
AND cash_bank_account = %(bank_account)s
|
||||
AND paid_amount {'= %(amount)s' if exact_match else '> 0.0'}
|
||||
"""
|
||||
purchase_invoice = frappe.qb.DocType("Purchase Invoice")
|
||||
|
||||
amount_equality = purchase_invoice.paid_amount == Parameter("%(amount)s")
|
||||
amount_rank = frappe.qb.terms.Case().when(amount_equality, 1).else_(0)
|
||||
amount_condition = amount_equality if exact_match else purchase_invoice.paid_amount > 0.0
|
||||
|
||||
party_condition = purchase_invoice.supplier == Parameter("%(party)s")
|
||||
party_rank = frappe.qb.terms.Case().when(party_condition, 1).else_(0)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(purchase_invoice)
|
||||
.select(
|
||||
(party_rank + amount_rank + 1).as_("rank"),
|
||||
ConstantColumn("Purchase Invoice").as_("doctype"),
|
||||
purchase_invoice.name,
|
||||
purchase_invoice.paid_amount,
|
||||
ConstantColumn("").as_("reference_no"),
|
||||
ConstantColumn("").as_("reference_date"),
|
||||
purchase_invoice.supplier.as_("party"),
|
||||
ConstantColumn("Supplier").as_("party_type"),
|
||||
purchase_invoice.posting_date,
|
||||
purchase_invoice.currency,
|
||||
)
|
||||
.where(purchase_invoice.docstatus == 1)
|
||||
.where(purchase_invoice.is_paid == 1)
|
||||
.where(purchase_invoice.clearance_date.isnull())
|
||||
.where(purchase_invoice.cash_bank_account == Parameter("%(bank_account)s"))
|
||||
.where(amount_condition)
|
||||
.where(purchase_invoice.currency == currency)
|
||||
)
|
||||
|
||||
return str(query)
|
||||
|
@ -1,9 +1,100 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, flt, getdate, today
|
||||
|
||||
class TestBankReconciliationTool(unittest.TestCase):
|
||||
pass
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
get_bank_transactions,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
|
||||
|
||||
class TestBankReconciliationTool(AccountsTestMixin, FrappeTestCase):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.clear_old_entries()
|
||||
bank_dt = qb.DocType("Bank")
|
||||
q = qb.from_(bank_dt).delete().where(bank_dt.name == "HDFC").run()
|
||||
self.create_bank_account()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_bank_account(self):
|
||||
bank = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank",
|
||||
"bank_name": "HDFC",
|
||||
}
|
||||
).save()
|
||||
|
||||
self.bank_account = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Account",
|
||||
"account_name": "HDFC _current_",
|
||||
"bank": bank,
|
||||
"is_company_account": True,
|
||||
"account": self.bank, # account from Chart of Accounts
|
||||
}
|
||||
)
|
||||
.insert()
|
||||
.name
|
||||
)
|
||||
|
||||
def test_auto_reconcile(self):
|
||||
# make payment
|
||||
from_date = add_days(today(), -1)
|
||||
to_date = today()
|
||||
payment = create_payment_entry(
|
||||
company=self.company,
|
||||
posting_date=from_date,
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=self.customer,
|
||||
paid_from=self.debit_to,
|
||||
paid_to=self.bank,
|
||||
paid_amount=100,
|
||||
).save()
|
||||
payment.reference_no = "123"
|
||||
payment = payment.save().submit()
|
||||
|
||||
# make bank transaction
|
||||
bank_transaction = (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": to_date,
|
||||
"deposit": 100,
|
||||
"bank_account": self.bank_account,
|
||||
"reference_number": "123",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
# assert API output pre reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 1)
|
||||
self.assertEqual(transactions[0].name, bank_transaction.name)
|
||||
|
||||
# auto reconcile
|
||||
auto_reconcile_vouchers(
|
||||
bank_account=self.bank_account,
|
||||
from_date=from_date,
|
||||
to_date=to_date,
|
||||
filter_by_reference_date=False,
|
||||
)
|
||||
|
||||
# assert API output post reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
@ -47,7 +47,7 @@ class TestBankTransaction(FrappeTestCase):
|
||||
from_date=bank_transaction.date,
|
||||
to_date=utils.today(),
|
||||
)
|
||||
self.assertTrue(linked_payments[0][6] == "Conrad Electronic")
|
||||
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
|
||||
|
||||
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
||||
def test_reconcile(self):
|
||||
@ -93,7 +93,7 @@ class TestBankTransaction(FrappeTestCase):
|
||||
from_date=bank_transaction.date,
|
||||
to_date=utils.today(),
|
||||
)
|
||||
self.assertTrue(linked_payments[0][3])
|
||||
self.assertTrue(linked_payments[0]["paid_amount"])
|
||||
|
||||
# Check error if already reconciled
|
||||
def test_already_reconciled(self):
|
||||
@ -188,7 +188,7 @@ class TestBankTransaction(FrappeTestCase):
|
||||
repayment_entry = create_loan_and_repayment()
|
||||
|
||||
linked_payments = get_linked_payments(bank_transaction.name, ["loan_repayment", "exact_match"])
|
||||
self.assertEqual(linked_payments[0][2], repayment_entry.name)
|
||||
self.assertEqual(linked_payments[0]["name"], repayment_entry.name)
|
||||
|
||||
|
||||
@if_lending_app_installed
|
||||
|
@ -50,6 +50,8 @@ frappe.ui.form.on("Journal Entry", {
|
||||
frm.trigger("make_inter_company_journal_entry");
|
||||
}, __('Make'));
|
||||
}
|
||||
|
||||
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
|
||||
},
|
||||
|
||||
make_inter_company_journal_entry: function(frm) {
|
||||
|
@ -48,9 +48,6 @@ def start_merge(docname):
|
||||
merge_account(
|
||||
row.account,
|
||||
ledger_merge.account,
|
||||
ledger_merge.is_group,
|
||||
ledger_merge.root_type,
|
||||
ledger_merge.company,
|
||||
)
|
||||
row.db_set("merged", 1)
|
||||
frappe.db.commit()
|
||||
|
@ -9,7 +9,7 @@ erpnext.accounts.taxes.setup_tax_filters("Advance Taxes and Charges");
|
||||
|
||||
frappe.ui.form.on('Payment Entry', {
|
||||
onload: function(frm) {
|
||||
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger'];
|
||||
frm.ignore_doctypes_on_cancel_all = ['Sales Invoice', 'Purchase Invoice', 'Journal Entry', 'Repost Payment Ledger','Repost Accounting Ledger', 'Unreconcile Payments', 'Unreconcile Payment Entries'];
|
||||
|
||||
if(frm.doc.__islocal) {
|
||||
if (!frm.doc.paid_from) frm.set_value("paid_from_account_currency", null);
|
||||
@ -154,6 +154,13 @@ frappe.ui.form.on('Payment Entry', {
|
||||
frm.events.set_dynamic_labels(frm);
|
||||
frm.events.show_general_ledger(frm);
|
||||
erpnext.accounts.ledger_preview.show_accounting_ledger_preview(frm);
|
||||
if(frm.doc.references.find((elem) => {return elem.exchange_gain_loss != 0})) {
|
||||
frm.add_custom_button(__("View Exchange Gain/Loss Journals"), function() {
|
||||
frappe.set_route("List", "Journal Entry", {"voucher_type": "Exchange Gain Or Loss", "reference_name": frm.doc.name});
|
||||
}, __('Actions'));
|
||||
|
||||
}
|
||||
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(frm);
|
||||
},
|
||||
|
||||
validate_company: (frm) => {
|
||||
|
@ -148,6 +148,8 @@ class PaymentEntry(AccountsController):
|
||||
"Repost Payment Ledger Items",
|
||||
"Repost Accounting Ledger",
|
||||
"Repost Accounting Ledger Items",
|
||||
"Unreconcile Payments",
|
||||
"Unreconcile Payment Entries",
|
||||
)
|
||||
super(PaymentEntry, self).on_cancel()
|
||||
self.make_gl_entries(cancel=1)
|
||||
@ -1150,8 +1152,25 @@ class PaymentEntry(AccountsController):
|
||||
)
|
||||
|
||||
make_reverse_gl_entries(gl_entries=gl_entries, partial_cancel=True)
|
||||
else:
|
||||
make_gl_entries(gl_entries)
|
||||
return
|
||||
|
||||
# same reference added to payment entry
|
||||
for gl_entry in gl_entries.copy():
|
||||
if frappe.db.exists(
|
||||
"GL Entry",
|
||||
{
|
||||
"account": gl_entry.account,
|
||||
"voucher_type": gl_entry.voucher_type,
|
||||
"voucher_no": gl_entry.voucher_no,
|
||||
"voucher_detail_no": gl_entry.voucher_detail_no,
|
||||
"debit": gl_entry.debit,
|
||||
"credit": gl_entry.credit,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
):
|
||||
gl_entries.remove(gl_entry)
|
||||
|
||||
make_gl_entries(gl_entries)
|
||||
|
||||
def make_invoice_liability_entry(self, gl_entries, invoice):
|
||||
args_dict = {
|
||||
@ -1586,6 +1605,14 @@ def get_outstanding_reference_documents(args, validate=False):
|
||||
fieldname, args.get(date_fields[0]), args.get(date_fields[1])
|
||||
)
|
||||
posting_and_due_date.append(ple[fieldname][args.get(date_fields[0]) : args.get(date_fields[1])])
|
||||
elif args.get(date_fields[0]):
|
||||
# if only from date is supplied
|
||||
condition += " and {0} >= '{1}'".format(fieldname, args.get(date_fields[0]))
|
||||
posting_and_due_date.append(ple[fieldname].gte(args.get(date_fields[0])))
|
||||
elif args.get(date_fields[1]):
|
||||
# if only to date is supplied
|
||||
condition += " and {0} <= '{1}'".format(fieldname, args.get(date_fields[1]))
|
||||
posting_and_due_date.append(ple[fieldname].lte(args.get(date_fields[1])))
|
||||
|
||||
if args.get("company"):
|
||||
condition += " and company = {0}".format(frappe.db.escape(args.get("company")))
|
||||
|
@ -19,7 +19,7 @@ from erpnext.accounts.utils import (
|
||||
get_outstanding_invoices,
|
||||
reconcile_against_document,
|
||||
)
|
||||
from erpnext.controllers.accounts_controller import get_advance_payment_entries
|
||||
from erpnext.controllers.accounts_controller import get_advance_payment_entries_for_regional
|
||||
|
||||
|
||||
class PaymentReconciliation(Document):
|
||||
@ -78,7 +78,7 @@ class PaymentReconciliation(Document):
|
||||
if self.payment_name:
|
||||
condition.update({"name": self.payment_name})
|
||||
|
||||
payment_entries = get_advance_payment_entries(
|
||||
payment_entries = get_advance_payment_entries_for_regional(
|
||||
self.party_type,
|
||||
self.party,
|
||||
party_account,
|
||||
@ -363,6 +363,7 @@ class PaymentReconciliation(Document):
|
||||
)
|
||||
|
||||
def reconcile_allocations(self, skip_ref_details_update_for_pe=False):
|
||||
adjust_allocations_for_taxes(self)
|
||||
dr_or_cr = (
|
||||
"credit_in_account_currency"
|
||||
if erpnext.get_party_account_type(self.party_type) == "Receivable"
|
||||
@ -663,3 +664,8 @@ def reconcile_dr_cr_note(dr_cr_notes, company):
|
||||
None,
|
||||
inv.cost_center,
|
||||
)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def adjust_allocations_for_taxes(doc):
|
||||
pass
|
||||
|
@ -230,6 +230,28 @@
|
||||
"fieldtype": "Read Only",
|
||||
"label": "SWIFT Number"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_request_type == 'Inward'",
|
||||
"fieldname": "recipient_and_message",
|
||||
@ -246,7 +268,8 @@
|
||||
"fieldname": "email_to",
|
||||
"fieldtype": "Data",
|
||||
"in_global_search": 1,
|
||||
"label": "To"
|
||||
"label": "To",
|
||||
"options": "Email"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval: doc.payment_channel != \"Phone\"",
|
||||
@ -317,9 +340,10 @@
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_url",
|
||||
"fieldtype": "Small Text",
|
||||
"hidden": 1,
|
||||
"label": "payment_url",
|
||||
"fieldtype": "Data",
|
||||
"length": 500,
|
||||
"options": "URL",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@ -343,6 +367,14 @@
|
||||
"label": "Payment Account",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "payment_gateway_account.payment_channel",
|
||||
"fieldname": "payment_channel",
|
||||
"fieldtype": "Select",
|
||||
"label": "Payment Channel",
|
||||
"options": "\nEmail\nPhone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_order",
|
||||
"fieldtype": "Link",
|
||||
@ -358,43 +390,13 @@
|
||||
"options": "Payment Request",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fetch_from": "payment_gateway_account.payment_channel",
|
||||
"fieldname": "payment_channel",
|
||||
"fieldtype": "Select",
|
||||
"label": "Payment Channel",
|
||||
"options": "\nEmail\nPhone",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "accounting_dimensions_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Dimensions"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-12-21 16:56:40.115737",
|
||||
"modified": "2023-09-16 14:15:02.510890",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Request",
|
||||
|
@ -33,7 +33,7 @@ class PeriodClosingVoucher(AccountsController):
|
||||
def on_cancel(self):
|
||||
self.validate_future_closing_vouchers()
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry")
|
||||
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Payment Ledger Entry")
|
||||
gle_count = frappe.db.count(
|
||||
"GL Entry",
|
||||
{"voucher_type": "Period Closing Voucher", "voucher_no": self.name, "is_cancelled": 0},
|
||||
|
@ -5,6 +5,10 @@ import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
|
||||
create_dimension,
|
||||
disable_dimension,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_closing_entry.pos_closing_entry import (
|
||||
make_closing_entry_from_opening,
|
||||
)
|
||||
@ -140,6 +144,43 @@ class TestPOSClosingEntry(unittest.TestCase):
|
||||
pos_inv1.load_from_db()
|
||||
self.assertEqual(pos_inv1.status, "Paid")
|
||||
|
||||
def test_pos_closing_for_required_accounting_dimension_in_pos_profile(self):
|
||||
"""
|
||||
test case to check whether we can create POS Closing Entry without mandatory accounting dimension
|
||||
"""
|
||||
|
||||
create_dimension()
|
||||
pos_profile = make_pos_profile(do_not_insert=1, do_not_set_accounting_dimension=1)
|
||||
|
||||
self.assertRaises(frappe.ValidationError, pos_profile.insert)
|
||||
|
||||
pos_profile.location = "Block 1"
|
||||
pos_profile.insert()
|
||||
self.assertTrue(frappe.db.exists("POS Profile", pos_profile.name))
|
||||
|
||||
test_user = init_user_and_profile(do_not_create_pos_profile=1)
|
||||
|
||||
opening_entry = create_opening_entry(pos_profile, test_user.name)
|
||||
pos_inv1 = create_pos_invoice(rate=350, do_not_submit=1, pos_profile=pos_profile.name)
|
||||
pos_inv1.append("payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 3500})
|
||||
pos_inv1.submit()
|
||||
|
||||
# if in between a mandatory accounting dimension is added to the POS Profile then
|
||||
accounting_dimension_department = frappe.get_doc("Accounting Dimension", {"name": "Department"})
|
||||
accounting_dimension_department.dimension_defaults[0].mandatory_for_bs = 1
|
||||
accounting_dimension_department.save()
|
||||
|
||||
pcv_doc = make_closing_entry_from_opening(opening_entry)
|
||||
# will assert coz the new mandatory accounting dimension bank is not set in POS Profile
|
||||
self.assertRaises(frappe.ValidationError, pcv_doc.submit)
|
||||
|
||||
accounting_dimension_department = frappe.get_doc(
|
||||
"Accounting Dimension Detail", {"parent": "Department"}
|
||||
)
|
||||
accounting_dimension_department.mandatory_for_bs = 0
|
||||
accounting_dimension_department.save()
|
||||
disable_dimension()
|
||||
|
||||
|
||||
def init_user_and_profile(**args):
|
||||
user = "test@example.com"
|
||||
@ -149,6 +190,9 @@ def init_user_and_profile(**args):
|
||||
test_user.add_roles(*roles)
|
||||
frappe.set_user(user)
|
||||
|
||||
if args.get("do_not_create_pos_profile"):
|
||||
return test_user
|
||||
|
||||
pos_profile = make_pos_profile(**args)
|
||||
pos_profile.append("applicable_for_users", {"default": 1, "user": user})
|
||||
|
||||
|
@ -414,7 +414,7 @@ class POSInvoice(SalesInvoice):
|
||||
selling_price_list = (
|
||||
customer_price_list or customer_group_price_list or profile.get("selling_price_list")
|
||||
)
|
||||
if customer_currency != profile.get("currency"):
|
||||
if customer_currency and customer_currency != profile.get("currency"):
|
||||
self.set("currency", customer_currency)
|
||||
|
||||
else:
|
||||
|
@ -12,6 +12,8 @@ from frappe.utils import cint, flt, get_time, getdate, nowdate, nowtime
|
||||
from frappe.utils.background_jobs import enqueue, is_job_enqueued
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.pos_profile import required_accounting_dimensions
|
||||
|
||||
|
||||
class POSInvoiceMergeLog(Document):
|
||||
def validate(self):
|
||||
@ -163,7 +165,8 @@ class POSInvoiceMergeLog(Document):
|
||||
for i in items:
|
||||
if (
|
||||
i.item_code == item.item_code
|
||||
and not i.serial_and_batch_bundle
|
||||
and not i.serial_no
|
||||
and not i.batch_no
|
||||
and i.uom == item.uom
|
||||
and i.net_rate == item.net_rate
|
||||
and i.warehouse == item.warehouse
|
||||
@ -238,6 +241,22 @@ class POSInvoiceMergeLog(Document):
|
||||
invoice.disable_rounded_total = cint(
|
||||
frappe.db.get_value("POS Profile", invoice.pos_profile, "disable_rounded_total")
|
||||
)
|
||||
accounting_dimensions = required_accounting_dimensions()
|
||||
dimension_values = frappe.db.get_value(
|
||||
"POS Profile", {"name": invoice.pos_profile}, accounting_dimensions, as_dict=1
|
||||
)
|
||||
for dimension in accounting_dimensions:
|
||||
dimension_value = dimension_values.get(dimension)
|
||||
|
||||
if not dimension_value:
|
||||
frappe.throw(
|
||||
_("Please set Accounting Dimension {} in {}").format(
|
||||
frappe.bold(frappe.unscrub(dimension)),
|
||||
frappe.get_desk_link("POS Profile", invoice.pos_profile),
|
||||
)
|
||||
)
|
||||
|
||||
invoice.set(dimension, dimension_value)
|
||||
|
||||
if self.merge_invoices_based_on == "Customer Group":
|
||||
invoice.flags.ignore_pos_profile = True
|
||||
@ -424,11 +443,9 @@ def create_merge_logs(invoice_by_customer, closing_entry=None):
|
||||
)
|
||||
merge_log.customer = customer
|
||||
merge_log.pos_closing_entry = closing_entry.get("name") if closing_entry else None
|
||||
|
||||
merge_log.set("pos_invoices", _invoices)
|
||||
merge_log.save(ignore_permissions=True)
|
||||
merge_log.submit()
|
||||
|
||||
if closing_entry:
|
||||
closing_entry.set_status(update=True, status="Submitted")
|
||||
closing_entry.db_set("error_message", "")
|
||||
|
@ -1,6 +1,5 @@
|
||||
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
// License: GNU General Public License v3. See license.txt
|
||||
|
||||
frappe.ui.form.on('POS Profile', {
|
||||
setup: function(frm) {
|
||||
frm.set_query("selling_price_list", function() {
|
||||
@ -140,6 +139,7 @@ frappe.ui.form.on('POS Profile', {
|
||||
company: function(frm) {
|
||||
frm.trigger("toggle_display_account_head");
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
|
||||
},
|
||||
|
||||
toggle_display_account_head: function(frm) {
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint
|
||||
from frappe import _, msgprint, scrub, unscrub
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import get_link_to_form, now
|
||||
|
||||
@ -14,6 +14,21 @@ class POSProfile(Document):
|
||||
self.validate_all_link_fields()
|
||||
self.validate_duplicate_groups()
|
||||
self.validate_payment_methods()
|
||||
self.validate_accounting_dimensions()
|
||||
|
||||
def validate_accounting_dimensions(self):
|
||||
acc_dim_names = required_accounting_dimensions()
|
||||
for acc_dim in acc_dim_names:
|
||||
if not self.get(acc_dim):
|
||||
frappe.throw(
|
||||
_(
|
||||
"{0} is a mandatory Accounting Dimension. <br>"
|
||||
"Please set a value for {0} in Accounting Dimensions section."
|
||||
).format(
|
||||
unscrub(frappe.bold(acc_dim)),
|
||||
),
|
||||
title=_("Mandatory Accounting Dimension"),
|
||||
)
|
||||
|
||||
def validate_default_profile(self):
|
||||
for row in self.applicable_for_users:
|
||||
@ -152,6 +167,24 @@ def get_child_nodes(group_type, root):
|
||||
)
|
||||
|
||||
|
||||
def required_accounting_dimensions():
|
||||
|
||||
p = frappe.qb.DocType("Accounting Dimension")
|
||||
c = frappe.qb.DocType("Accounting Dimension Detail")
|
||||
|
||||
acc_dim_doc = (
|
||||
frappe.qb.from_(p)
|
||||
.inner_join(c)
|
||||
.on(p.name == c.parent)
|
||||
.select(c.parent)
|
||||
.where((c.mandatory_for_bs == 1) | (c.mandatory_for_pl == 1))
|
||||
.where(p.disabled == 0)
|
||||
).run(as_dict=1)
|
||||
|
||||
acc_dim_names = [scrub(d.parent) for d in acc_dim_doc]
|
||||
return acc_dim_names
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def pos_profile_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
@ -5,7 +5,10 @@ import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.pos_profile import get_child_nodes
|
||||
from erpnext.accounts.doctype.pos_profile.pos_profile import (
|
||||
get_child_nodes,
|
||||
required_accounting_dimensions,
|
||||
)
|
||||
from erpnext.stock.get_item_details import get_pos_profile
|
||||
|
||||
test_dependencies = ["Item"]
|
||||
@ -118,6 +121,7 @@ def make_pos_profile(**args):
|
||||
"warehouse": args.warehouse or "_Test Warehouse - _TC",
|
||||
"write_off_account": args.write_off_account or "_Test Write Off - _TC",
|
||||
"write_off_cost_center": args.write_off_cost_center or "_Test Write Off Cost Center - _TC",
|
||||
"location": "Block 1" if not args.do_not_set_accounting_dimension else None,
|
||||
}
|
||||
)
|
||||
|
||||
@ -132,6 +136,7 @@ def make_pos_profile(**args):
|
||||
pos_profile.append("payments", {"mode_of_payment": "Cash", "default": 1})
|
||||
|
||||
if not frappe.db.exists("POS Profile", args.name or "_Test POS Profile"):
|
||||
pos_profile.insert()
|
||||
if not args.get("do_not_insert"):
|
||||
pos_profile.insert()
|
||||
|
||||
return pos_profile
|
||||
|
@ -65,6 +65,7 @@ def get_report_pdf(doc, consolidated=True):
|
||||
filters = get_common_filters(doc)
|
||||
|
||||
if doc.report == "General Ledger":
|
||||
filters.update(get_gl_filters(doc, entry, tax_id, presentation_currency))
|
||||
col, res = get_soa(filters)
|
||||
for x in [0, -2, -1]:
|
||||
res[x]["account"] = res[x]["account"].replace("'", "")
|
||||
|
@ -0,0 +1,8 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
// frappe.ui.form.on("Process Subscription", {
|
||||
// refresh(frm) {
|
||||
|
||||
// },
|
||||
// });
|
@ -0,0 +1,90 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-09-17 15:40:59.724177",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"posting_date",
|
||||
"subscription",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Process Subscription",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "posting_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Posting Date",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "subscription",
|
||||
"fieldtype": "Link",
|
||||
"label": "Subscription",
|
||||
"options": "Subscription"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-09-17 17:33:37.974166",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Subscription",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 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": []
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Union
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.subscription.subscription import process_all
|
||||
|
||||
|
||||
class ProcessSubscription(Document):
|
||||
def on_submit(self):
|
||||
process_all(subscription=self.subscription, posting_date=self.posting_date)
|
||||
|
||||
|
||||
def create_subscription_process(
|
||||
subscription: str | None, posting_date: Union[str, datetime.date] | None
|
||||
):
|
||||
"""Create a new Process Subscription document"""
|
||||
doc = frappe.new_doc("Process Subscription")
|
||||
doc.subscription = subscription
|
||||
doc.posting_date = getdate(posting_date)
|
||||
doc.insert(ignore_permissions=True)
|
||||
doc.submit()
|
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
|
||||
class TestProcessSubscription(FrappeTestCase):
|
||||
pass
|
@ -161,6 +161,7 @@ erpnext.accounts.PurchaseInvoice = class PurchaseInvoice extends erpnext.buying.
|
||||
}
|
||||
|
||||
this.frm.set_df_property("tax_withholding_category", "hidden", doc.apply_tds ? 0 : 1);
|
||||
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
|
||||
}
|
||||
|
||||
unblock_invoice() {
|
||||
|
@ -1164,7 +1164,7 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
|
||||
|
||||
item = create_item("_Test Item for Deferred Accounting", is_purchase_item=True)
|
||||
item.enable_deferred_expense = 1
|
||||
item.deferred_expense_account = deferred_account
|
||||
item.item_defaults[0].deferred_expense_account = deferred_account
|
||||
item.save()
|
||||
|
||||
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
|
||||
|
@ -37,7 +37,7 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
|
||||
super.onload();
|
||||
|
||||
this.frm.ignore_doctypes_on_cancel_all = ['POS Invoice', 'Timesheet', 'POS Invoice Merge Log',
|
||||
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger"];
|
||||
'POS Closing Entry', 'Journal Entry', 'Payment Entry', "Repost Payment Ledger", "Repost Accounting Ledger", "Unreconcile Payments", "Unreconcile Payment Entries"];
|
||||
|
||||
if(!this.frm.doc.__islocal && !this.frm.doc.customer && this.frm.doc.debit_to) {
|
||||
// show debit_to in print format
|
||||
@ -183,8 +183,11 @@ erpnext.accounts.SalesInvoiceController = class SalesInvoiceController extends e
|
||||
}, __('Create'));
|
||||
}
|
||||
}
|
||||
|
||||
erpnext.accounts.unreconcile_payments.add_unreconcile_btn(me.frm);
|
||||
}
|
||||
|
||||
|
||||
make_maintenance_schedule() {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.accounts.doctype.sales_invoice.sales_invoice.make_maintenance_schedule",
|
||||
|
@ -388,6 +388,8 @@ class SalesInvoice(SellingController):
|
||||
"Repost Payment Ledger Items",
|
||||
"Repost Accounting Ledger",
|
||||
"Repost Accounting Ledger Items",
|
||||
"Unreconcile Payments",
|
||||
"Unreconcile Payment Entries",
|
||||
"Payment Ledger Entry",
|
||||
"Serial and Batch Bundle",
|
||||
)
|
||||
|
@ -1801,6 +1801,10 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_outstanding_amount_after_advance_payment_entry_cancellation(self):
|
||||
"""Test impact of advance PE submission/cancellation on SI and SO."""
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
|
||||
sales_order = make_sales_order(item_code="138-CMS Shoe", qty=1, price_list_rate=500)
|
||||
pe = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Payment Entry",
|
||||
@ -1820,10 +1824,25 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
"paid_to": "_Test Cash - _TC",
|
||||
}
|
||||
)
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
"reference_doctype": "Sales Order",
|
||||
"reference_name": sales_order.name,
|
||||
"total_amount": sales_order.grand_total,
|
||||
"outstanding_amount": sales_order.grand_total,
|
||||
"allocated_amount": 300,
|
||||
},
|
||||
)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
sales_order.reload()
|
||||
self.assertEqual(sales_order.advance_paid, 300)
|
||||
|
||||
si = frappe.copy_doc(test_records[0])
|
||||
si.items[0].sales_order = sales_order.name
|
||||
si.items[0].so_detail = sales_order.get("items")[0].name
|
||||
si.is_pos = 0
|
||||
si.append(
|
||||
"advances",
|
||||
@ -1831,6 +1850,7 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
"doctype": "Sales Invoice Advance",
|
||||
"reference_type": "Payment Entry",
|
||||
"reference_name": pe.name,
|
||||
"reference_row": pe.references[0].name,
|
||||
"advance_amount": 300,
|
||||
"allocated_amount": 300,
|
||||
"remarks": pe.remarks,
|
||||
@ -1839,7 +1859,13 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
si.insert()
|
||||
si.submit()
|
||||
|
||||
si.load_from_db()
|
||||
si.reload()
|
||||
pe.reload()
|
||||
sales_order.reload()
|
||||
|
||||
# Check if SO is unlinked/replaced by SI in PE & if SO advance paid is 0
|
||||
self.assertEqual(pe.references[0].reference_name, si.name)
|
||||
self.assertEqual(sales_order.advance_paid, 0.0)
|
||||
|
||||
# check outstanding after advance allocation
|
||||
self.assertEqual(
|
||||
@ -1847,11 +1873,9 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
flt(si.rounded_total - si.total_advance, si.precision("outstanding_amount")),
|
||||
)
|
||||
|
||||
# added to avoid Document has been modified exception
|
||||
pe = frappe.get_doc("Payment Entry", pe.name)
|
||||
pe.cancel()
|
||||
si.reload()
|
||||
|
||||
si.load_from_db()
|
||||
# check outstanding after advance cancellation
|
||||
self.assertEqual(
|
||||
flt(si.outstanding_amount),
|
||||
@ -2322,7 +2346,7 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
|
||||
item = create_item("_Test Item for Deferred Accounting")
|
||||
item.enable_deferred_revenue = 1
|
||||
item.deferred_revenue_account = deferred_account
|
||||
item.item_defaults[0].deferred_revenue_account = deferred_account
|
||||
item.no_of_months = 12
|
||||
item.save()
|
||||
|
||||
@ -3102,7 +3126,7 @@ class TestSalesInvoice(unittest.TestCase):
|
||||
|
||||
item = create_item("_Test Item for Deferred Accounting")
|
||||
item.enable_deferred_expense = 1
|
||||
item.deferred_revenue_account = deferred_account
|
||||
item.item_defaults[0].deferred_revenue_account = deferred_account
|
||||
item.save()
|
||||
|
||||
si = create_sales_invoice(
|
||||
|
@ -24,8 +24,9 @@
|
||||
"current_invoice_start",
|
||||
"current_invoice_end",
|
||||
"days_until_due",
|
||||
"generate_invoice_at",
|
||||
"number_of_days",
|
||||
"cancel_at_period_end",
|
||||
"generate_invoice_at_period_start",
|
||||
"sb_4",
|
||||
"plans",
|
||||
"sb_1",
|
||||
@ -86,12 +87,14 @@
|
||||
"fieldname": "current_invoice_start",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice Start Date",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "current_invoice_end",
|
||||
"fieldtype": "Date",
|
||||
"label": "Current Invoice End Date",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@ -107,12 +110,6 @@
|
||||
"fieldtype": "Check",
|
||||
"label": "Cancel At End Of Period"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "generate_invoice_at_period_start",
|
||||
"fieldtype": "Check",
|
||||
"label": "Generate Invoice At Beginning Of Period"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "sb_4",
|
||||
@ -240,6 +237,21 @@
|
||||
"fieldname": "submit_invoice",
|
||||
"fieldtype": "Check",
|
||||
"label": "Submit Generated Invoices"
|
||||
},
|
||||
{
|
||||
"default": "End of the current subscription period",
|
||||
"fieldname": "generate_invoice_at",
|
||||
"fieldtype": "Select",
|
||||
"label": "Generate Invoice At",
|
||||
"options": "End of the current subscription period\nBeginning of the current subscription period\nDays before the current subscription period",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\"",
|
||||
"fieldname": "number_of_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of Days",
|
||||
"mandatory_depends_on": "eval:doc.generate_invoice_at === \"Days before the current subscription period\""
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
@ -255,7 +267,7 @@
|
||||
"link_fieldname": "subscription"
|
||||
}
|
||||
],
|
||||
"modified": "2022-02-18 23:24:57.185054",
|
||||
"modified": "2023-09-18 17:48:21.900252",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription",
|
||||
|
@ -36,12 +36,15 @@ class InvoiceNotCancelled(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
DateTimeLikeObject = Union[str, datetime.date]
|
||||
|
||||
|
||||
class Subscription(Document):
|
||||
def before_insert(self):
|
||||
# update start just before the subscription doc is created
|
||||
self.update_subscription_period(self.start_date)
|
||||
|
||||
def update_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
|
||||
def update_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
|
||||
"""
|
||||
Subscription period is the period to be billed. This method updates the
|
||||
beginning of the billing period and end of the billing period.
|
||||
@ -52,14 +55,14 @@ class Subscription(Document):
|
||||
self.current_invoice_start = self.get_current_invoice_start(date)
|
||||
self.current_invoice_end = self.get_current_invoice_end(self.current_invoice_start)
|
||||
|
||||
def _get_subscription_period(self, date: Optional[Union[datetime.date, str]] = None):
|
||||
def _get_subscription_period(self, date: Optional["DateTimeLikeObject"] = None):
|
||||
_current_invoice_start = self.get_current_invoice_start(date)
|
||||
_current_invoice_end = self.get_current_invoice_end(_current_invoice_start)
|
||||
|
||||
return _current_invoice_start, _current_invoice_end
|
||||
|
||||
def get_current_invoice_start(
|
||||
self, date: Optional[Union[datetime.date, str]] = None
|
||||
self, date: Optional["DateTimeLikeObject"] = None
|
||||
) -> Union[datetime.date, str]:
|
||||
"""
|
||||
This returns the date of the beginning of the current billing period.
|
||||
@ -84,7 +87,7 @@ class Subscription(Document):
|
||||
return _current_invoice_start
|
||||
|
||||
def get_current_invoice_end(
|
||||
self, date: Optional[Union[datetime.date, str]] = None
|
||||
self, date: Optional["DateTimeLikeObject"] = None
|
||||
) -> Union[datetime.date, str]:
|
||||
"""
|
||||
This returns the date of the end of the current billing period.
|
||||
@ -179,30 +182,24 @@ class Subscription(Document):
|
||||
|
||||
return data
|
||||
|
||||
def set_subscription_status(self) -> None:
|
||||
def set_subscription_status(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
|
||||
"""
|
||||
Sets the status of the `Subscription`
|
||||
"""
|
||||
if self.is_trialling():
|
||||
self.status = "Trialling"
|
||||
elif (
|
||||
self.status == "Active"
|
||||
and self.end_date
|
||||
and getdate(frappe.flags.current_date) > getdate(self.end_date)
|
||||
self.status == "Active" and self.end_date and getdate(posting_date) > getdate(self.end_date)
|
||||
):
|
||||
self.status = "Completed"
|
||||
elif self.is_past_grace_period():
|
||||
self.status = self.get_status_for_past_grace_period()
|
||||
self.cancelation_date = (
|
||||
getdate(frappe.flags.current_date) if self.status == "Cancelled" else None
|
||||
)
|
||||
self.cancelation_date = getdate(posting_date) if self.status == "Cancelled" else None
|
||||
elif self.current_invoice_is_past_due() and not self.is_past_grace_period():
|
||||
self.status = "Past Due Date"
|
||||
elif not self.has_outstanding_invoice() or self.is_new_subscription():
|
||||
self.status = "Active"
|
||||
|
||||
self.save()
|
||||
|
||||
def is_trialling(self) -> bool:
|
||||
"""
|
||||
Returns `True` if the `Subscription` is in trial period.
|
||||
@ -210,7 +207,9 @@ class Subscription(Document):
|
||||
return not self.period_has_passed(self.trial_period_end) and self.is_new_subscription()
|
||||
|
||||
@staticmethod
|
||||
def period_has_passed(end_date: Union[str, datetime.date]) -> bool:
|
||||
def period_has_passed(
|
||||
end_date: Union[str, datetime.date], posting_date: Optional["DateTimeLikeObject"] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Returns true if the given `end_date` has passed
|
||||
"""
|
||||
@ -218,7 +217,7 @@ class Subscription(Document):
|
||||
if not end_date:
|
||||
return True
|
||||
|
||||
return getdate(frappe.flags.current_date) > getdate(end_date)
|
||||
return getdate(posting_date) > getdate(end_date)
|
||||
|
||||
def get_status_for_past_grace_period(self) -> str:
|
||||
cancel_after_grace = cint(frappe.get_value("Subscription Settings", None, "cancel_after_grace"))
|
||||
@ -229,7 +228,7 @@ class Subscription(Document):
|
||||
|
||||
return status
|
||||
|
||||
def is_past_grace_period(self) -> bool:
|
||||
def is_past_grace_period(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
|
||||
"""
|
||||
Returns `True` if the grace period for the `Subscription` has passed
|
||||
"""
|
||||
@ -237,18 +236,18 @@ class Subscription(Document):
|
||||
return
|
||||
|
||||
grace_period = cint(frappe.get_value("Subscription Settings", None, "grace_period"))
|
||||
return getdate(frappe.flags.current_date) >= getdate(
|
||||
add_days(self.current_invoice.due_date, grace_period)
|
||||
)
|
||||
return getdate(posting_date) >= getdate(add_days(self.current_invoice.due_date, grace_period))
|
||||
|
||||
def current_invoice_is_past_due(self) -> bool:
|
||||
def current_invoice_is_past_due(
|
||||
self, posting_date: Optional["DateTimeLikeObject"] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Returns `True` if the current generated invoice is overdue
|
||||
"""
|
||||
if not self.current_invoice or self.is_paid(self.current_invoice):
|
||||
return False
|
||||
|
||||
return getdate(frappe.flags.current_date) >= getdate(self.current_invoice.due_date)
|
||||
return getdate(posting_date) >= getdate(self.current_invoice.due_date)
|
||||
|
||||
@property
|
||||
def invoice_document_type(self) -> str:
|
||||
@ -270,6 +269,9 @@ class Subscription(Document):
|
||||
if not self.cost_center:
|
||||
self.cost_center = get_default_cost_center(self.get("company"))
|
||||
|
||||
if self.is_new():
|
||||
self.set_subscription_status()
|
||||
|
||||
def validate_trial_period(self) -> None:
|
||||
"""
|
||||
Runs sanity checks on trial period dates for the `Subscription`
|
||||
@ -305,10 +307,6 @@ class Subscription(Document):
|
||||
if billing_info[0]["billing_interval"] != "Month":
|
||||
frappe.throw(_("Billing Interval in Subscription Plan must be Month to follow calendar months"))
|
||||
|
||||
def after_insert(self) -> None:
|
||||
# todo: deal with users who collect prepayments. Maybe a new Subscription Invoice doctype?
|
||||
self.set_subscription_status()
|
||||
|
||||
def generate_invoice(
|
||||
self,
|
||||
from_date: Optional[Union[str, datetime.date]] = None,
|
||||
@ -344,7 +342,7 @@ class Subscription(Document):
|
||||
invoice.set_posting_time = 1
|
||||
invoice.posting_date = (
|
||||
self.current_invoice_start
|
||||
if self.generate_invoice_at_period_start
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period"
|
||||
else self.current_invoice_end
|
||||
)
|
||||
|
||||
@ -438,7 +436,7 @@ class Subscription(Document):
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.current_invoice_end,
|
||||
self.current_invoice_start,
|
||||
cint(self.generate_invoice_at_period_start),
|
||||
cint(self.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
)
|
||||
|
||||
items = []
|
||||
@ -503,42 +501,45 @@ class Subscription(Document):
|
||||
return items
|
||||
|
||||
@frappe.whitelist()
|
||||
def process(self) -> bool:
|
||||
def process(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
|
||||
"""
|
||||
To be called by task periodically. It checks the subscription and takes appropriate action
|
||||
as need be. It calls either of these methods depending the `Subscription` status:
|
||||
1. `process_for_active`
|
||||
2. `process_for_past_due`
|
||||
"""
|
||||
if (
|
||||
not self.is_current_invoice_generated(self.current_invoice_start, self.current_invoice_end)
|
||||
and self.can_generate_new_invoice()
|
||||
):
|
||||
if not self.is_current_invoice_generated(
|
||||
self.current_invoice_start, self.current_invoice_end
|
||||
) and self.can_generate_new_invoice(posting_date):
|
||||
self.generate_invoice()
|
||||
self.update_subscription_period(add_days(self.current_invoice_end, 1))
|
||||
|
||||
if self.cancel_at_period_end and (
|
||||
getdate(frappe.flags.current_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(frappe.flags.current_date) >= getdate(self.end_date)
|
||||
getdate(posting_date) >= getdate(self.current_invoice_end)
|
||||
or getdate(posting_date) >= getdate(self.end_date)
|
||||
):
|
||||
self.cancel_subscription()
|
||||
|
||||
self.set_subscription_status()
|
||||
self.set_subscription_status(posting_date=posting_date)
|
||||
|
||||
self.save()
|
||||
|
||||
def can_generate_new_invoice(self) -> bool:
|
||||
def can_generate_new_invoice(self, posting_date: Optional["DateTimeLikeObject"] = None) -> bool:
|
||||
if self.cancelation_date:
|
||||
return False
|
||||
elif self.generate_invoice_at_period_start and (
|
||||
getdate(frappe.flags.current_date) == getdate(self.current_invoice_start)
|
||||
or self.is_new_subscription()
|
||||
|
||||
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
|
||||
return False
|
||||
|
||||
if self.generate_invoice_at == "Beginning of the current subscription period" and (
|
||||
getdate(posting_date) == getdate(self.current_invoice_start) or self.is_new_subscription()
|
||||
):
|
||||
return True
|
||||
elif getdate(frappe.flags.current_date) == getdate(self.current_invoice_end):
|
||||
if self.has_outstanding_invoice() and not self.generate_new_invoices_past_due_date:
|
||||
return False
|
||||
|
||||
elif self.generate_invoice_at == "Days before the current subscription period" and (
|
||||
getdate(posting_date) == getdate(add_days(self.current_invoice_start, -1 * self.number_of_days))
|
||||
):
|
||||
return True
|
||||
elif getdate(posting_date) == getdate(self.current_invoice_end):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@ -628,7 +629,10 @@ class Subscription(Document):
|
||||
frappe.throw(_("subscription is already cancelled."), InvoiceCancelled)
|
||||
|
||||
to_generate_invoice = (
|
||||
True if self.status == "Active" and not self.generate_invoice_at_period_start else False
|
||||
True
|
||||
if self.status == "Active"
|
||||
and not self.generate_invoice_at == "Beginning of the current subscription period"
|
||||
else False
|
||||
)
|
||||
self.status = "Cancelled"
|
||||
self.cancelation_date = nowdate()
|
||||
@ -639,7 +643,7 @@ class Subscription(Document):
|
||||
self.save()
|
||||
|
||||
@frappe.whitelist()
|
||||
def restart_subscription(self) -> None:
|
||||
def restart_subscription(self, posting_date: Optional["DateTimeLikeObject"] = None) -> None:
|
||||
"""
|
||||
This sets the subscription as active. The subscription will be made to be like a new
|
||||
subscription and the `Subscription` will lose all the history of generated invoices
|
||||
@ -650,7 +654,7 @@ class Subscription(Document):
|
||||
|
||||
self.status = "Active"
|
||||
self.cancelation_date = None
|
||||
self.update_subscription_period(frappe.flags.current_date or nowdate())
|
||||
self.update_subscription_period(posting_date or nowdate())
|
||||
self.save()
|
||||
|
||||
|
||||
@ -671,14 +675,21 @@ def get_prorata_factor(
|
||||
return diff / plan_days
|
||||
|
||||
|
||||
def process_all() -> None:
|
||||
def process_all(
|
||||
subscription: str | None, posting_date: Optional["DateTimeLikeObject"] = None
|
||||
) -> None:
|
||||
"""
|
||||
Task to updates the status of all `Subscription` apart from those that are cancelled
|
||||
"""
|
||||
for subscription in frappe.get_all("Subscription", {"status": ("!=", "Cancelled")}, pluck="name"):
|
||||
filters = {"status": ("!=", "Cancelled")}
|
||||
|
||||
if subscription:
|
||||
filters["name"] = subscription
|
||||
|
||||
for subscription in frappe.get_all("Subscription", filters, pluck="name"):
|
||||
try:
|
||||
subscription = frappe.get_doc("Subscription", subscription)
|
||||
subscription.process()
|
||||
subscription.process(posting_date)
|
||||
frappe.db.commit()
|
||||
except frappe.ValidationError:
|
||||
frappe.db.rollback()
|
||||
|
@ -8,6 +8,7 @@ from frappe.utils.data import (
|
||||
add_days,
|
||||
add_months,
|
||||
add_to_date,
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
get_date_str,
|
||||
@ -20,99 +21,16 @@ from erpnext.accounts.doctype.subscription.subscription import get_prorata_facto
|
||||
test_dependencies = ("UOM", "Item Group", "Item")
|
||||
|
||||
|
||||
def create_plan():
|
||||
if not frappe.db.exists("Subscription Plan", "_Test Plan Name"):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = "_Test Plan Name"
|
||||
plan.item = "_Test Non Stock Item"
|
||||
plan.price_determination = "Fixed Rate"
|
||||
plan.cost = 900
|
||||
plan.billing_interval = "Month"
|
||||
plan.billing_interval_count = 1
|
||||
plan.insert()
|
||||
|
||||
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 2"):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = "_Test Plan Name 2"
|
||||
plan.item = "_Test Non Stock Item"
|
||||
plan.price_determination = "Fixed Rate"
|
||||
plan.cost = 1999
|
||||
plan.billing_interval = "Month"
|
||||
plan.billing_interval_count = 1
|
||||
plan.insert()
|
||||
|
||||
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 3"):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = "_Test Plan Name 3"
|
||||
plan.item = "_Test Non Stock Item"
|
||||
plan.price_determination = "Fixed Rate"
|
||||
plan.cost = 1999
|
||||
plan.billing_interval = "Day"
|
||||
plan.billing_interval_count = 14
|
||||
plan.insert()
|
||||
|
||||
# Defined a quarterly Subscription Plan
|
||||
if not frappe.db.exists("Subscription Plan", "_Test Plan Name 4"):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = "_Test Plan Name 4"
|
||||
plan.item = "_Test Non Stock Item"
|
||||
plan.price_determination = "Monthly Rate"
|
||||
plan.cost = 20000
|
||||
plan.billing_interval = "Month"
|
||||
plan.billing_interval_count = 3
|
||||
plan.insert()
|
||||
|
||||
if not frappe.db.exists("Subscription Plan", "_Test Plan Multicurrency"):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = "_Test Plan Multicurrency"
|
||||
plan.item = "_Test Non Stock Item"
|
||||
plan.price_determination = "Fixed Rate"
|
||||
plan.cost = 50
|
||||
plan.currency = "USD"
|
||||
plan.billing_interval = "Month"
|
||||
plan.billing_interval_count = 1
|
||||
plan.insert()
|
||||
|
||||
|
||||
def create_parties():
|
||||
if not frappe.db.exists("Supplier", "_Test Supplier"):
|
||||
supplier = frappe.new_doc("Supplier")
|
||||
supplier.supplier_name = "_Test Supplier"
|
||||
supplier.supplier_group = "All Supplier Groups"
|
||||
supplier.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "_Test Subscription Customer"
|
||||
customer.billing_currency = "USD"
|
||||
customer.append(
|
||||
"accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
|
||||
)
|
||||
customer.insert()
|
||||
|
||||
|
||||
def reset_settings():
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
settings.grace_period = 0
|
||||
settings.cancel_after_grace = 0
|
||||
settings.save()
|
||||
|
||||
|
||||
class TestSubscription(unittest.TestCase):
|
||||
def setUp(self):
|
||||
create_plan()
|
||||
make_plans()
|
||||
create_parties()
|
||||
reset_settings()
|
||||
|
||||
def test_create_subscription_with_trial_with_correct_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.trial_period_start = nowdate()
|
||||
subscription.trial_period_end = add_months(nowdate(), 1)
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
subscription = create_subscription(
|
||||
trial_period_start=nowdate(), trial_period_end=add_months(nowdate(), 1)
|
||||
)
|
||||
self.assertEqual(subscription.trial_period_start, nowdate())
|
||||
self.assertEqual(subscription.trial_period_end, add_months(nowdate(), 1))
|
||||
self.assertEqual(
|
||||
@ -126,12 +44,7 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.status, "Trialling")
|
||||
|
||||
def test_create_subscription_without_trial_with_correct_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
subscription = create_subscription()
|
||||
self.assertEqual(subscription.trial_period_start, None)
|
||||
self.assertEqual(subscription.trial_period_end, None)
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
@ -141,55 +54,28 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
def test_create_subscription_trial_with_wrong_dates(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.trial_period_end = nowdate()
|
||||
subscription.trial_period_start = add_days(nowdate(), 30)
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
|
||||
def test_create_subscription_multi_with_different_billing_fails(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.trial_period_end = nowdate()
|
||||
subscription.trial_period_start = add_days(nowdate(), 30)
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 3", "qty": 1})
|
||||
|
||||
subscription = create_subscription(
|
||||
trial_period_start=add_days(nowdate(), 30), trial_period_end=nowdate(), do_not_save=True
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, subscription.save)
|
||||
|
||||
def test_invoice_is_generated_at_end_of_billing_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.insert()
|
||||
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-01-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-01-31")
|
||||
frappe.flags.current_date = "2018-01-31"
|
||||
subscription.process()
|
||||
|
||||
subscription.process(posting_date="2018-01-31")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.current_invoice_start, "2018-02-01")
|
||||
self.assertEqual(subscription.current_invoice_end, "2018-02-28")
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
def test_status_goes_back_to_active_after_invoice_is_paid(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.generate_invoice_at_period_start = True
|
||||
subscription.insert()
|
||||
frappe.flags.current_date = "2018-01-01"
|
||||
subscription.process() # generate first invoice
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
)
|
||||
subscription.process(posting_date="2018-01-01") # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
# Status is unpaid as Days until Due is zero and grace period is Zero
|
||||
@ -213,18 +99,10 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
# subscription.generate_invoice_at_period_start = True
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.insert()
|
||||
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
frappe.flags.current_date = "2018-01-31"
|
||||
subscription.process() # generate first invoice
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
# This should change status to Cancelled since grace period is 0
|
||||
# And is backdated subscription so subscription will be cancelled after processing
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
@ -235,13 +113,8 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.cancel_after_grace = 0
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.insert()
|
||||
subscription.process() # generate first invoice
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
|
||||
# Status is unpaid as Days until Due is zero and grace period is Zero
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
@ -251,21 +124,9 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
def test_subscription_invoice_days_until_due(self):
|
||||
_date = add_months(nowdate(), -1)
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.days_until_due = 10
|
||||
subscription.start_date = _date
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.insert()
|
||||
subscription = create_subscription(start_date=_date, days_until_due=10)
|
||||
|
||||
frappe.flags.current_date = subscription.current_invoice_end
|
||||
|
||||
subscription.process() # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
frappe.flags.current_date = add_days(subscription.current_invoice_end, 3)
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@ -275,16 +136,9 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.grace_period = 1000
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = add_days(nowdate(), -1000)
|
||||
subscription.insert()
|
||||
|
||||
frappe.flags.current_date = subscription.current_invoice_end
|
||||
subscription.process() # generate first invoice
|
||||
subscription = create_subscription(start_date=add_days(nowdate(), -1000))
|
||||
|
||||
subscription.process(posting_date=subscription.current_invoice_end) # generate first invoice
|
||||
self.assertEqual(subscription.status, "Past Due Date")
|
||||
|
||||
subscription.process()
|
||||
@ -301,12 +155,7 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.save()
|
||||
|
||||
def test_subscription_remains_active_during_invoice_period(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription.process() # no changes expected
|
||||
subscription = create_subscription() # no changes expected
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
self.assertEqual(subscription.current_invoice_start, nowdate())
|
||||
@ -325,12 +174,8 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.current_invoice_end, add_to_date(nowdate(), months=1, days=-1))
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
def test_subscription_cancelation(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
def test_subscription_cancellation(self):
|
||||
subscription = create_subscription()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
self.assertEqual(subscription.status, "Cancelled")
|
||||
@ -341,11 +186,7 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription()
|
||||
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
@ -365,7 +206,7 @@ class TestSubscription(unittest.TestCase):
|
||||
get_prorata_factor(
|
||||
subscription.current_invoice_end,
|
||||
subscription.current_invoice_start,
|
||||
subscription.generate_invoice_at_period_start,
|
||||
cint(subscription.generate_invoice_at == "Beginning of the current subscription period"),
|
||||
),
|
||||
2,
|
||||
),
|
||||
@ -383,11 +224,7 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = 0
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription()
|
||||
subscription.cancel_subscription()
|
||||
invoice = subscription.get_current_invoice()
|
||||
|
||||
@ -402,11 +239,7 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
@ -421,18 +254,13 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = to_prorate
|
||||
settings.save()
|
||||
|
||||
def test_subcription_cancellation_and_process(self):
|
||||
def test_subscription_cancellation_and_process(self):
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
default_grace_period_action = settings.cancel_after_grace
|
||||
settings.cancel_after_grace = 1
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.insert()
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process() # generate first invoice
|
||||
|
||||
# Generate an invoice for the cancelled period
|
||||
@ -458,14 +286,8 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.cancel_after_grace = 0
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.insert()
|
||||
frappe.flags.current_date = "2018-01-31"
|
||||
subscription.process() # generate first invoice
|
||||
subscription = create_subscription(start_date="2018-01-01")
|
||||
subscription.process(posting_date="2018-01-31") # generate first invoice
|
||||
|
||||
# Status is unpaid as Days until Due is zero and grace period is Zero
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
@ -494,17 +316,10 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.cancel_after_grace = 0
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.generate_invoice_at_period_start = True
|
||||
subscription.insert()
|
||||
|
||||
frappe.flags.current_date = subscription.current_invoice_start
|
||||
|
||||
subscription.process() # generate first invoice
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01", generate_invoice_at="Beginning of the current subscription period"
|
||||
)
|
||||
subscription.process(subscription.current_invoice_start) # generate first invoice
|
||||
# This should change status to Unpaid since grace period is 0
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
@ -516,29 +331,18 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(subscription.status, "Active")
|
||||
|
||||
# A new invoice is generated
|
||||
frappe.flags.current_date = subscription.current_invoice_start
|
||||
subscription.process()
|
||||
subscription.process(posting_date=subscription.current_invoice_start)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
settings.cancel_after_grace = default_grace_period_action
|
||||
settings.save()
|
||||
|
||||
def test_restart_active_subscription(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
|
||||
subscription = create_subscription()
|
||||
self.assertRaises(frappe.ValidationError, subscription.restart_subscription)
|
||||
|
||||
def test_subscription_invoice_discount_percentage(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.additional_discount_percentage = 10
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription(additional_discount_percentage=10)
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
@ -547,12 +351,7 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(invoice.apply_discount_on, "Grand Total")
|
||||
|
||||
def test_subscription_invoice_discount_amount(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.additional_discount_amount = 11
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription(additional_discount_amount=11)
|
||||
subscription.cancel_subscription()
|
||||
|
||||
invoice = subscription.get_current_invoice()
|
||||
@ -563,18 +362,13 @@ class TestSubscription(unittest.TestCase):
|
||||
def test_prepaid_subscriptions(self):
|
||||
# Create a non pre-billed subscription, processing should not create
|
||||
# invoices.
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription()
|
||||
subscription.process()
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 0)
|
||||
|
||||
# Change the subscription type to prebilled and process it.
|
||||
# Prepaid invoice should be generated
|
||||
subscription.generate_invoice_at_period_start = True
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.save()
|
||||
subscription.process()
|
||||
|
||||
@ -586,12 +380,9 @@ class TestSubscription(unittest.TestCase):
|
||||
settings.prorate = 1
|
||||
settings.save()
|
||||
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Customer"
|
||||
subscription.generate_invoice_at_period_start = True
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription(
|
||||
generate_invoice_at="Beginning of the current subscription period"
|
||||
)
|
||||
subscription.process()
|
||||
subscription.cancel_subscription()
|
||||
|
||||
@ -609,9 +400,10 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
def test_subscription_with_follow_calendar_months(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.company = "_Test Company"
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
subscription.generate_invoice_at = "Beginning of the current subscription period"
|
||||
subscription.follow_calendar_months = 1
|
||||
|
||||
# select subscription start date as "2018-01-15"
|
||||
@ -625,39 +417,33 @@ class TestSubscription(unittest.TestCase):
|
||||
self.assertEqual(get_date_str(subscription.current_invoice_end), "2018-03-31")
|
||||
|
||||
def test_subscription_generate_invoice_past_due(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
subscription.generate_new_invoices_past_due_date = 1
|
||||
# select subscription start date as "2018-01-15"
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
party_type="Supplier",
|
||||
party="_Test Supplier",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
generate_new_invoices_past_due_date=1,
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
frappe.flags.current_date = "2018-01-01"
|
||||
# Process subscription and create first invoice
|
||||
# Subscription status will be unpaid since due date has already passed
|
||||
subscription.process()
|
||||
subscription.process(posting_date="2018-01-01")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, "Unpaid")
|
||||
|
||||
# Now the Subscription is unpaid
|
||||
# Even then new invoice should be created as we have enabled `generate_new_invoices_past_due_date` in
|
||||
# subscription and the interval between the subscriptions is 3 months
|
||||
frappe.flags.current_date = "2018-04-01"
|
||||
subscription.process()
|
||||
subscription.process(posting_date="2018-04-01")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
def test_subscription_without_generate_invoice_past_due(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Supplier"
|
||||
subscription.party = "_Test Supplier"
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
# select subscription start date as "2018-01-15"
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.append("plans", {"plan": "_Test Plan Name 4", "qty": 1})
|
||||
subscription.save()
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test Plan Name 4", "qty": 1}],
|
||||
)
|
||||
|
||||
# Process subscription and create first invoice
|
||||
# Subscription status will be unpaid since due date has already passed
|
||||
@ -668,16 +454,13 @@ class TestSubscription(unittest.TestCase):
|
||||
subscription.process()
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_multicurrency_subscription(self):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Subscription Customer"
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
subscription.company = "_Test Company"
|
||||
# select subscription start date as "2018-01-15"
|
||||
subscription.start_date = "2018-01-01"
|
||||
subscription.append("plans", {"plan": "_Test Plan Multicurrency", "qty": 1})
|
||||
subscription.save()
|
||||
def test_multi_currency_subscription(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2018-01-01",
|
||||
generate_invoice_at="Beginning of the current subscription period",
|
||||
plans=[{"plan": "_Test Plan Multicurrency", "qty": 1}],
|
||||
party="_Test Subscription Customer",
|
||||
)
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
@ -689,42 +472,135 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
def test_subscription_recovery(self):
|
||||
"""Test if Subscription recovers when start/end date run out of sync with created invoices."""
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = "Customer"
|
||||
subscription.party = "_Test Subscription Customer"
|
||||
subscription.company = "_Test Company"
|
||||
subscription.start_date = "2021-12-01"
|
||||
subscription.generate_new_invoices_past_due_date = 1
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
subscription.submit_invoice = 0
|
||||
subscription.save()
|
||||
subscription = create_subscription(
|
||||
start_date="2021-01-01",
|
||||
submit_invoice=0,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
party="_Test Subscription Customer",
|
||||
)
|
||||
|
||||
# create invoices for the first two moths
|
||||
frappe.flags.current_date = "2021-12-31"
|
||||
subscription.process()
|
||||
subscription.process(posting_date="2021-01-31")
|
||||
|
||||
frappe.flags.current_date = "2022-01-31"
|
||||
subscription.process()
|
||||
subscription.process(posting_date="2021-02-28")
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
|
||||
getdate("2021-12-01"),
|
||||
getdate("2021-01-01"),
|
||||
)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
|
||||
getdate("2022-01-01"),
|
||||
getdate("2021-02-01"),
|
||||
)
|
||||
|
||||
# recreate most recent invoice
|
||||
subscription.process()
|
||||
subscription.process(posting_date="2022-01-31")
|
||||
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[0].name, "from_date")),
|
||||
getdate("2021-12-01"),
|
||||
getdate("2021-01-01"),
|
||||
)
|
||||
self.assertEqual(
|
||||
getdate(frappe.db.get_value("Sales Invoice", subscription.invoices[1].name, "from_date")),
|
||||
getdate("2022-01-01"),
|
||||
getdate("2021-02-01"),
|
||||
)
|
||||
|
||||
def test_subscription_invoice_generation_before_days(self):
|
||||
subscription = create_subscription(
|
||||
start_date="2023-01-01",
|
||||
generate_invoice_at="Days before the current subscription period",
|
||||
number_of_days=10,
|
||||
generate_new_invoices_past_due_date=1,
|
||||
)
|
||||
|
||||
subscription.process(posting_date="2022-12-22")
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
subscription.process(posting_date="2023-01-22")
|
||||
self.assertEqual(len(subscription.invoices), 2)
|
||||
|
||||
|
||||
def make_plans():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900)
|
||||
create_plan(plan_name="_Test Plan Name 2", cost=1999)
|
||||
create_plan(
|
||||
plan_name="_Test Plan Name 3", cost=1999, billing_interval="Day", billing_interval_count=14
|
||||
)
|
||||
create_plan(
|
||||
plan_name="_Test Plan Name 4", cost=20000, billing_interval="Month", billing_interval_count=3
|
||||
)
|
||||
create_plan(
|
||||
plan_name="_Test Plan Multicurrency", cost=50, billing_interval="Month", currency="USD"
|
||||
)
|
||||
|
||||
|
||||
def create_plan(**kwargs):
|
||||
if not frappe.db.exists("Subscription Plan", kwargs.get("plan_name")):
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = kwargs.get("plan_name") or "_Test Plan Name"
|
||||
plan.item = kwargs.get("item") or "_Test Non Stock Item"
|
||||
plan.price_determination = kwargs.get("price_determination") or "Fixed Rate"
|
||||
plan.cost = kwargs.get("cost") or 1000
|
||||
plan.billing_interval = kwargs.get("billing_interval") or "Month"
|
||||
plan.billing_interval_count = kwargs.get("billing_interval_count") or 1
|
||||
plan.currency = kwargs.get("currency")
|
||||
plan.insert()
|
||||
|
||||
|
||||
def create_parties():
|
||||
if not frappe.db.exists("Supplier", "_Test Supplier"):
|
||||
supplier = frappe.new_doc("Supplier")
|
||||
supplier.supplier_name = "_Test Supplier"
|
||||
supplier.supplier_group = "All Supplier Groups"
|
||||
supplier.insert()
|
||||
|
||||
if not frappe.db.exists("Customer", "_Test Subscription Customer"):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "_Test Subscription Customer"
|
||||
customer.billing_currency = "USD"
|
||||
customer.append(
|
||||
"accounts", {"company": "_Test Company", "account": "_Test Receivable USD - _TC"}
|
||||
)
|
||||
customer.insert()
|
||||
|
||||
|
||||
def reset_settings():
|
||||
settings = frappe.get_single("Subscription Settings")
|
||||
settings.grace_period = 0
|
||||
settings.cancel_after_grace = 0
|
||||
settings.save()
|
||||
|
||||
|
||||
def create_subscription(**kwargs):
|
||||
subscription = frappe.new_doc("Subscription")
|
||||
subscription.party_type = (kwargs.get("party_type") or "Customer",)
|
||||
subscription.company = kwargs.get("company") or "_Test Company"
|
||||
subscription.party = kwargs.get("party") or "_Test Customer"
|
||||
subscription.trial_period_start = kwargs.get("trial_period_start")
|
||||
subscription.trial_period_end = kwargs.get("trial_period_end")
|
||||
subscription.start_date = kwargs.get("start_date")
|
||||
subscription.generate_invoice_at = kwargs.get("generate_invoice_at")
|
||||
subscription.additional_discount_percentage = kwargs.get("additional_discount_percentage")
|
||||
subscription.additional_discount_amount = kwargs.get("additional_discount_amount")
|
||||
subscription.follow_calendar_months = kwargs.get("follow_calendar_months")
|
||||
subscription.generate_new_invoices_past_due_date = kwargs.get(
|
||||
"generate_new_invoices_past_due_date"
|
||||
)
|
||||
subscription.submit_invoice = kwargs.get("submit_invoice")
|
||||
subscription.days_until_due = kwargs.get("days_until_due")
|
||||
subscription.number_of_days = kwargs.get("number_of_days")
|
||||
|
||||
if not kwargs.get("plans"):
|
||||
subscription.append("plans", {"plan": "_Test Plan Name", "qty": 1})
|
||||
else:
|
||||
for plan in kwargs.get("plans"):
|
||||
subscription.append("plans", plan)
|
||||
|
||||
if kwargs.get("do_not_save"):
|
||||
return subscription
|
||||
|
||||
subscription.save()
|
||||
|
||||
return subscription
|
||||
|
@ -57,18 +57,17 @@ def get_plan_rate(
|
||||
prorate = frappe.db.get_single_value("Subscription Settings", "prorate")
|
||||
|
||||
if prorate:
|
||||
prorate_factor = flt(
|
||||
date_diff(start_date, get_first_day(start_date))
|
||||
/ date_diff(get_last_day(start_date), get_first_day(start_date)),
|
||||
1,
|
||||
)
|
||||
|
||||
prorate_factor += flt(
|
||||
date_diff(get_last_day(end_date), end_date)
|
||||
/ date_diff(get_last_day(end_date), get_first_day(end_date)),
|
||||
1,
|
||||
)
|
||||
|
||||
cost -= plan.cost * prorate_factor
|
||||
|
||||
cost -= plan.cost * get_prorate_factor(start_date, end_date)
|
||||
return cost
|
||||
|
||||
|
||||
def get_prorate_factor(start_date, end_date):
|
||||
total_days_to_skip = date_diff(start_date, get_first_day(start_date))
|
||||
total_days_in_month = int(get_last_day(start_date).strftime("%d"))
|
||||
prorate_factor = flt(total_days_to_skip / total_days_in_month)
|
||||
|
||||
total_days_to_skip = date_diff(get_last_day(end_date), end_date)
|
||||
total_days_in_month = int(get_last_day(end_date).strftime("%d"))
|
||||
prorate_factor += flt(total_days_to_skip / total_days_in_month)
|
||||
|
||||
return prorate_factor
|
||||
|
@ -0,0 +1,83 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2023-08-22 10:28:10.196712",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account",
|
||||
"party_type",
|
||||
"party",
|
||||
"reference_doctype",
|
||||
"reference_name",
|
||||
"allocated_amount",
|
||||
"account_currency",
|
||||
"unlinked"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "reference_name",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference Name",
|
||||
"options": "reference_doctype"
|
||||
},
|
||||
{
|
||||
"fieldname": "allocated_amount",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Allocated Amount",
|
||||
"options": "account_currency"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "unlinked",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Unlinked",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "reference_doctype",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Reference Type",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "account",
|
||||
"fieldtype": "Data",
|
||||
"label": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "party_type",
|
||||
"fieldtype": "Data",
|
||||
"label": "Party Type"
|
||||
},
|
||||
{
|
||||
"fieldname": "party",
|
||||
"fieldtype": "Data",
|
||||
"label": "Party"
|
||||
},
|
||||
{
|
||||
"fieldname": "account_currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Account Currency",
|
||||
"options": "Currency",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-09-05 09:33:28.620149",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Unreconcile Payment Entries",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class UnreconcilePaymentEntries(Document):
|
||||
pass
|
@ -0,0 +1,316 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
|
||||
|
||||
class TestUnreconcilePayments(AccountsTestMixin, FrappeTestCase):
|
||||
def setUp(self):
|
||||
self.create_company()
|
||||
self.create_customer()
|
||||
self.create_usd_receivable_account()
|
||||
self.create_item()
|
||||
self.clear_old_entries()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=do_not_submit,
|
||||
)
|
||||
return si
|
||||
|
||||
def create_payment_entry(self):
|
||||
pe = create_payment_entry(
|
||||
company=self.company,
|
||||
payment_type="Receive",
|
||||
party_type="Customer",
|
||||
party=self.customer,
|
||||
paid_from=self.debit_to,
|
||||
paid_to=self.cash,
|
||||
paid_amount=200,
|
||||
save=True,
|
||||
)
|
||||
return pe
|
||||
|
||||
def test_01_unreconcile_invoice(self):
|
||||
si1 = self.create_sales_invoice()
|
||||
si2 = self.create_sales_invoice()
|
||||
|
||||
pe = self.create_payment_entry()
|
||||
pe.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
|
||||
)
|
||||
pe.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
|
||||
)
|
||||
# Allocation payment against both invoices
|
||||
pe.save().submit()
|
||||
|
||||
# Assert outstanding
|
||||
[doc.reload() for doc in [si1, si2, pe]]
|
||||
self.assertEqual(si1.outstanding_amount, 0)
|
||||
self.assertEqual(si2.outstanding_amount, 0)
|
||||
self.assertEqual(pe.unallocated_amount, 0)
|
||||
|
||||
unreconcile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Unreconcile Payments",
|
||||
"company": self.company,
|
||||
"voucher_type": pe.doctype,
|
||||
"voucher_no": pe.name,
|
||||
}
|
||||
)
|
||||
unreconcile.add_references()
|
||||
self.assertEqual(len(unreconcile.allocations), 2)
|
||||
allocations = [x.reference_name for x in unreconcile.allocations]
|
||||
self.assertEquals([si1.name, si2.name], allocations)
|
||||
# unreconcile si1
|
||||
for x in unreconcile.allocations:
|
||||
if x.reference_name != si1.name:
|
||||
unreconcile.remove(x)
|
||||
unreconcile.save().submit()
|
||||
|
||||
# Assert outstanding
|
||||
[doc.reload() for doc in [si1, si2, pe]]
|
||||
self.assertEqual(si1.outstanding_amount, 100)
|
||||
self.assertEqual(si2.outstanding_amount, 0)
|
||||
self.assertEqual(len(pe.references), 1)
|
||||
self.assertEqual(pe.unallocated_amount, 100)
|
||||
|
||||
def test_02_unreconcile_one_payment_from_multi_payments(self):
|
||||
"""
|
||||
Scenario: 2 payments, both split against 2 different invoices
|
||||
Unreconcile only one payment from one invoice
|
||||
"""
|
||||
si1 = self.create_sales_invoice()
|
||||
si2 = self.create_sales_invoice()
|
||||
pe1 = self.create_payment_entry()
|
||||
pe1.paid_amount = 100
|
||||
# Allocate payment against both invoices
|
||||
pe1.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
|
||||
)
|
||||
pe1.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
|
||||
)
|
||||
pe1.save().submit()
|
||||
|
||||
pe2 = self.create_payment_entry()
|
||||
pe2.paid_amount = 100
|
||||
# Allocate payment against both invoices
|
||||
pe2.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
|
||||
)
|
||||
pe2.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
|
||||
)
|
||||
pe2.save().submit()
|
||||
|
||||
# Assert outstanding and unallocated
|
||||
[doc.reload() for doc in [si1, si2, pe1, pe2]]
|
||||
self.assertEqual(si1.outstanding_amount, 0.0)
|
||||
self.assertEqual(si2.outstanding_amount, 0.0)
|
||||
self.assertEqual(pe1.unallocated_amount, 0.0)
|
||||
self.assertEqual(pe2.unallocated_amount, 0.0)
|
||||
|
||||
unreconcile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Unreconcile Payments",
|
||||
"company": self.company,
|
||||
"voucher_type": pe2.doctype,
|
||||
"voucher_no": pe2.name,
|
||||
}
|
||||
)
|
||||
unreconcile.add_references()
|
||||
self.assertEqual(len(unreconcile.allocations), 2)
|
||||
allocations = [x.reference_name for x in unreconcile.allocations]
|
||||
self.assertEquals([si1.name, si2.name], allocations)
|
||||
# unreconcile si1 from pe2
|
||||
for x in unreconcile.allocations:
|
||||
if x.reference_name != si1.name:
|
||||
unreconcile.remove(x)
|
||||
unreconcile.save().submit()
|
||||
|
||||
# Assert outstanding and unallocated
|
||||
[doc.reload() for doc in [si1, si2, pe1, pe2]]
|
||||
self.assertEqual(si1.outstanding_amount, 50)
|
||||
self.assertEqual(si2.outstanding_amount, 0)
|
||||
self.assertEqual(len(pe1.references), 2)
|
||||
self.assertEqual(len(pe2.references), 1)
|
||||
self.assertEqual(pe1.unallocated_amount, 0)
|
||||
self.assertEqual(pe2.unallocated_amount, 50)
|
||||
|
||||
def test_03_unreconciliation_on_multi_currency_invoice(self):
|
||||
self.create_customer("_Test MC Customer USD", "USD")
|
||||
si1 = self.create_sales_invoice(do_not_submit=True)
|
||||
si1.currency = "USD"
|
||||
si1.debit_to = self.debtors_usd
|
||||
si1.conversion_rate = 80
|
||||
si1.save().submit()
|
||||
|
||||
si2 = self.create_sales_invoice(do_not_submit=True)
|
||||
si2.currency = "USD"
|
||||
si2.debit_to = self.debtors_usd
|
||||
si2.conversion_rate = 80
|
||||
si2.save().submit()
|
||||
|
||||
pe = self.create_payment_entry()
|
||||
pe.paid_from = self.debtors_usd
|
||||
pe.paid_from_account_currency = "USD"
|
||||
pe.source_exchange_rate = 75
|
||||
pe.received_amount = 75 * 200
|
||||
pe.save()
|
||||
# Allocate payment against both invoices
|
||||
pe.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 100},
|
||||
)
|
||||
pe.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 100},
|
||||
)
|
||||
pe.save().submit()
|
||||
|
||||
unreconcile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Unreconcile Payments",
|
||||
"company": self.company,
|
||||
"voucher_type": pe.doctype,
|
||||
"voucher_no": pe.name,
|
||||
}
|
||||
)
|
||||
unreconcile.add_references()
|
||||
self.assertEqual(len(unreconcile.allocations), 2)
|
||||
allocations = [x.reference_name for x in unreconcile.allocations]
|
||||
self.assertEquals([si1.name, si2.name], allocations)
|
||||
# unreconcile si1 from pe
|
||||
for x in unreconcile.allocations:
|
||||
if x.reference_name != si1.name:
|
||||
unreconcile.remove(x)
|
||||
unreconcile.save().submit()
|
||||
|
||||
# Assert outstanding and unallocated
|
||||
[doc.reload() for doc in [si1, si2, pe]]
|
||||
self.assertEqual(si1.outstanding_amount, 100)
|
||||
self.assertEqual(si2.outstanding_amount, 0)
|
||||
self.assertEqual(len(pe.references), 1)
|
||||
self.assertEqual(pe.unallocated_amount, 100)
|
||||
|
||||
# Exc gain/loss JE should've been cancelled as well
|
||||
self.assertEqual(
|
||||
frappe.db.count(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_04_unreconciliation_on_multi_currency_invoice(self):
|
||||
"""
|
||||
2 payments split against 2 foreign currency invoices
|
||||
"""
|
||||
self.create_customer("_Test MC Customer USD", "USD")
|
||||
si1 = self.create_sales_invoice(do_not_submit=True)
|
||||
si1.currency = "USD"
|
||||
si1.debit_to = self.debtors_usd
|
||||
si1.conversion_rate = 80
|
||||
si1.save().submit()
|
||||
|
||||
si2 = self.create_sales_invoice(do_not_submit=True)
|
||||
si2.currency = "USD"
|
||||
si2.debit_to = self.debtors_usd
|
||||
si2.conversion_rate = 80
|
||||
si2.save().submit()
|
||||
|
||||
pe1 = self.create_payment_entry()
|
||||
pe1.paid_from = self.debtors_usd
|
||||
pe1.paid_from_account_currency = "USD"
|
||||
pe1.source_exchange_rate = 75
|
||||
pe1.received_amount = 75 * 100
|
||||
pe1.save()
|
||||
# Allocate payment against both invoices
|
||||
pe1.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
|
||||
)
|
||||
pe1.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
|
||||
)
|
||||
pe1.save().submit()
|
||||
|
||||
pe2 = self.create_payment_entry()
|
||||
pe2.paid_from = self.debtors_usd
|
||||
pe2.paid_from_account_currency = "USD"
|
||||
pe2.source_exchange_rate = 75
|
||||
pe2.received_amount = 75 * 100
|
||||
pe2.save()
|
||||
# Allocate payment against both invoices
|
||||
pe2.append(
|
||||
"references",
|
||||
{"reference_doctype": si1.doctype, "reference_name": si1.name, "allocated_amount": 50},
|
||||
)
|
||||
pe2.append(
|
||||
"references",
|
||||
{"reference_doctype": si2.doctype, "reference_name": si2.name, "allocated_amount": 50},
|
||||
)
|
||||
pe2.save().submit()
|
||||
|
||||
unreconcile = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Unreconcile Payments",
|
||||
"company": self.company,
|
||||
"voucher_type": pe2.doctype,
|
||||
"voucher_no": pe2.name,
|
||||
}
|
||||
)
|
||||
unreconcile.add_references()
|
||||
self.assertEqual(len(unreconcile.allocations), 2)
|
||||
allocations = [x.reference_name for x in unreconcile.allocations]
|
||||
self.assertEquals([si1.name, si2.name], allocations)
|
||||
# unreconcile si1 from pe2
|
||||
for x in unreconcile.allocations:
|
||||
if x.reference_name != si1.name:
|
||||
unreconcile.remove(x)
|
||||
unreconcile.save().submit()
|
||||
|
||||
# Assert outstanding and unallocated
|
||||
[doc.reload() for doc in [si1, si2, pe1, pe2]]
|
||||
self.assertEqual(si1.outstanding_amount, 50)
|
||||
self.assertEqual(si2.outstanding_amount, 0)
|
||||
self.assertEqual(len(pe1.references), 2)
|
||||
self.assertEqual(len(pe2.references), 1)
|
||||
self.assertEqual(pe1.unallocated_amount, 0)
|
||||
self.assertEqual(pe2.unallocated_amount, 50)
|
||||
|
||||
# Exc gain/loss JE from PE1 should be available
|
||||
self.assertEqual(
|
||||
frappe.db.count(
|
||||
"Journal Entry Account",
|
||||
filters={"reference_type": si1.doctype, "reference_name": si1.name, "docstatus": 1},
|
||||
),
|
||||
1,
|
||||
)
|
@ -0,0 +1,41 @@
|
||||
// Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Unreconcile Payments", {
|
||||
refresh(frm) {
|
||||
frm.set_query("voucher_type", function() {
|
||||
return {
|
||||
filters: {
|
||||
name: ["in", ["Payment Entry", "Journal Entry"]]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
frm.set_query("voucher_no", function(doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
docstatus: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
get_allocations: function(frm) {
|
||||
frm.clear_table("allocations");
|
||||
frappe.call({
|
||||
method: "get_allocations_from_payment",
|
||||
doc: frm.doc,
|
||||
callback: function(r) {
|
||||
if (r.message) {
|
||||
r.message.forEach(x => {
|
||||
frm.add_child("allocations", x)
|
||||
})
|
||||
frm.refresh_fields();
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
});
|
@ -0,0 +1,93 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"autoname": "format:UNREC-{#####}",
|
||||
"creation": "2023-08-22 10:26:34.421423",
|
||||
"default_view": "List",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"company",
|
||||
"voucher_type",
|
||||
"voucher_no",
|
||||
"get_allocations",
|
||||
"allocations",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Unreconcile Payments",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company"
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_type",
|
||||
"fieldtype": "Link",
|
||||
"label": "Voucher Type",
|
||||
"options": "DocType"
|
||||
},
|
||||
{
|
||||
"fieldname": "voucher_no",
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Voucher No",
|
||||
"options": "voucher_type"
|
||||
},
|
||||
{
|
||||
"fieldname": "get_allocations",
|
||||
"fieldtype": "Button",
|
||||
"label": "Get Allocations"
|
||||
},
|
||||
{
|
||||
"fieldname": "allocations",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allocations",
|
||||
"options": "Unreconcile Payment Entries"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2023-08-28 17:42:50.261377",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Unreconcile Payments",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts Manager",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"read": 1,
|
||||
"role": "Accounts User",
|
||||
"select": 1,
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
@ -0,0 +1,158 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.utils.data import comma_and
|
||||
|
||||
from erpnext.accounts.utils import (
|
||||
cancel_exchange_gain_loss_journal,
|
||||
unlink_ref_doc_from_payment_entries,
|
||||
update_voucher_outstanding,
|
||||
)
|
||||
|
||||
|
||||
class UnreconcilePayments(Document):
|
||||
def validate(self):
|
||||
self.supported_types = ["Payment Entry", "Journal Entry"]
|
||||
if not self.voucher_type in self.supported_types:
|
||||
frappe.throw(_("Only {0} are supported").format(comma_and(self.supported_types)))
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_allocations_from_payment(self):
|
||||
allocated_references = []
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
allocated_references = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.account,
|
||||
ple.party_type,
|
||||
ple.party,
|
||||
ple.against_voucher_type.as_("reference_doctype"),
|
||||
ple.against_voucher_no.as_("reference_name"),
|
||||
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
|
||||
ple.account_currency,
|
||||
)
|
||||
.where(
|
||||
(ple.docstatus == 1)
|
||||
& (ple.voucher_type == self.voucher_type)
|
||||
& (ple.voucher_no == self.voucher_no)
|
||||
& (ple.voucher_no != ple.against_voucher_no)
|
||||
)
|
||||
.groupby(ple.against_voucher_type, ple.against_voucher_no)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
return allocated_references
|
||||
|
||||
def add_references(self):
|
||||
allocations = self.get_allocations_from_payment()
|
||||
|
||||
for alloc in allocations:
|
||||
self.append("allocations", alloc)
|
||||
|
||||
def on_submit(self):
|
||||
# todo: more granular unreconciliation
|
||||
for alloc in self.allocations:
|
||||
doc = frappe.get_doc(alloc.reference_doctype, alloc.reference_name)
|
||||
unlink_ref_doc_from_payment_entries(doc, self.voucher_no)
|
||||
cancel_exchange_gain_loss_journal(doc, self.voucher_type, self.voucher_no)
|
||||
update_voucher_outstanding(
|
||||
alloc.reference_doctype, alloc.reference_name, alloc.account, alloc.party_type, alloc.party
|
||||
)
|
||||
frappe.db.set_value("Unreconcile Payment Entries", alloc.name, "unlinked", True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def doc_has_references(doctype: str = None, docname: str = None):
|
||||
if doctype in ["Sales Invoice", "Purchase Invoice"]:
|
||||
return frappe.db.count(
|
||||
"Payment Ledger Entry",
|
||||
filters={"delinked": 0, "against_voucher_no": docname, "amount": ["<", 0]},
|
||||
)
|
||||
else:
|
||||
return frappe.db.count(
|
||||
"Payment Ledger Entry",
|
||||
filters={"delinked": 0, "voucher_no": docname, "against_voucher_no": ["!=", docname]},
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_linked_payments_for_doc(
|
||||
company: str = None, doctype: str = None, docname: str = None
|
||||
) -> list:
|
||||
if company and doctype and docname:
|
||||
_dt = doctype
|
||||
_dn = docname
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
if _dt in ["Sales Invoice", "Purchase Invoice"]:
|
||||
criteria = [
|
||||
(ple.company == company),
|
||||
(ple.delinked == 0),
|
||||
(ple.against_voucher_no == _dn),
|
||||
(ple.amount < 0),
|
||||
]
|
||||
|
||||
res = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.company,
|
||||
ple.voucher_type,
|
||||
ple.voucher_no,
|
||||
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
|
||||
ple.account_currency,
|
||||
)
|
||||
.where(Criterion.all(criteria))
|
||||
.groupby(ple.voucher_no, ple.against_voucher_no)
|
||||
.having(qb.Field("allocated_amount") > 0)
|
||||
.run(as_dict=True)
|
||||
)
|
||||
return res
|
||||
else:
|
||||
criteria = [
|
||||
(ple.company == company),
|
||||
(ple.delinked == 0),
|
||||
(ple.voucher_no == _dn),
|
||||
(ple.against_voucher_no != _dn),
|
||||
]
|
||||
|
||||
query = (
|
||||
qb.from_(ple)
|
||||
.select(
|
||||
ple.company,
|
||||
ple.against_voucher_type.as_("voucher_type"),
|
||||
ple.against_voucher_no.as_("voucher_no"),
|
||||
Abs(Sum(ple.amount_in_account_currency)).as_("allocated_amount"),
|
||||
ple.account_currency,
|
||||
)
|
||||
.where(Criterion.all(criteria))
|
||||
.groupby(ple.against_voucher_no)
|
||||
)
|
||||
res = query.run(as_dict=True)
|
||||
return res
|
||||
return []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_unreconcile_doc_for_selection(selections=None):
|
||||
if selections:
|
||||
selections = frappe.json.loads(selections)
|
||||
# assuming each row is a unique voucher
|
||||
for row in selections:
|
||||
unrecon = frappe.new_doc("Unreconcile Payments")
|
||||
unrecon.company = row.get("company")
|
||||
unrecon.voucher_type = row.get("voucher_type")
|
||||
unrecon.voucher_no = row.get("voucher_no")
|
||||
unrecon.add_references()
|
||||
|
||||
# remove unselected references
|
||||
unrecon.allocations = [
|
||||
x
|
||||
for x in unrecon.allocations
|
||||
if x.reference_doctype == row.get("against_voucher_type")
|
||||
and x.reference_name == row.get("against_voucher_no")
|
||||
]
|
||||
unrecon.save().submit()
|
@ -24,7 +24,7 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_accounts_receivable_with_supplier(self):
|
||||
def test_accounts_payable_for_foreign_currency_supplier(self):
|
||||
pi = self.create_purchase_invoice(do_not_submit=True)
|
||||
pi.currency = "USD"
|
||||
pi.conversion_rate = 80
|
||||
|
@ -38,32 +38,31 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldname": "customer",
|
||||
"label": __("Customer"),
|
||||
"fieldname": "party_type",
|
||||
"label": __("Party Type"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"options": "Party Type",
|
||||
"Default": "Customer",
|
||||
get_query: () => {
|
||||
return {
|
||||
filters: {
|
||||
'account_type': 'Receivable'
|
||||
}
|
||||
};
|
||||
},
|
||||
on_change: () => {
|
||||
var customer = frappe.query_report.get_filter_value('customer');
|
||||
var company = frappe.query_report.get_filter_value('company');
|
||||
if (customer) {
|
||||
frappe.db.get_value('Customer', customer, ["customer_name", "payment_terms"], function(value) {
|
||||
frappe.query_report.set_filter_value('customer_name', value["customer_name"]);
|
||||
frappe.query_report.set_filter_value('payment_terms', value["payment_terms"]);
|
||||
});
|
||||
frappe.query_report.set_filter_value('party', "");
|
||||
let party_type = frappe.query_report.get_filter_value('party_type');
|
||||
frappe.query_report.toggle_filter_display('customer_group', frappe.query_report.get_filter_value('party_type') !== "Customer");
|
||||
|
||||
frappe.db.get_value('Customer Credit Limit', {'parent': customer, 'company': company},
|
||||
["credit_limit"], function(value) {
|
||||
if (value) {
|
||||
frappe.query_report.set_filter_value('credit_limit', value["credit_limit"]);
|
||||
}
|
||||
}, "Customer");
|
||||
} else {
|
||||
frappe.query_report.set_filter_value('customer_name', "");
|
||||
frappe.query_report.set_filter_value('credit_limit', "");
|
||||
frappe.query_report.set_filter_value('payment_terms', "");
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldname":"party",
|
||||
"label": __("Party"),
|
||||
"fieldtype": "Dynamic Link",
|
||||
"options": "party_type",
|
||||
},
|
||||
{
|
||||
"fieldname": "party_account",
|
||||
"label": __("Receivable Account"),
|
||||
@ -174,24 +173,6 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
"fieldname": "show_remarks",
|
||||
"label": __("Show Remarks"),
|
||||
"fieldtype": "Check",
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_name",
|
||||
"label": __("Customer Name"),
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "payment_terms",
|
||||
"label": __("Payment Tems"),
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "credit_limit",
|
||||
"label": __("Credit Limit"),
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 1
|
||||
}
|
||||
],
|
||||
|
||||
|
@ -769,15 +769,12 @@ class ReceivablePayableReport(object):
|
||||
self.or_filters = []
|
||||
|
||||
for party_type in self.party_type:
|
||||
party_type_field = scrub(party_type)
|
||||
self.or_filters.append(self.ple.party_type == party_type)
|
||||
self.add_common_filters()
|
||||
|
||||
self.add_common_filters(party_type_field=party_type_field)
|
||||
|
||||
if party_type_field == "customer":
|
||||
if self.account_type == "Receivable":
|
||||
self.add_customer_filters()
|
||||
|
||||
elif party_type_field == "supplier":
|
||||
elif self.account_type == "Payable":
|
||||
self.add_supplier_filters()
|
||||
|
||||
if self.filters.cost_center:
|
||||
@ -793,16 +790,13 @@ class ReceivablePayableReport(object):
|
||||
]
|
||||
self.qb_selection_filter.append(self.ple.cost_center.isin(cost_center_list))
|
||||
|
||||
def add_common_filters(self, party_type_field):
|
||||
def add_common_filters(self):
|
||||
if self.filters.company:
|
||||
self.qb_selection_filter.append(self.ple.company == self.filters.company)
|
||||
|
||||
if self.filters.finance_book:
|
||||
self.qb_selection_filter.append(self.ple.finance_book == self.filters.finance_book)
|
||||
|
||||
if self.filters.get(party_type_field):
|
||||
self.qb_selection_filter.append(self.ple.party == self.filters.get(party_type_field))
|
||||
|
||||
if self.filters.get("party_type"):
|
||||
self.qb_selection_filter.append(self.filters.party_type == self.ple.party_type)
|
||||
|
||||
@ -969,6 +963,20 @@ class ReceivablePayableReport(object):
|
||||
fieldtype="Link",
|
||||
options="Contact",
|
||||
)
|
||||
if self.filters.party_type == "Customer":
|
||||
self.add_column(
|
||||
_("Customer Name"),
|
||||
fieldname="customer_name",
|
||||
fieldtype="Link",
|
||||
options="Customer",
|
||||
)
|
||||
elif self.filters.party_type == "Supplier":
|
||||
self.add_column(
|
||||
_("Supplier Name"),
|
||||
fieldname="supplier_name",
|
||||
fieldtype="Link",
|
||||
options="Supplier",
|
||||
)
|
||||
|
||||
self.add_column(label=_("Cost Center"), fieldname="cost_center", fieldtype="Data")
|
||||
self.add_column(label=_("Voucher Type"), fieldname="voucher_type", fieldtype="Data")
|
||||
|
@ -568,3 +568,40 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
row.account_currency,
|
||||
],
|
||||
)
|
||||
|
||||
def test_usd_customer_filter(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"party_type": "Customer",
|
||||
"party": self.customer,
|
||||
"report_date": today(),
|
||||
"range1": 30,
|
||||
"range2": 60,
|
||||
"range3": 90,
|
||||
"range4": 120,
|
||||
}
|
||||
|
||||
si = self.create_sales_invoice(no_payment_schedule=True, do_not_submit=True)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.debit_to = self.debtors_usd
|
||||
si.save().submit()
|
||||
name = si.name
|
||||
|
||||
# check invoice grand total and invoiced column's value for 3 payment terms
|
||||
report = execute(filters)
|
||||
|
||||
expected = {
|
||||
"voucher_type": si.doctype,
|
||||
"voucher_no": si.name,
|
||||
"party_account": self.debtors_usd,
|
||||
"customer_name": self.customer,
|
||||
"invoiced": 100.0,
|
||||
"outstanding": 100.0,
|
||||
"account_currency": "USD",
|
||||
}
|
||||
self.assertEqual(len(report[1]), 1)
|
||||
report_output = report[1][0]
|
||||
for field in expected:
|
||||
with self.subTest(field=field):
|
||||
self.assertEqual(report_output.get(field), expected.get(field))
|
||||
|
@ -81,7 +81,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
|
||||
self.create_item("_Test Internet Subscription", 0, self.warehouse, self.company)
|
||||
item = frappe.get_doc("Item", self.item)
|
||||
item.enable_deferred_revenue = 1
|
||||
item.deferred_revenue_account = self.deferred_revenue_account
|
||||
item.item_defaults[0].deferred_revenue_account = self.deferred_revenue_account
|
||||
item.no_of_months = 3
|
||||
item.save()
|
||||
|
||||
@ -150,7 +150,7 @@ class TestDeferredRevenueAndExpense(FrappeTestCase, AccountsTestMixin):
|
||||
self.create_item("_Test Office Desk", 0, self.warehouse, self.company)
|
||||
item = frappe.get_doc("Item", self.item)
|
||||
item.enable_deferred_expense = 1
|
||||
item.deferred_expense_account = self.deferred_expense_account
|
||||
item.item_defaults[0].deferred_expense_account = self.deferred_expense_account
|
||||
item.no_of_months_exp = 3
|
||||
item.save()
|
||||
|
||||
|
@ -23,6 +23,12 @@ frappe.query_reports["Item-wise Purchase Register"] = {
|
||||
"fieldtype": "Link",
|
||||
"options": "Item",
|
||||
},
|
||||
{
|
||||
"fieldname": "item_group",
|
||||
"label": __("Item Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
},
|
||||
{
|
||||
"fieldname":"supplier",
|
||||
"label": __("Supplier"),
|
||||
|
@ -293,6 +293,7 @@ def get_conditions(filters):
|
||||
("from_date", " and `tabPurchase Invoice`.posting_date>=%(from_date)s"),
|
||||
("to_date", " and `tabPurchase Invoice`.posting_date<=%(to_date)s"),
|
||||
("mode_of_payment", " and ifnull(mode_of_payment, '') = %(mode_of_payment)s"),
|
||||
("item_group", " and ifnull(`tabPurchase Invoice Item`.item_group, '') = %(item_group)s"),
|
||||
):
|
||||
if filters.get(opts[0]):
|
||||
conditions += opts[1]
|
||||
|
@ -99,6 +99,12 @@ frappe.require("assets/erpnext/js/financial_statements.js", function() {
|
||||
"label": __("Include Default Book Entries"),
|
||||
"fieldtype": "Check",
|
||||
"default": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "show_net_values",
|
||||
"label": __("Show net values in opening and closing columns"),
|
||||
"fieldtype": "Check",
|
||||
"default": 1
|
||||
}
|
||||
],
|
||||
"formatter": erpnext.financial_statements.formatter,
|
||||
|
@ -120,7 +120,9 @@ def get_data(filters):
|
||||
ignore_opening_entries=True,
|
||||
)
|
||||
|
||||
calculate_values(accounts, gl_entries_by_account, opening_balances)
|
||||
calculate_values(
|
||||
accounts, gl_entries_by_account, opening_balances, filters.get("show_net_values")
|
||||
)
|
||||
accumulate_values_into_parents(accounts, accounts_by_name)
|
||||
|
||||
data = prepare_data(accounts, filters, parent_children_map, company_currency)
|
||||
@ -310,7 +312,7 @@ def get_opening_balance(
|
||||
return gle
|
||||
|
||||
|
||||
def calculate_values(accounts, gl_entries_by_account, opening_balances):
|
||||
def calculate_values(accounts, gl_entries_by_account, opening_balances, show_net_values):
|
||||
init = {
|
||||
"opening_debit": 0.0,
|
||||
"opening_credit": 0.0,
|
||||
@ -335,7 +337,8 @@ def calculate_values(accounts, gl_entries_by_account, opening_balances):
|
||||
d["closing_debit"] = d["opening_debit"] + d["debit"]
|
||||
d["closing_credit"] = d["opening_credit"] + d["credit"]
|
||||
|
||||
prepare_opening_closing(d)
|
||||
if show_net_values:
|
||||
prepare_opening_closing(d)
|
||||
|
||||
|
||||
def calculate_total_row(accounts, company_currency):
|
||||
@ -375,7 +378,7 @@ def prepare_data(accounts, filters, parent_children_map, company_currency):
|
||||
|
||||
for d in accounts:
|
||||
# Prepare opening closing for group account
|
||||
if parent_children_map.get(d.account):
|
||||
if parent_children_map.get(d.account) and filters.get("show_net_values"):
|
||||
prepare_opening_closing(d)
|
||||
|
||||
has_value = False
|
||||
|
@ -158,6 +158,8 @@ class AccountsTestMixin:
|
||||
"Journal Entry",
|
||||
"Sales Order",
|
||||
"Exchange Rate Revaluation",
|
||||
"Bank Account",
|
||||
"Bank Transaction",
|
||||
]
|
||||
for doctype in doctype_list:
|
||||
qb.from_(qb.DocType(doctype)).delete().where(qb.DocType(doctype).company == self.company).run()
|
||||
|
@ -581,6 +581,10 @@ def update_reference_in_journal_entry(d, journal_entry, do_not_save=False):
|
||||
"""
|
||||
jv_detail = journal_entry.get("accounts", {"name": d["voucher_detail_no"]})[0]
|
||||
|
||||
# Update Advance Paid in SO/PO since they might be getting unlinked
|
||||
if jv_detail.get("reference_type") in ("Sales Order", "Purchase Order"):
|
||||
frappe.get_doc(jv_detail.reference_type, jv_detail.reference_name).set_total_advance_paid()
|
||||
|
||||
if flt(d["unadjusted_amount"]) - flt(d["allocated_amount"]) != 0:
|
||||
# adjust the unreconciled balance
|
||||
amount_in_account_currency = flt(d["unadjusted_amount"]) - flt(d["allocated_amount"])
|
||||
@ -647,6 +651,13 @@ def update_reference_in_payment_entry(
|
||||
|
||||
if d.voucher_detail_no:
|
||||
existing_row = payment_entry.get("references", {"name": d["voucher_detail_no"]})[0]
|
||||
|
||||
# Update Advance Paid in SO/PO since they are getting unlinked
|
||||
if existing_row.get("reference_doctype") in ("Sales Order", "Purchase Order"):
|
||||
frappe.get_doc(
|
||||
existing_row.reference_doctype, existing_row.reference_name
|
||||
).set_total_advance_paid()
|
||||
|
||||
original_row = existing_row.as_dict().copy()
|
||||
existing_row.update(reference_details)
|
||||
|
||||
@ -674,7 +685,9 @@ def update_reference_in_payment_entry(
|
||||
payment_entry.save(ignore_permissions=True)
|
||||
|
||||
|
||||
def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
|
||||
def cancel_exchange_gain_loss_journal(
|
||||
parent_doc: dict | object, referenced_dt: str = None, referenced_dn: str = None
|
||||
) -> None:
|
||||
"""
|
||||
Cancel Exchange Gain/Loss for Sales/Purchase Invoice, if they have any.
|
||||
"""
|
||||
@ -701,76 +714,147 @@ def cancel_exchange_gain_loss_journal(parent_doc: dict | object) -> None:
|
||||
as_list=1,
|
||||
)
|
||||
for doc in gain_loss_journals:
|
||||
frappe.get_doc("Journal Entry", doc[0]).cancel()
|
||||
gain_loss_je = frappe.get_doc("Journal Entry", doc[0])
|
||||
if referenced_dt and referenced_dn:
|
||||
references = [(x.reference_type, x.reference_name) for x in gain_loss_je.accounts]
|
||||
if (
|
||||
len(references) == 2
|
||||
and (referenced_dt, referenced_dn) in references
|
||||
and (parent_doc.doctype, parent_doc.name) in references
|
||||
):
|
||||
# only cancel JE generated against parent_doc and referenced_dn
|
||||
gain_loss_je.cancel()
|
||||
else:
|
||||
gain_loss_je.cancel()
|
||||
|
||||
|
||||
def unlink_ref_doc_from_payment_entries(ref_doc):
|
||||
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name)
|
||||
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name)
|
||||
|
||||
frappe.db.sql(
|
||||
"""update `tabGL Entry`
|
||||
set against_voucher_type=null, against_voucher=null,
|
||||
modified=%s, modified_by=%s
|
||||
where against_voucher_type=%s and against_voucher=%s
|
||||
and voucher_no != ifnull(against_voucher, '')""",
|
||||
(now(), frappe.session.user, ref_doc.doctype, ref_doc.name),
|
||||
def update_accounting_ledgers_after_reference_removal(
|
||||
ref_type: str = None, ref_no: str = None, payment_name: str = None
|
||||
):
|
||||
# General Ledger
|
||||
gle = qb.DocType("GL Entry")
|
||||
gle_update_query = (
|
||||
qb.update(gle)
|
||||
.set(gle.against_voucher_type, None)
|
||||
.set(gle.against_voucher, None)
|
||||
.set(gle.modified, now())
|
||||
.set(gle.modified_by, frappe.session.user)
|
||||
.where((gle.against_voucher_type == ref_type) & (gle.against_voucher == ref_no))
|
||||
)
|
||||
|
||||
if payment_name:
|
||||
gle_update_query = gle_update_query.where(gle.voucher_no == payment_name)
|
||||
gle_update_query.run()
|
||||
|
||||
# Payment Ledger
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
ple_update_query = (
|
||||
qb.update(ple)
|
||||
.set(ple.against_voucher_type, ple.voucher_type)
|
||||
.set(ple.against_voucher_no, ple.voucher_no)
|
||||
.set(ple.modified, now())
|
||||
.set(ple.modified_by, frappe.session.user)
|
||||
.where(
|
||||
(ple.against_voucher_type == ref_type)
|
||||
& (ple.against_voucher_no == ref_no)
|
||||
& (ple.delinked == 0)
|
||||
)
|
||||
)
|
||||
|
||||
qb.update(ple).set(ple.against_voucher_type, ple.voucher_type).set(
|
||||
ple.against_voucher_no, ple.voucher_no
|
||||
).set(ple.modified, now()).set(ple.modified_by, frappe.session.user).where(
|
||||
(ple.against_voucher_type == ref_doc.doctype)
|
||||
& (ple.against_voucher_no == ref_doc.name)
|
||||
& (ple.delinked == 0)
|
||||
).run()
|
||||
if payment_name:
|
||||
ple_update_query = ple_update_query.where(ple.voucher_no == payment_name)
|
||||
ple_update_query.run()
|
||||
|
||||
|
||||
def remove_ref_from_advance_section(ref_doc: object = None):
|
||||
# TODO: this might need some testing
|
||||
if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
ref_doc.set("advances", [])
|
||||
|
||||
frappe.db.sql(
|
||||
"""delete from `tab{0} Advance` where parent = %s""".format(ref_doc.doctype), ref_doc.name
|
||||
)
|
||||
adv_type = qb.DocType(f"{ref_doc.doctype} Advance")
|
||||
qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run()
|
||||
|
||||
|
||||
def remove_ref_doc_link_from_jv(ref_type, ref_no):
|
||||
linked_jv = frappe.db.sql_list(
|
||||
"""select parent from `tabJournal Entry Account`
|
||||
where reference_type=%s and reference_name=%s and docstatus < 2""",
|
||||
(ref_type, ref_no),
|
||||
def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str = None):
|
||||
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name)
|
||||
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name)
|
||||
update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name)
|
||||
remove_ref_from_advance_section(ref_doc)
|
||||
|
||||
|
||||
def remove_ref_doc_link_from_jv(
|
||||
ref_type: str = None, ref_no: str = None, payment_name: str = None
|
||||
):
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
|
||||
linked_jv = (
|
||||
qb.from_(jea)
|
||||
.select(jea.parent)
|
||||
.where((jea.reference_type == ref_type) & (jea.reference_name == ref_no) & (jea.docstatus.lt(2)))
|
||||
.run(as_list=1)
|
||||
)
|
||||
linked_jv = convert_to_list(linked_jv)
|
||||
# remove reference only from specified payment
|
||||
linked_jv = [x for x in linked_jv if x == payment_name] if payment_name else linked_jv
|
||||
|
||||
if linked_jv:
|
||||
frappe.db.sql(
|
||||
"""update `tabJournal Entry Account`
|
||||
set reference_type=null, reference_name = null,
|
||||
modified=%s, modified_by=%s
|
||||
where reference_type=%s and reference_name=%s
|
||||
and docstatus < 2""",
|
||||
(now(), frappe.session.user, ref_type, ref_no),
|
||||
update_query = (
|
||||
qb.update(jea)
|
||||
.set(jea.reference_type, None)
|
||||
.set(jea.reference_name, None)
|
||||
.set(jea.modified, now())
|
||||
.set(jea.modified_by, frappe.session.user)
|
||||
.where((jea.reference_type == ref_type) & (jea.reference_name == ref_no))
|
||||
)
|
||||
|
||||
if payment_name:
|
||||
update_query = update_query.where(jea.parent == payment_name)
|
||||
|
||||
update_query.run()
|
||||
|
||||
frappe.msgprint(_("Journal Entries {0} are un-linked").format("\n".join(linked_jv)))
|
||||
|
||||
|
||||
def remove_ref_doc_link_from_pe(ref_type, ref_no):
|
||||
linked_pe = frappe.db.sql_list(
|
||||
"""select parent from `tabPayment Entry Reference`
|
||||
where reference_doctype=%s and reference_name=%s and docstatus < 2""",
|
||||
(ref_type, ref_no),
|
||||
def convert_to_list(result):
|
||||
"""
|
||||
Convert tuple to list
|
||||
"""
|
||||
return [x[0] for x in result]
|
||||
|
||||
|
||||
def remove_ref_doc_link_from_pe(
|
||||
ref_type: str = None, ref_no: str = None, payment_name: str = None
|
||||
):
|
||||
per = qb.DocType("Payment Entry Reference")
|
||||
pay = qb.DocType("Payment Entry")
|
||||
|
||||
linked_pe = (
|
||||
qb.from_(per)
|
||||
.select(per.parent)
|
||||
.where(
|
||||
(per.reference_doctype == ref_type) & (per.reference_name == ref_no) & (per.docstatus.lt(2))
|
||||
)
|
||||
.run(as_list=1)
|
||||
)
|
||||
linked_pe = convert_to_list(linked_pe)
|
||||
# remove reference only from specified payment
|
||||
linked_pe = [x for x in linked_pe if x == payment_name] if payment_name else linked_pe
|
||||
|
||||
if linked_pe:
|
||||
frappe.db.sql(
|
||||
"""update `tabPayment Entry Reference`
|
||||
set allocated_amount=0, modified=%s, modified_by=%s
|
||||
where reference_doctype=%s and reference_name=%s
|
||||
and docstatus < 2""",
|
||||
(now(), frappe.session.user, ref_type, ref_no),
|
||||
update_query = (
|
||||
qb.update(per)
|
||||
.set(per.allocated_amount, 0)
|
||||
.set(per.modified, now())
|
||||
.set(per.modified_by, frappe.session.user)
|
||||
.where(
|
||||
(per.docstatus.lt(2) & (per.reference_doctype == ref_type) & (per.reference_name == ref_no))
|
||||
)
|
||||
)
|
||||
|
||||
if payment_name:
|
||||
update_query = update_query.where(per.parent == payment_name)
|
||||
|
||||
update_query.run()
|
||||
|
||||
for pe in linked_pe:
|
||||
try:
|
||||
pe_doc = frappe.get_doc("Payment Entry", pe)
|
||||
@ -784,19 +868,13 @@ def remove_ref_doc_link_from_pe(ref_type, ref_no):
|
||||
msg += _("Please cancel payment entry manually first")
|
||||
frappe.throw(msg, exc=PaymentEntryUnlinkError, title=_("Payment Unlink Error"))
|
||||
|
||||
frappe.db.sql(
|
||||
"""update `tabPayment Entry` set total_allocated_amount=%s,
|
||||
base_total_allocated_amount=%s, unallocated_amount=%s, modified=%s, modified_by=%s
|
||||
where name=%s""",
|
||||
(
|
||||
pe_doc.total_allocated_amount,
|
||||
pe_doc.base_total_allocated_amount,
|
||||
pe_doc.unallocated_amount,
|
||||
now(),
|
||||
frappe.session.user,
|
||||
pe,
|
||||
),
|
||||
)
|
||||
qb.update(pay).set(pay.total_allocated_amount, pe_doc.total_allocated_amount).set(
|
||||
pay.base_total_allocated_amount, pe_doc.base_total_allocated_amount
|
||||
).set(pay.unallocated_amount, pe_doc.unallocated_amount).set(pay.modified, now()).set(
|
||||
pay.modified_by, frappe.session.user
|
||||
).where(
|
||||
pay.name == pe
|
||||
).run()
|
||||
|
||||
frappe.msgprint(_("Payment Entries {0} are un-linked").format("\n".join(linked_pe)))
|
||||
|
||||
|
@ -40,16 +40,6 @@ frappe.ui.form.on('Asset Repair', {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let sbb_field = frm.get_docfield('stock_items', 'serial_and_batch_bundle');
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
'item_code': row.doc.item_code,
|
||||
'voucher_type': frm.doc.doctype,
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
@ -61,6 +51,16 @@ frappe.ui.form.on('Asset Repair', {
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
}
|
||||
|
||||
let sbb_field = frm.get_docfield('stock_items', 'serial_and_batch_bundle');
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
'item_code': row.doc.item_code,
|
||||
'voucher_type': frm.doc.doctype,
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
repair_status: (frm) => {
|
||||
|
@ -45,12 +45,13 @@
|
||||
"column_break1",
|
||||
"contact_html",
|
||||
"primary_address_and_contact_detail_section",
|
||||
"supplier_primary_contact",
|
||||
"mobile_no",
|
||||
"email_id",
|
||||
"column_break_44",
|
||||
"supplier_primary_address",
|
||||
"primary_address",
|
||||
"column_break_mglr",
|
||||
"supplier_primary_contact",
|
||||
"mobile_no",
|
||||
"email_id",
|
||||
"accounting_tab",
|
||||
"payment_terms",
|
||||
"default_accounts_section",
|
||||
@ -469,6 +470,10 @@
|
||||
{
|
||||
"fieldname": "column_break_1mqv",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_mglr",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-user",
|
||||
@ -481,7 +486,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2023-06-26 14:20:00.961554",
|
||||
"modified": "2023-09-21 12:24:20.398889",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
@ -7,7 +7,7 @@ import copy
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
from frappe.utils import date_diff, flt, getdate
|
||||
from frappe.utils import cint, date_diff, flt, getdate
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
@ -47,8 +47,10 @@ def get_data(filters):
|
||||
mr.transaction_date.as_("date"),
|
||||
mr_item.schedule_date.as_("required_date"),
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("qty"),
|
||||
Coalesce(mr_item.stock_uom, "").as_("uom"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
Coalesce(mr_item.uom, "").as_("uom"),
|
||||
Coalesce(mr_item.stock_uom, "").as_("stock_uom"),
|
||||
Sum(Coalesce(mr_item.ordered_qty, 0)).as_("ordered_qty"),
|
||||
Sum(Coalesce(mr_item.received_qty, 0)).as_("received_qty"),
|
||||
(Sum(Coalesce(mr_item.stock_qty, 0)) - Sum(Coalesce(mr_item.received_qty, 0))).as_(
|
||||
@ -96,7 +98,7 @@ def get_conditions(filters, query, mr, mr_item):
|
||||
|
||||
|
||||
def update_qty_columns(row_to_update, data_row):
|
||||
fields = ["qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"]
|
||||
fields = ["qty", "stock_qty", "ordered_qty", "received_qty", "qty_to_receive", "qty_to_order"]
|
||||
for field in fields:
|
||||
row_to_update[field] += flt(data_row[field])
|
||||
|
||||
@ -104,16 +106,20 @@ def update_qty_columns(row_to_update, data_row):
|
||||
def prepare_data(data, filters):
|
||||
"""Prepare consolidated Report data and Chart data"""
|
||||
material_request_map, item_qty_map = {}, {}
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
|
||||
for row in data:
|
||||
# item wise map for charts
|
||||
if not row["item_code"] in item_qty_map:
|
||||
item_qty_map[row["item_code"]] = {
|
||||
"qty": row["qty"],
|
||||
"ordered_qty": row["ordered_qty"],
|
||||
"received_qty": row["received_qty"],
|
||||
"qty_to_receive": row["qty_to_receive"],
|
||||
"qty_to_order": row["qty_to_order"],
|
||||
"qty": flt(row["stock_qty"], precision),
|
||||
"stock_qty": flt(row["stock_qty"], precision),
|
||||
"stock_uom": row["stock_uom"],
|
||||
"uom": row["uom"],
|
||||
"ordered_qty": flt(row["ordered_qty"], precision),
|
||||
"received_qty": flt(row["received_qty"], precision),
|
||||
"qty_to_receive": flt(row["qty_to_receive"], precision),
|
||||
"qty_to_order": flt(row["qty_to_order"], precision),
|
||||
}
|
||||
else:
|
||||
item_entry = item_qty_map[row["item_code"]]
|
||||
@ -200,21 +206,34 @@ def get_columns(filters):
|
||||
{"label": _("Item Name"), "fieldname": "item_name", "fieldtype": "Data", "width": 100},
|
||||
{"label": _("Description"), "fieldname": "description", "fieldtype": "Data", "width": 200},
|
||||
{
|
||||
"label": _("Stock UOM"),
|
||||
"label": _("UOM"),
|
||||
"fieldname": "uom",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
{
|
||||
"label": _("Stock UOM"),
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Data",
|
||||
"width": 100,
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
columns.extend(
|
||||
[
|
||||
{
|
||||
"label": _("Stock Qty"),
|
||||
"label": _("Qty"),
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 120,
|
||||
"width": 140,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
"label": _("Qty in Stock UOM"),
|
||||
"fieldname": "stock_qty",
|
||||
"fieldtype": "Float",
|
||||
"width": 140,
|
||||
"convertible": "qty",
|
||||
},
|
||||
{
|
||||
|
@ -169,7 +169,6 @@ class AccountsController(TransactionBase):
|
||||
self.validate_value("base_grand_total", ">=", 0)
|
||||
|
||||
validate_return(self)
|
||||
self.set_total_in_words()
|
||||
|
||||
self.validate_all_documents_schedule()
|
||||
|
||||
@ -208,9 +207,42 @@ class AccountsController(TransactionBase):
|
||||
if self.doctype != "Material Request" and not self.ignore_pricing_rule:
|
||||
apply_pricing_rule_on_transaction(self)
|
||||
|
||||
self.set_total_in_words()
|
||||
|
||||
def before_cancel(self):
|
||||
validate_einvoice_fields(self)
|
||||
|
||||
def _remove_references_in_unreconcile(self):
|
||||
upe = frappe.qb.DocType("Unreconcile Payment Entries")
|
||||
rows = (
|
||||
frappe.qb.from_(upe)
|
||||
.select(upe.name, upe.parent)
|
||||
.where((upe.reference_doctype == self.doctype) & (upe.reference_name == self.name))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
if rows:
|
||||
references_map = frappe._dict()
|
||||
for x in rows:
|
||||
references_map.setdefault(x.parent, []).append(x.name)
|
||||
|
||||
for doc, rows in references_map.items():
|
||||
unreconcile_doc = frappe.get_doc("Unreconcile Payments", doc)
|
||||
for row in rows:
|
||||
unreconcile_doc.remove(unreconcile_doc.get("allocations", {"name": row})[0])
|
||||
|
||||
unreconcile_doc.flags.ignore_validate_update_after_submit = True
|
||||
unreconcile_doc.flags.ignore_links = True
|
||||
unreconcile_doc.save(ignore_permissions=True)
|
||||
|
||||
# delete docs upon parent doc deletion
|
||||
unreconcile_docs = frappe.db.get_all("Unreconcile Payments", filters={"voucher_no": self.name})
|
||||
for x in unreconcile_docs:
|
||||
_doc = frappe.get_doc("Unreconcile Payments", x.name)
|
||||
if _doc.docstatus == 1:
|
||||
_doc.cancel()
|
||||
_doc.delete()
|
||||
|
||||
def on_trash(self):
|
||||
# delete references in 'Repost Payment Ledger'
|
||||
rpi = frappe.qb.DocType("Repost Payment Ledger Items")
|
||||
@ -218,6 +250,8 @@ class AccountsController(TransactionBase):
|
||||
(rpi.voucher_type == self.doctype) & (rpi.voucher_no == self.name)
|
||||
).run()
|
||||
|
||||
self._remove_references_in_unreconcile()
|
||||
|
||||
# delete sl and gl entries on deletion of transaction
|
||||
if frappe.db.get_single_value("Accounts Settings", "delete_linked_ledger_entries"):
|
||||
ple = frappe.qb.DocType("Payment Ledger Entry")
|
||||
@ -935,7 +969,7 @@ class AccountsController(TransactionBase):
|
||||
party_type, party, party_account, amount_field, order_doctype, order_list, include_unallocated
|
||||
)
|
||||
|
||||
payment_entries = get_advance_payment_entries(
|
||||
payment_entries = get_advance_payment_entries_for_regional(
|
||||
party_type, party, party_account, order_doctype, order_list, include_unallocated
|
||||
)
|
||||
|
||||
@ -2376,6 +2410,11 @@ def get_advance_journal_entries(
|
||||
return list(journal_entries)
|
||||
|
||||
|
||||
@erpnext.allow_regional
|
||||
def get_advance_payment_entries_for_regional(*args, **kwargs):
|
||||
return get_advance_payment_entries(*args, **kwargs)
|
||||
|
||||
|
||||
def get_advance_payment_entries(
|
||||
party_type,
|
||||
party,
|
||||
|
@ -190,10 +190,13 @@ class BuyingController(SubcontractingController):
|
||||
purchase_doc_field = (
|
||||
"purchase_receipt" if self.doctype == "Purchase Receipt" else "purchase_invoice"
|
||||
)
|
||||
not_cancelled_asset = [
|
||||
d.name
|
||||
for d in frappe.db.get_all("Asset", {purchase_doc_field: self.return_against, "docstatus": 1})
|
||||
]
|
||||
not_cancelled_asset = []
|
||||
if self.return_against:
|
||||
not_cancelled_asset = [
|
||||
d.name
|
||||
for d in frappe.db.get_all("Asset", {purchase_doc_field: self.return_against, "docstatus": 1})
|
||||
]
|
||||
|
||||
if self.is_return and len(not_cancelled_asset):
|
||||
frappe.throw(
|
||||
_(
|
||||
@ -415,6 +418,10 @@ class BuyingController(SubcontractingController):
|
||||
item.bom = None
|
||||
|
||||
def set_qty_as_per_stock_uom(self):
|
||||
allow_to_edit_stock_qty = frappe.db.get_single_value(
|
||||
"Stock Settings", "allow_to_edit_stock_uom_qty_for_purchase"
|
||||
)
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.meta.get_field("stock_qty"):
|
||||
# Check if item code is present
|
||||
@ -429,6 +436,11 @@ class BuyingController(SubcontractingController):
|
||||
d.conversion_factor, d.precision("conversion_factor")
|
||||
)
|
||||
|
||||
if allow_to_edit_stock_qty:
|
||||
d.stock_qty = flt(d.stock_qty, d.precision("stock_qty"))
|
||||
if d.get("received_stock_qty"):
|
||||
d.received_stock_qty = flt(d.received_stock_qty, d.precision("received_stock_qty"))
|
||||
|
||||
def validate_purchase_return(self):
|
||||
for d in self.get("items"):
|
||||
if self.is_return and flt(d.rejected_qty) != 0:
|
||||
|
@ -194,11 +194,17 @@ class SellingController(StockController):
|
||||
frappe.throw(_("Maximum discount for Item {0} is {1}%").format(d.item_code, discount))
|
||||
|
||||
def set_qty_as_per_stock_uom(self):
|
||||
allow_to_edit_stock_qty = frappe.db.get_single_value(
|
||||
"Stock Settings", "allow_to_edit_stock_uom_qty_for_sales"
|
||||
)
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.meta.get_field("stock_qty"):
|
||||
if not d.conversion_factor:
|
||||
frappe.throw(_("Row {0}: Conversion Factor is mandatory").format(d.idx))
|
||||
d.stock_qty = flt(d.qty) * flt(d.conversion_factor)
|
||||
if allow_to_edit_stock_qty:
|
||||
d.stock_qty = flt(d.stock_qty, d.precision("stock_qty"))
|
||||
|
||||
def validate_selling_price(self):
|
||||
def throw_message(idx, item_name, rate, ref_rate_field):
|
||||
|
@ -1,74 +0,0 @@
|
||||
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('LinkedIn Settings', {
|
||||
onload: function(frm) {
|
||||
if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret) {
|
||||
frappe.confirm(
|
||||
__('Session not valid. Do you want to login?'),
|
||||
function(){
|
||||
frm.trigger("login");
|
||||
},
|
||||
function(){
|
||||
window.close();
|
||||
}
|
||||
);
|
||||
}
|
||||
frm.dashboard.set_headline(__("For more information, {0}.", [`<a target='_blank' href='https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings'>${__('click here')}</a>`]));
|
||||
},
|
||||
refresh: function(frm) {
|
||||
if (frm.doc.session_status=="Expired"){
|
||||
let msg = __("Session not active. Save document to login.");
|
||||
frm.dashboard.set_headline_alert(
|
||||
`<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<span class="indicator whitespace-nowrap red"><span class="hidden-xs">${msg}</span></span>
|
||||
</div>
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
|
||||
if (frm.doc.session_status=="Active"){
|
||||
let d = new Date(frm.doc.modified);
|
||||
d.setDate(d.getDate()+60);
|
||||
let dn = new Date();
|
||||
let days = d.getTime() - dn.getTime();
|
||||
days = Math.floor(days/(1000 * 3600 * 24));
|
||||
let msg,color;
|
||||
|
||||
if (days>0){
|
||||
msg = __("Your session will be expire in {0} days.", [days]);
|
||||
color = "green";
|
||||
}
|
||||
else {
|
||||
msg = __("Session is expired. Save doc to login.");
|
||||
color = "red";
|
||||
}
|
||||
|
||||
frm.dashboard.set_headline_alert(
|
||||
`<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<span class="indicator whitespace-nowrap ${color}"><span class="hidden-xs">${msg}</span></span>
|
||||
</div>
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
},
|
||||
login: function(frm) {
|
||||
if (frm.doc.consumer_key && frm.doc.consumer_secret){
|
||||
frappe.dom.freeze();
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_authorization_url",
|
||||
callback : function(r) {
|
||||
window.location.href = r.message;
|
||||
}
|
||||
}).fail(function() {
|
||||
frappe.dom.unfreeze();
|
||||
});
|
||||
}
|
||||
},
|
||||
after_save: function(frm) {
|
||||
frm.trigger("login");
|
||||
}
|
||||
});
|
@ -1,112 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2020-01-30 13:36:39.492931",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/linkedin-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account_name",
|
||||
"column_break_2",
|
||||
"company_id",
|
||||
"oauth_details",
|
||||
"consumer_key",
|
||||
"column_break_5",
|
||||
"consumer_secret",
|
||||
"user_details_section",
|
||||
"access_token",
|
||||
"person_urn",
|
||||
"session_status"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "account_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Account Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "oauth_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "OAuth Credentials"
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Consumer Key",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_secret",
|
||||
"fieldtype": "Password",
|
||||
"in_list_view": 1,
|
||||
"label": "Consumer Secret",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Access Token",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "person_urn",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Person URN",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_5",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "user_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "User Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "session_status",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"label": "Session Status",
|
||||
"options": "Expired\nActive",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "company_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Company ID",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2021-02-18 15:19:21.920725",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "LinkedIn Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
@ -1,208 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# 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
|
||||
|
||||
|
||||
class LinkedInSettings(Document):
|
||||
@frappe.whitelist()
|
||||
def get_authorization_url(self):
|
||||
params = urlencode(
|
||||
{
|
||||
"response_type": "code",
|
||||
"client_id": self.consumer_key,
|
||||
"redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format(
|
||||
frappe.utils.get_url()
|
||||
),
|
||||
"scope": "r_emailaddress w_organization_social r_basicprofile r_liteprofile r_organization_social rw_organization_admin w_member_social",
|
||||
}
|
||||
)
|
||||
|
||||
url = "https://www.linkedin.com/oauth/v2/authorization?{}".format(params)
|
||||
|
||||
return url
|
||||
|
||||
def get_access_token(self, code):
|
||||
url = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||
body = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": self.consumer_key,
|
||||
"client_secret": self.get_password(fieldname="consumer_secret"),
|
||||
"redirect_uri": "{0}/api/method/erpnext.crm.doctype.linkedin_settings.linkedin_settings.callback?".format(
|
||||
frappe.utils.get_url()
|
||||
),
|
||||
}
|
||||
headers = {"Content-Type": "application/x-www-form-urlencoded"}
|
||||
|
||||
response = self.http_post(url=url, data=body, headers=headers)
|
||||
response = frappe.parse_json(response.content.decode())
|
||||
self.db_set("access_token", response["access_token"])
|
||||
|
||||
def get_member_profile(self):
|
||||
response = requests.get(url="https://api.linkedin.com/v2/me", headers=self.get_headers())
|
||||
response = frappe.parse_json(response.content.decode())
|
||||
|
||||
frappe.db.set_value(
|
||||
self.doctype,
|
||||
self.name,
|
||||
{
|
||||
"person_urn": response["id"],
|
||||
"account_name": response["vanityName"],
|
||||
"session_status": "Active",
|
||||
},
|
||||
)
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings")
|
||||
|
||||
def post(self, text, title, media=None):
|
||||
if not media:
|
||||
return self.post_text(text, title)
|
||||
else:
|
||||
media_id = self.upload_image(media)
|
||||
|
||||
if media_id:
|
||||
return self.post_text(text, title, media_id=media_id)
|
||||
else:
|
||||
self.log_error("LinkedIn: Failed to upload media")
|
||||
|
||||
def upload_image(self, media):
|
||||
media = get_file_path(media)
|
||||
register_url = "https://api.linkedin.com/v2/assets?action=registerUpload"
|
||||
body = {
|
||||
"registerUploadRequest": {
|
||||
"recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
|
||||
"owner": "urn:li:organization:{0}".format(self.company_id),
|
||||
"serviceRelationships": [
|
||||
{"relationshipType": "OWNER", "identifier": "urn:li:userGeneratedContent"}
|
||||
],
|
||||
}
|
||||
}
|
||||
headers = self.get_headers()
|
||||
response = self.http_post(url=register_url, body=body, headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
response = response.json()
|
||||
asset = response["value"]["asset"]
|
||||
upload_url = response["value"]["uploadMechanism"][
|
||||
"com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
|
||||
]["uploadUrl"]
|
||||
headers["Content-Type"] = "image/jpeg"
|
||||
response = self.http_post(upload_url, headers=headers, data=open(media, "rb"))
|
||||
if response.status_code < 200 and response.status_code > 299:
|
||||
frappe.throw(
|
||||
_("Error While Uploading Image"),
|
||||
title="{0} {1}".format(response.status_code, response.reason),
|
||||
)
|
||||
return None
|
||||
return asset
|
||||
|
||||
return None
|
||||
|
||||
def post_text(self, text, title, media_id=None):
|
||||
url = "https://api.linkedin.com/v2/shares"
|
||||
headers = self.get_headers()
|
||||
headers["X-Restli-Protocol-Version"] = "2.0.0"
|
||||
headers["Content-Type"] = "application/json; charset=UTF-8"
|
||||
|
||||
body = {
|
||||
"distribution": {"linkedInDistributionTarget": {}},
|
||||
"owner": "urn:li:organization:{0}".format(self.company_id),
|
||||
"subject": title,
|
||||
"text": {"text": text},
|
||||
}
|
||||
|
||||
reference_url = self.get_reference_url(text)
|
||||
if reference_url:
|
||||
body["content"] = {"contentEntities": [{"entityLocation": reference_url}]}
|
||||
|
||||
if media_id:
|
||||
body["content"] = {"contentEntities": [{"entity": media_id}], "shareMediaCategory": "IMAGE"}
|
||||
|
||||
response = self.http_post(url=url, headers=headers, body=body)
|
||||
return response
|
||||
|
||||
def http_post(self, url, headers=None, body=None, data=None):
|
||||
try:
|
||||
response = requests.post(url=url, json=body, data=data, headers=headers)
|
||||
if response.status_code not in [201, 200]:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
self.api_error(response)
|
||||
|
||||
return response
|
||||
|
||||
def get_headers(self):
|
||||
return {"Authorization": "Bearer {}".format(self.access_token)}
|
||||
|
||||
def get_reference_url(self, text):
|
||||
import re
|
||||
|
||||
regex_url = r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
|
||||
urls = re.findall(regex_url, text)
|
||||
if urls:
|
||||
return urls[0]
|
||||
|
||||
def delete_post(self, post_id):
|
||||
try:
|
||||
response = requests.delete(
|
||||
url="https://api.linkedin.com/v2/shares/urn:li:share:{0}".format(post_id),
|
||||
headers=self.get_headers(),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise
|
||||
except Exception:
|
||||
self.api_error(response)
|
||||
|
||||
def get_post(self, post_id):
|
||||
url = "https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=urn:li:organization:{0}&shares[0]=urn:li:share:{1}".format(
|
||||
self.company_id, post_id
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.get(url=url, headers=self.get_headers())
|
||||
if response.status_code != 200:
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
self.api_error(response)
|
||||
|
||||
response = frappe.parse_json(response.content.decode())
|
||||
if len(response.elements):
|
||||
return response.elements[0]
|
||||
|
||||
return None
|
||||
|
||||
def api_error(self, response):
|
||||
content = frappe.parse_json(response.content.decode())
|
||||
|
||||
if response.status_code == 401:
|
||||
self.db_set("session_status", "Expired")
|
||||
frappe.db.commit()
|
||||
frappe.throw(content["message"], title=_("LinkedIn Error - Unauthorized"))
|
||||
elif response.status_code == 403:
|
||||
frappe.msgprint(_("You didn't have permission to access this API"))
|
||||
frappe.throw(content["message"], title=_("LinkedIn Error - Access Denied"))
|
||||
else:
|
||||
frappe.throw(response.reason, title=response.status_code)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def callback(code=None, error=None, error_description=None):
|
||||
if not error:
|
||||
linkedin_settings = frappe.get_doc("LinkedIn Settings")
|
||||
linkedin_settings.get_access_token(code)
|
||||
linkedin_settings.get_member_profile()
|
||||
frappe.db.commit()
|
||||
else:
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = get_url_to_form("LinkedIn Settings", "LinkedIn Settings")
|
@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
|
||||
class TestLinkedInSettings(unittest.TestCase):
|
||||
pass
|
@ -1,139 +0,0 @@
|
||||
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
frappe.ui.form.on('Social Media Post', {
|
||||
validate: function(frm) {
|
||||
if (frm.doc.twitter === 0 && frm.doc.linkedin === 0) {
|
||||
frappe.throw(__("Select atleast one Social Media Platform to Share on."));
|
||||
}
|
||||
if (frm.doc.scheduled_time) {
|
||||
let scheduled_time = new Date(frm.doc.scheduled_time);
|
||||
let date_time = new Date();
|
||||
if (scheduled_time.getTime() < date_time.getTime()) {
|
||||
frappe.throw(__("Scheduled Time must be a future time."));
|
||||
}
|
||||
}
|
||||
frm.trigger('validate_tweet_length');
|
||||
},
|
||||
|
||||
text: function(frm) {
|
||||
if (frm.doc.text) {
|
||||
frm.set_df_property('text', 'description', `${frm.doc.text.length}/280`);
|
||||
frm.refresh_field('text');
|
||||
frm.trigger('validate_tweet_length');
|
||||
}
|
||||
},
|
||||
|
||||
validate_tweet_length: function(frm) {
|
||||
if (frm.doc.text && frm.doc.text.length > 280) {
|
||||
frappe.throw(__("Tweet length Must be less than 280."));
|
||||
}
|
||||
},
|
||||
|
||||
onload: function(frm) {
|
||||
frm.trigger('make_dashboard');
|
||||
},
|
||||
|
||||
make_dashboard: function(frm) {
|
||||
if (frm.doc.post_status == "Posted") {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: 'get_post',
|
||||
freeze: true,
|
||||
callback: (r) => {
|
||||
if (!r.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
let datasets = [], colors = [];
|
||||
if (r.message && r.message.twitter) {
|
||||
colors.push('#1DA1F2');
|
||||
datasets.push({
|
||||
name: 'Twitter',
|
||||
values: [r.message.twitter.favorite_count, r.message.twitter.retweet_count]
|
||||
});
|
||||
}
|
||||
if (r.message && r.message.linkedin) {
|
||||
colors.push('#0077b5');
|
||||
datasets.push({
|
||||
name: 'LinkedIn',
|
||||
values: [r.message.linkedin.totalShareStatistics.likeCount, r.message.linkedin.totalShareStatistics.shareCount]
|
||||
});
|
||||
}
|
||||
|
||||
if (datasets.length) {
|
||||
frm.dashboard.render_graph({
|
||||
data: {
|
||||
labels: ['Likes', 'Retweets/Shares'],
|
||||
datasets: datasets
|
||||
},
|
||||
|
||||
title: __("Post Metrics"),
|
||||
type: 'bar',
|
||||
height: 300,
|
||||
colors: colors
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
refresh: function(frm) {
|
||||
frm.trigger('text');
|
||||
|
||||
if (frm.doc.docstatus === 1) {
|
||||
if (!['Posted', 'Deleted'].includes(frm.doc.post_status)) {
|
||||
frm.trigger('add_post_btn');
|
||||
}
|
||||
if (frm.doc.post_status !='Deleted') {
|
||||
frm.add_custom_button(__('Delete Post'), function() {
|
||||
frappe.confirm(__('Are you sure want to delete the Post from Social Media platforms?'),
|
||||
function() {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: 'delete_post',
|
||||
freeze: true,
|
||||
callback: () => {
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.post_status !='Deleted') {
|
||||
let html='';
|
||||
if (frm.doc.twitter) {
|
||||
let color = frm.doc.twitter_post_id ? "green" : "red";
|
||||
let status = frm.doc.twitter_post_id ? "Posted" : "Not Posted";
|
||||
html += `<div class="col-xs-6">
|
||||
<span class="indicator whitespace-nowrap ${color}"><span>Twitter : ${status} </span></span>
|
||||
</div>` ;
|
||||
}
|
||||
if (frm.doc.linkedin) {
|
||||
let color = frm.doc.linkedin_post_id ? "green" : "red";
|
||||
let status = frm.doc.linkedin_post_id ? "Posted" : "Not Posted";
|
||||
html += `<div class="col-xs-6">
|
||||
<span class="indicator whitespace-nowrap ${color}"><span>LinkedIn : ${status} </span></span>
|
||||
</div>` ;
|
||||
}
|
||||
html = `<div class="row">${html}</div>`;
|
||||
frm.dashboard.set_headline_alert(html);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
add_post_btn: function(frm) {
|
||||
frm.add_custom_button(__('Post Now'), function() {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: 'post',
|
||||
freeze: true,
|
||||
callback: function() {
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
@ -1,208 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format: CRM-SMP-{YYYY}-{MM}-{DD}-{###}",
|
||||
"creation": "2020-01-30 11:53:13.872864",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/social-media-post",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"campaign_name",
|
||||
"scheduled_time",
|
||||
"post_status",
|
||||
"column_break_6",
|
||||
"twitter",
|
||||
"linkedin",
|
||||
"twitter_post_id",
|
||||
"linkedin_post_id",
|
||||
"content",
|
||||
"text",
|
||||
"column_break_14",
|
||||
"tweet_preview",
|
||||
"linkedin_section",
|
||||
"linkedin_post",
|
||||
"column_break_15",
|
||||
"attachments_section",
|
||||
"image",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "text",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Tweet",
|
||||
"mandatory_depends_on": "eval:doc.twitter ==1"
|
||||
},
|
||||
{
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Attach Image",
|
||||
"label": "Image"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "twitter",
|
||||
"fieldtype": "Check",
|
||||
"label": "Twitter"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "linkedin",
|
||||
"fieldtype": "Check",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Social Media Post",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.twitter ==1",
|
||||
"fieldname": "content",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Twitter"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "post_status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Post Status",
|
||||
"no_copy": 1,
|
||||
"options": "\nScheduled\nPosted\nCancelled\nDeleted\nError",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "twitter_post_id",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Twitter Post Id",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "linkedin_post_id",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "LinkedIn Post Id",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "campaign_name",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Campaign",
|
||||
"options": "Campaign"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_6",
|
||||
"fieldtype": "Column Break",
|
||||
"label": "Share On"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_14",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "tweet_preview",
|
||||
"fieldtype": "HTML"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"depends_on": "eval:doc.linkedin==1",
|
||||
"fieldname": "linkedin_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "LinkedIn"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "attachments_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Attachments"
|
||||
},
|
||||
{
|
||||
"fieldname": "linkedin_post",
|
||||
"fieldtype": "Text",
|
||||
"label": "Post",
|
||||
"mandatory_depends_on": "eval:doc.linkedin ==1"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_15",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "scheduled_time",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Scheduled Time",
|
||||
"read_only_depends_on": "eval:doc.post_status == \"Posted\""
|
||||
},
|
||||
{
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"label": "Title",
|
||||
"reqd": 1
|
||||
}
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-04-14 14:24:59.821223",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Social Media Post",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales User",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"title_field": "title",
|
||||
"track_changes": 1
|
||||
}
|
@ -1,89 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import datetime
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class SocialMediaPost(Document):
|
||||
def validate(self):
|
||||
if not self.twitter and not self.linkedin:
|
||||
frappe.throw(_("Select atleast one Social Media Platform to Share on."))
|
||||
|
||||
if self.scheduled_time:
|
||||
current_time = frappe.utils.now_datetime()
|
||||
scheduled_time = frappe.utils.get_datetime(self.scheduled_time)
|
||||
if scheduled_time < current_time:
|
||||
frappe.throw(_("Scheduled Time must be a future time."))
|
||||
|
||||
if self.text and len(self.text) > 280:
|
||||
frappe.throw(_("Tweet length must be less than 280."))
|
||||
|
||||
def submit(self):
|
||||
if self.scheduled_time:
|
||||
self.post_status = "Scheduled"
|
||||
super(SocialMediaPost, self).submit()
|
||||
|
||||
def on_cancel(self):
|
||||
self.db_set("post_status", "Cancelled")
|
||||
|
||||
@frappe.whitelist()
|
||||
def delete_post(self):
|
||||
if self.twitter and self.twitter_post_id:
|
||||
twitter = frappe.get_doc("Twitter Settings")
|
||||
twitter.delete_tweet(self.twitter_post_id)
|
||||
|
||||
if self.linkedin and self.linkedin_post_id:
|
||||
linkedin = frappe.get_doc("LinkedIn Settings")
|
||||
linkedin.delete_post(self.linkedin_post_id)
|
||||
|
||||
self.db_set("post_status", "Deleted")
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_post(self):
|
||||
response = {}
|
||||
if self.linkedin and self.linkedin_post_id:
|
||||
linkedin = frappe.get_doc("LinkedIn Settings")
|
||||
response["linkedin"] = linkedin.get_post(self.linkedin_post_id)
|
||||
if self.twitter and self.twitter_post_id:
|
||||
twitter = frappe.get_doc("Twitter Settings")
|
||||
response["twitter"] = twitter.get_tweet(self.twitter_post_id)
|
||||
|
||||
return response
|
||||
|
||||
@frappe.whitelist()
|
||||
def post(self):
|
||||
try:
|
||||
if self.twitter and not self.twitter_post_id:
|
||||
twitter = frappe.get_doc("Twitter Settings")
|
||||
twitter_post = twitter.post(self.text, self.image)
|
||||
self.db_set("twitter_post_id", twitter_post.id)
|
||||
if self.linkedin and not self.linkedin_post_id:
|
||||
linkedin = frappe.get_doc("LinkedIn Settings")
|
||||
linkedin_post = linkedin.post(self.linkedin_post, self.title, self.image)
|
||||
self.db_set("linkedin_post_id", linkedin_post.headers["X-RestLi-Id"])
|
||||
self.db_set("post_status", "Posted")
|
||||
|
||||
except Exception:
|
||||
self.db_set("post_status", "Error")
|
||||
self.log_error("Social posting failed")
|
||||
|
||||
|
||||
def process_scheduled_social_media_posts():
|
||||
posts = frappe.get_all(
|
||||
"Social Media Post",
|
||||
filters={"post_status": "Scheduled", "docstatus": 1},
|
||||
fields=["name", "scheduled_time"],
|
||||
)
|
||||
start = frappe.utils.now_datetime()
|
||||
end = start + datetime.timedelta(minutes=10)
|
||||
for post in posts:
|
||||
if post.scheduled_time:
|
||||
post_time = frappe.utils.get_datetime(post.scheduled_time)
|
||||
if post_time > start and post_time <= end:
|
||||
sm_post = frappe.get_doc("Social Media Post", post.name)
|
||||
sm_post.post()
|
@ -1,11 +0,0 @@
|
||||
frappe.listview_settings['Social Media Post'] = {
|
||||
add_fields: ["status", "post_status"],
|
||||
get_indicator: function(doc) {
|
||||
return [__(doc.post_status), {
|
||||
"Scheduled": "orange",
|
||||
"Posted": "green",
|
||||
"Error": "red",
|
||||
"Deleted": "red"
|
||||
}[doc.post_status]];
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
|
||||
class TestSocialMediaPost(unittest.TestCase):
|
||||
pass
|
@ -1,9 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
|
||||
class TestTwitterSettings(unittest.TestCase):
|
||||
pass
|
@ -1,59 +0,0 @@
|
||||
// Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Twitter Settings', {
|
||||
onload: function(frm) {
|
||||
if (frm.doc.session_status == 'Expired' && frm.doc.consumer_key && frm.doc.consumer_secret){
|
||||
frappe.confirm(
|
||||
__('Session not valid, Do you want to login?'),
|
||||
function(){
|
||||
frm.trigger("login");
|
||||
},
|
||||
function(){
|
||||
window.close();
|
||||
}
|
||||
);
|
||||
}
|
||||
frm.dashboard.set_headline(__("For more information, {0}.", [`<a target='_blank' href='https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings'>${__('click here')}</a>`]));
|
||||
},
|
||||
refresh: function(frm) {
|
||||
let msg, color, flag=false;
|
||||
if (frm.doc.session_status == "Active") {
|
||||
msg = __("Session Active");
|
||||
color = 'green';
|
||||
flag = true;
|
||||
}
|
||||
else if(frm.doc.consumer_key && frm.doc.consumer_secret) {
|
||||
msg = __("Session Not Active. Save doc to login.");
|
||||
color = 'red';
|
||||
flag = true;
|
||||
}
|
||||
|
||||
if (flag) {
|
||||
frm.dashboard.set_headline_alert(
|
||||
`<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<span class="indicator whitespace-nowrap ${color}"><span class="hidden-xs">${msg}</span></span>
|
||||
</div>
|
||||
</div>`
|
||||
);
|
||||
}
|
||||
},
|
||||
login: function(frm) {
|
||||
if (frm.doc.consumer_key && frm.doc.consumer_secret){
|
||||
frappe.dom.freeze();
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_authorize_url",
|
||||
callback : function(r) {
|
||||
window.location.href = r.message;
|
||||
}
|
||||
}).fail(function() {
|
||||
frappe.dom.unfreeze();
|
||||
});
|
||||
}
|
||||
},
|
||||
after_save: function(frm) {
|
||||
frm.trigger("login");
|
||||
}
|
||||
});
|
@ -1,102 +0,0 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2020-01-30 10:29:08.562108",
|
||||
"doctype": "DocType",
|
||||
"documentation": "https://docs.erpnext.com/docs/user/manual/en/CRM/twitter-settings",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"account_name",
|
||||
"profile_pic",
|
||||
"oauth_details",
|
||||
"consumer_key",
|
||||
"column_break_5",
|
||||
"consumer_secret",
|
||||
"access_token",
|
||||
"access_token_secret",
|
||||
"session_status"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "account_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Account Name",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "oauth_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "OAuth Credentials"
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "API Key",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "consumer_secret",
|
||||
"fieldtype": "Password",
|
||||
"in_list_view": 1,
|
||||
"label": "API Secret Key",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_5",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "profile_pic",
|
||||
"fieldtype": "Attach Image",
|
||||
"hidden": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "session_status",
|
||||
"fieldtype": "Select",
|
||||
"hidden": 1,
|
||||
"label": "Session Status",
|
||||
"options": "Expired\nActive",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Access Token",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "access_token_secret",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Access Token Secret",
|
||||
"read_only": 1
|
||||
}
|
||||
],
|
||||
"image_field": "profile_pic",
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2021-02-18 15:18:07.900031",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Twitter Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
@ -1,141 +0,0 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
import tweepy
|
||||
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
|
||||
|
||||
|
||||
class TwitterSettings(Document):
|
||||
@frappe.whitelist()
|
||||
def get_authorize_url(self):
|
||||
callback_url = (
|
||||
"{0}/api/method/erpnext.crm.doctype.twitter_settings.twitter_settings.callback?".format(
|
||||
frappe.utils.get_url()
|
||||
)
|
||||
)
|
||||
auth = tweepy.OAuth1UserHandler(
|
||||
self.consumer_key, self.get_password(fieldname="consumer_secret"), callback_url
|
||||
)
|
||||
try:
|
||||
redirect_url = auth.get_authorization_url()
|
||||
return redirect_url
|
||||
except (tweepy.TweepyException, tweepy.HTTPException) as e:
|
||||
frappe.msgprint(_("Error! Failed to get request token."))
|
||||
frappe.throw(
|
||||
_("Invalid {0} or {1}").format(frappe.bold("Consumer Key"), frappe.bold("Consumer Secret Key"))
|
||||
)
|
||||
|
||||
def get_access_token(self, oauth_token, oauth_verifier):
|
||||
auth = tweepy.OAuth1UserHandler(
|
||||
self.consumer_key, self.get_password(fieldname="consumer_secret")
|
||||
)
|
||||
auth.request_token = {"oauth_token": oauth_token, "oauth_token_secret": oauth_verifier}
|
||||
|
||||
try:
|
||||
auth.get_access_token(oauth_verifier)
|
||||
self.access_token = auth.access_token
|
||||
self.access_token_secret = auth.access_token_secret
|
||||
api = self.get_api()
|
||||
user = api.me()
|
||||
profile_pic = (user._json["profile_image_url"]).replace("_normal", "")
|
||||
|
||||
frappe.db.set_value(
|
||||
self.doctype,
|
||||
self.name,
|
||||
{
|
||||
"access_token": auth.access_token,
|
||||
"access_token_secret": auth.access_token_secret,
|
||||
"account_name": user._json["screen_name"],
|
||||
"profile_pic": profile_pic,
|
||||
"session_status": "Active",
|
||||
},
|
||||
)
|
||||
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings")
|
||||
except (tweepy.TweepyException, tweepy.HTTPException) as e:
|
||||
frappe.msgprint(_("Error! Failed to get access token."))
|
||||
frappe.throw(_("Invalid Consumer Key or Consumer Secret Key"))
|
||||
|
||||
def get_api(self):
|
||||
# authentication of consumer key and secret
|
||||
auth = tweepy.OAuth1UserHandler(
|
||||
self.consumer_key, self.get_password(fieldname="consumer_secret")
|
||||
)
|
||||
# authentication of access token and secret
|
||||
auth.set_access_token(self.access_token, self.access_token_secret)
|
||||
|
||||
return tweepy.API(auth)
|
||||
|
||||
def post(self, text, media=None):
|
||||
if not media:
|
||||
return self.send_tweet(text)
|
||||
|
||||
if media:
|
||||
media_id = self.upload_image(media)
|
||||
return self.send_tweet(text, media_id)
|
||||
|
||||
def upload_image(self, media):
|
||||
media = get_file_path(media)
|
||||
api = self.get_api()
|
||||
media = api.media_upload(media)
|
||||
|
||||
return media.media_id
|
||||
|
||||
def send_tweet(self, text, media_id=None):
|
||||
api = self.get_api()
|
||||
try:
|
||||
if media_id:
|
||||
response = api.update_status(status=text, media_ids=[media_id])
|
||||
else:
|
||||
response = api.update_status(status=text)
|
||||
|
||||
return response
|
||||
|
||||
except (tweepy.TweepyException, tweepy.HTTPException) as e:
|
||||
self.api_error(e)
|
||||
|
||||
def delete_tweet(self, tweet_id):
|
||||
api = self.get_api()
|
||||
try:
|
||||
api.destroy_status(tweet_id)
|
||||
except (tweepy.TweepyException, tweepy.HTTPException) as e:
|
||||
self.api_error(e)
|
||||
|
||||
def get_tweet(self, tweet_id):
|
||||
api = self.get_api()
|
||||
try:
|
||||
response = api.get_status(tweet_id, trim_user=True, include_entities=True)
|
||||
except (tweepy.TweepyException, tweepy.HTTPException) as e:
|
||||
self.api_error(e)
|
||||
|
||||
return response._json
|
||||
|
||||
def api_error(self, e):
|
||||
content = json.loads(e.response.content)
|
||||
content = content["errors"][0]
|
||||
if e.response.status_code == 401:
|
||||
self.db_set("session_status", "Expired")
|
||||
frappe.db.commit()
|
||||
frappe.throw(
|
||||
content["message"],
|
||||
title=_("Twitter Error {0} : {1}").format(e.response.status_code, e.response.reason),
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def callback(oauth_token=None, oauth_verifier=None):
|
||||
if oauth_token and oauth_verifier:
|
||||
twitter_settings = frappe.get_single("Twitter Settings")
|
||||
twitter_settings.get_access_token(oauth_token, oauth_verifier)
|
||||
frappe.db.commit()
|
||||
else:
|
||||
frappe.local.response["type"] = "redirect"
|
||||
frappe.local.response["location"] = get_url_to_form("Twitter Settings", "Twitter Settings")
|
@ -122,131 +122,6 @@
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 0,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Social Media Post",
|
||||
"link_count": 0,
|
||||
"link_to": "Social Media Post",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Center",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Center",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Log",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Log",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Group",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Settings",
|
||||
"link_count": 0,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "CRM Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "CRM Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Twitter Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "Twitter Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "LinkedIn Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "LinkedIn Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
@ -450,9 +325,101 @@
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Settings",
|
||||
"link_count": 2,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "CRM Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "CRM Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Settings",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Settings",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 5,
|
||||
"onboard": 0,
|
||||
"type": "Card Break"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Campaign",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Campaign",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Center",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Center",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "SMS Log",
|
||||
"link_count": 0,
|
||||
"link_to": "SMS Log",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
},
|
||||
{
|
||||
"dependencies": "",
|
||||
"hidden": 0,
|
||||
"is_query_report": 0,
|
||||
"label": "Email Group",
|
||||
"link_count": 0,
|
||||
"link_to": "Email Group",
|
||||
"link_type": "DocType",
|
||||
"onboard": 0,
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2023-05-26 16:49:04.298122",
|
||||
"modified": "2023-09-14 12:11:03.968048",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM",
|
||||
@ -463,7 +430,7 @@
|
||||
"quick_lists": [],
|
||||
"restrict_to_domain": "",
|
||||
"roles": [],
|
||||
"sequence_id": 10.0,
|
||||
"sequence_id": 17.0,
|
||||
"shortcuts": [
|
||||
{
|
||||
"color": "Blue",
|
||||
|
@ -312,7 +312,7 @@ class TestWebsiteItem(unittest.TestCase):
|
||||
# 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)
|
||||
self.assertEqual(data.product_info["stock_qty"], 0)
|
||||
|
||||
# disable show stock availability
|
||||
setup_e_commerce_settings({"show_stock_availability": 0})
|
||||
@ -355,7 +355,7 @@ class TestWebsiteItem(unittest.TestCase):
|
||||
# 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)
|
||||
self.assertEqual(data.product_info["stock_qty"], 2)
|
||||
|
||||
# unset warehouse
|
||||
frappe.db.set_value("Website Item", {"item_code": item_code}, "website_warehouse", "")
|
||||
@ -364,7 +364,7 @@ class TestWebsiteItem(unittest.TestCase):
|
||||
# (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"]))
|
||||
self.assertFalse(data.product_info["stock_qty"])
|
||||
|
||||
# disable show stock availability
|
||||
setup_e_commerce_settings({"show_stock_availability": 0})
|
||||
|
@ -5,12 +5,6 @@ frappe.ui.form.on('Website Item', {
|
||||
onload: (frm) => {
|
||||
// should never check Private
|
||||
frm.fields_dict["website_image"].df.is_private = 0;
|
||||
|
||||
frm.set_query("website_warehouse", () => {
|
||||
return {
|
||||
filters: {"is_group": 0}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
refresh: (frm) => {
|
||||
|
@ -135,7 +135,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Show Stock availability based on this warehouse.",
|
||||
"description": "Show Stock availability based on this warehouse. If the parent warehouse is selected, then the system will display the consolidated available quantity of all child warehouses.",
|
||||
"fieldname": "website_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
@ -348,7 +348,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2022-09-30 04:01:52.090732",
|
||||
"modified": "2023-09-12 14:19:22.822689",
|
||||
"modified_by": "Administrator",
|
||||
"module": "E-commerce",
|
||||
"name": "Website Item",
|
||||
|
@ -259,6 +259,10 @@ class ProductQuery:
|
||||
)
|
||||
|
||||
def get_stock_availability(self, item):
|
||||
from erpnext.templates.pages.wishlist import (
|
||||
get_stock_availability as get_stock_availability_from_template,
|
||||
)
|
||||
|
||||
"""Modify item object and add stock details."""
|
||||
item.in_stock = False
|
||||
warehouse = item.get("website_warehouse")
|
||||
@ -274,11 +278,7 @@ class ProductQuery:
|
||||
else:
|
||||
item.in_stock = True
|
||||
elif warehouse:
|
||||
# stock item and has warehouse
|
||||
actual_qty = frappe.db.get_value(
|
||||
"Bin", {"item_code": item.item_code, "warehouse": item.get("website_warehouse")}, "actual_qty"
|
||||
)
|
||||
item.in_stock = bool(flt(actual_qty))
|
||||
item.in_stock = get_stock_availability_from_template(item.item_code, warehouse)
|
||||
|
||||
def get_cart_items(self):
|
||||
customer = get_customer(silent=True)
|
||||
|
@ -111,8 +111,8 @@ def place_order():
|
||||
item_stock = get_web_item_qty_in_stock(item.item_code, "website_warehouse")
|
||||
if not cint(item_stock.in_stock):
|
||||
throw(_("{0} Not in Stock").format(item.item_code))
|
||||
if item.qty > item_stock.stock_qty[0][0]:
|
||||
throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty[0][0], item.item_code))
|
||||
if item.qty > item_stock.stock_qty:
|
||||
throw(_("Only {0} in Stock for item {1}").format(item_stock.stock_qty, item.item_code))
|
||||
|
||||
sales_order.flags.ignore_permissions = True
|
||||
sales_order.insert()
|
||||
@ -150,6 +150,10 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False):
|
||||
empty_card = True
|
||||
|
||||
else:
|
||||
warehouse = frappe.get_cached_value(
|
||||
"Website Item", {"item_code": item_code}, "website_warehouse"
|
||||
)
|
||||
|
||||
quotation_items = quotation.get("items", {"item_code": item_code})
|
||||
if not quotation_items:
|
||||
quotation.append(
|
||||
@ -159,11 +163,13 @@ def update_cart(item_code, qty, additional_notes=None, with_items=False):
|
||||
"item_code": item_code,
|
||||
"qty": qty,
|
||||
"additional_notes": additional_notes,
|
||||
"warehouse": warehouse,
|
||||
},
|
||||
)
|
||||
else:
|
||||
quotation_items[0].qty = qty
|
||||
quotation_items[0].additional_notes = additional_notes
|
||||
quotation_items[0].warehouse = warehouse
|
||||
|
||||
apply_cart_settings(quotation=quotation)
|
||||
|
||||
@ -317,6 +323,10 @@ def decorate_quotation_doc(doc):
|
||||
fields = fields[2:]
|
||||
|
||||
d.update(frappe.db.get_value("Website Item", {"item_code": item_code}, fields, as_dict=True))
|
||||
website_warehouse = frappe.get_cached_value(
|
||||
"Website Item", {"item_code": item_code}, "website_warehouse"
|
||||
)
|
||||
d.warehouse = website_warehouse
|
||||
|
||||
return doc
|
||||
|
||||
|
@ -104,6 +104,8 @@ def get_attributes_and_values(item_code):
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def get_next_attribute_and_values(item_code, selected_attributes):
|
||||
from erpnext.stock.doctype.warehouse.warehouse import get_child_warehouses
|
||||
|
||||
"""Find the count of Items that match the selected attributes.
|
||||
Also, find the attribute values that are not applicable for further searching.
|
||||
If less than equal to 10 items are found, return item_codes of those items.
|
||||
@ -168,7 +170,7 @@ def get_next_attribute_and_values(item_code, selected_attributes):
|
||||
product_info = None
|
||||
|
||||
product_id = ""
|
||||
website_warehouse = ""
|
||||
warehouse = ""
|
||||
if exact_match or filtered_items:
|
||||
if exact_match and len(exact_match) == 1:
|
||||
product_id = exact_match[0]
|
||||
@ -176,16 +178,19 @@ def get_next_attribute_and_values(item_code, selected_attributes):
|
||||
product_id = list(filtered_items)[0]
|
||||
|
||||
if product_id:
|
||||
website_warehouse = frappe.get_cached_value(
|
||||
warehouse = frappe.get_cached_value(
|
||||
"Website Item", {"item_code": product_id}, "website_warehouse"
|
||||
)
|
||||
|
||||
available_qty = 0.0
|
||||
if website_warehouse:
|
||||
available_qty = flt(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": product_id, "warehouse": website_warehouse}, "actual_qty"
|
||||
)
|
||||
if warehouse and frappe.get_cached_value("Warehouse", warehouse, "is_group") == 1:
|
||||
warehouses = get_child_warehouses(warehouse)
|
||||
else:
|
||||
warehouses = [warehouse] if warehouse else []
|
||||
|
||||
for warehouse in warehouses:
|
||||
available_qty += flt(
|
||||
frappe.db.get_value("Bin", {"item_code": product_id, "warehouse": warehouse}, "actual_qty")
|
||||
)
|
||||
|
||||
return {
|
||||
|
@ -1,256 +0,0 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cstr
|
||||
|
||||
|
||||
def verify_request():
|
||||
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
|
||||
sig = base64.b64encode(
|
||||
hmac.new(
|
||||
woocommerce_settings.secret.encode("utf8"), frappe.request.data, hashlib.sha256
|
||||
).digest()
|
||||
)
|
||||
|
||||
if (
|
||||
frappe.request.data
|
||||
and not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode()
|
||||
):
|
||||
frappe.throw(_("Unverified Webhook Data"))
|
||||
frappe.set_user(woocommerce_settings.creation_user)
|
||||
|
||||
|
||||
@frappe.whitelist(allow_guest=True)
|
||||
def order(*args, **kwargs):
|
||||
try:
|
||||
_order(*args, **kwargs)
|
||||
except Exception:
|
||||
error_message = (
|
||||
frappe.get_traceback() + "\n\n Request Data: \n" + json.loads(frappe.request.data).__str__()
|
||||
)
|
||||
frappe.log_error("WooCommerce Error", error_message)
|
||||
raise
|
||||
|
||||
|
||||
def _order(*args, **kwargs):
|
||||
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
|
||||
if frappe.flags.woocomm_test_order_data:
|
||||
order = frappe.flags.woocomm_test_order_data
|
||||
event = "created"
|
||||
# Ignore the test ping issued during WooCommerce webhook configuration
|
||||
# Ref: https://github.com/woocommerce/woocommerce/issues/15642
|
||||
if frappe.request.data.decode("utf-8").startswith("webhook_id="):
|
||||
return "success"
|
||||
elif frappe.request and frappe.request.data:
|
||||
verify_request()
|
||||
try:
|
||||
order = json.loads(frappe.request.data)
|
||||
except ValueError:
|
||||
# woocommerce returns 'webhook_id=value' for the first request which is not JSON
|
||||
order = frappe.request.data
|
||||
event = frappe.get_request_header("X-Wc-Webhook-Event")
|
||||
|
||||
else:
|
||||
return "success"
|
||||
|
||||
if event == "created":
|
||||
sys_lang = frappe.get_single("System Settings").language or "en"
|
||||
raw_billing_data = order.get("billing")
|
||||
raw_shipping_data = order.get("shipping")
|
||||
customer_name = raw_billing_data.get("first_name") + " " + raw_billing_data.get("last_name")
|
||||
link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name)
|
||||
link_items(order.get("line_items"), woocommerce_settings, sys_lang)
|
||||
create_sales_order(order, woocommerce_settings, customer_name, sys_lang)
|
||||
|
||||
|
||||
def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name):
|
||||
customer_woo_com_email = raw_billing_data.get("email")
|
||||
customer_exists = frappe.get_value("Customer", {"woocommerce_email": customer_woo_com_email})
|
||||
if not customer_exists:
|
||||
# Create Customer
|
||||
customer = frappe.new_doc("Customer")
|
||||
else:
|
||||
# Edit Customer
|
||||
customer = frappe.get_doc("Customer", {"woocommerce_email": customer_woo_com_email})
|
||||
old_name = customer.customer_name
|
||||
|
||||
customer.customer_name = customer_name
|
||||
customer.woocommerce_email = customer_woo_com_email
|
||||
customer.flags.ignore_mandatory = True
|
||||
customer.save()
|
||||
|
||||
if customer_exists:
|
||||
# Fixes https://github.com/frappe/erpnext/issues/33708
|
||||
if old_name != customer_name:
|
||||
frappe.rename_doc("Customer", old_name, customer_name)
|
||||
for address_type in (
|
||||
"Billing",
|
||||
"Shipping",
|
||||
):
|
||||
try:
|
||||
address = frappe.get_doc(
|
||||
"Address", {"woocommerce_email": customer_woo_com_email, "address_type": address_type}
|
||||
)
|
||||
rename_address(address, customer)
|
||||
except (
|
||||
frappe.DoesNotExistError,
|
||||
frappe.DuplicateEntryError,
|
||||
frappe.ValidationError,
|
||||
):
|
||||
pass
|
||||
else:
|
||||
create_address(raw_billing_data, customer, "Billing")
|
||||
create_address(raw_shipping_data, customer, "Shipping")
|
||||
create_contact(raw_billing_data, customer)
|
||||
|
||||
|
||||
def create_contact(data, customer):
|
||||
email = data.get("email", None)
|
||||
phone = data.get("phone", None)
|
||||
|
||||
if not email and not phone:
|
||||
return
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.first_name = data.get("first_name")
|
||||
contact.last_name = data.get("last_name")
|
||||
contact.is_primary_contact = 1
|
||||
contact.is_billing_contact = 1
|
||||
|
||||
if phone:
|
||||
contact.add_phone(phone, is_primary_mobile_no=1, is_primary_phone=1)
|
||||
|
||||
if email:
|
||||
contact.add_email(email, is_primary=1)
|
||||
|
||||
contact.append("links", {"link_doctype": "Customer", "link_name": customer.name})
|
||||
|
||||
contact.flags.ignore_mandatory = True
|
||||
contact.save()
|
||||
|
||||
|
||||
def create_address(raw_data, customer, address_type):
|
||||
address = frappe.new_doc("Address")
|
||||
|
||||
address.address_line1 = raw_data.get("address_1", "Not Provided")
|
||||
address.address_line2 = raw_data.get("address_2", "Not Provided")
|
||||
address.city = raw_data.get("city", "Not Provided")
|
||||
address.woocommerce_email = customer.woocommerce_email
|
||||
address.address_type = address_type
|
||||
address.country = frappe.get_value("Country", {"code": raw_data.get("country", "IN").lower()})
|
||||
address.state = raw_data.get("state")
|
||||
address.pincode = raw_data.get("postcode")
|
||||
address.phone = raw_data.get("phone")
|
||||
address.email_id = customer.woocommerce_email
|
||||
address.append("links", {"link_doctype": "Customer", "link_name": customer.name})
|
||||
|
||||
address.flags.ignore_mandatory = True
|
||||
address.save()
|
||||
|
||||
|
||||
def rename_address(address, customer):
|
||||
old_address_title = address.name
|
||||
new_address_title = customer.name + "-" + address.address_type
|
||||
address.address_title = customer.customer_name
|
||||
address.save()
|
||||
|
||||
frappe.rename_doc("Address", old_address_title, new_address_title)
|
||||
|
||||
|
||||
def link_items(items_list, woocommerce_settings, sys_lang):
|
||||
for item_data in items_list:
|
||||
item_woo_com_id = cstr(item_data.get("product_id"))
|
||||
|
||||
if not frappe.db.get_value("Item", {"woocommerce_id": item_woo_com_id}, "name"):
|
||||
# Create Item
|
||||
item = frappe.new_doc("Item")
|
||||
item.item_code = _("woocommerce - {0}", sys_lang).format(item_woo_com_id)
|
||||
item.stock_uom = woocommerce_settings.uom or _("Nos", sys_lang)
|
||||
item.item_group = _("WooCommerce Products", sys_lang)
|
||||
|
||||
item.item_name = item_data.get("name")
|
||||
item.woocommerce_id = item_woo_com_id
|
||||
item.flags.ignore_mandatory = True
|
||||
item.save()
|
||||
|
||||
|
||||
def create_sales_order(order, woocommerce_settings, customer_name, sys_lang):
|
||||
new_sales_order = frappe.new_doc("Sales Order")
|
||||
new_sales_order.customer = customer_name
|
||||
|
||||
new_sales_order.po_no = new_sales_order.woocommerce_id = order.get("id")
|
||||
new_sales_order.naming_series = woocommerce_settings.sales_order_series or "SO-WOO-"
|
||||
|
||||
created_date = order.get("date_created").split("T")
|
||||
new_sales_order.transaction_date = created_date[0]
|
||||
delivery_after = woocommerce_settings.delivery_after_days or 7
|
||||
new_sales_order.delivery_date = frappe.utils.add_days(created_date[0], delivery_after)
|
||||
|
||||
new_sales_order.company = woocommerce_settings.company
|
||||
|
||||
set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang)
|
||||
new_sales_order.flags.ignore_mandatory = True
|
||||
new_sales_order.insert()
|
||||
new_sales_order.submit()
|
||||
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang):
|
||||
company_abbr = frappe.db.get_value("Company", woocommerce_settings.company, "abbr")
|
||||
|
||||
default_warehouse = _("Stores - {0}", sys_lang).format(company_abbr)
|
||||
if not frappe.db.exists("Warehouse", default_warehouse) and not woocommerce_settings.warehouse:
|
||||
frappe.throw(_("Please set Warehouse in Woocommerce Settings"))
|
||||
|
||||
for item in order.get("line_items"):
|
||||
woocomm_item_id = item.get("product_id")
|
||||
found_item = frappe.get_doc("Item", {"woocommerce_id": cstr(woocomm_item_id)})
|
||||
|
||||
ordered_items_tax = item.get("total_tax")
|
||||
|
||||
new_sales_order.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": found_item.name,
|
||||
"item_name": found_item.item_name,
|
||||
"description": found_item.item_name,
|
||||
"delivery_date": new_sales_order.delivery_date,
|
||||
"uom": woocommerce_settings.uom or _("Nos", sys_lang),
|
||||
"qty": item.get("quantity"),
|
||||
"rate": item.get("price"),
|
||||
"warehouse": woocommerce_settings.warehouse or default_warehouse,
|
||||
},
|
||||
)
|
||||
|
||||
add_tax_details(
|
||||
new_sales_order, ordered_items_tax, "Ordered Item tax", woocommerce_settings.tax_account
|
||||
)
|
||||
|
||||
# shipping_details = order.get("shipping_lines") # used for detailed order
|
||||
|
||||
add_tax_details(
|
||||
new_sales_order, order.get("shipping_tax"), "Shipping Tax", woocommerce_settings.f_n_f_account
|
||||
)
|
||||
add_tax_details(
|
||||
new_sales_order,
|
||||
order.get("shipping_total"),
|
||||
"Shipping Total",
|
||||
woocommerce_settings.f_n_f_account,
|
||||
)
|
||||
|
||||
|
||||
def add_tax_details(sales_order, price, desc, tax_account_head):
|
||||
sales_order.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "Actual",
|
||||
"account_head": tax_account_head,
|
||||
"tax_amount": price,
|
||||
"description": desc,
|
||||
},
|
||||
)
|
@ -1,8 +0,0 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestWoocommerceSettings(unittest.TestCase):
|
||||
pass
|
@ -1,56 +0,0 @@
|
||||
// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on('Woocommerce Settings', {
|
||||
refresh (frm) {
|
||||
frm.trigger("add_button_generate_secret");
|
||||
frm.trigger("check_enabled");
|
||||
frm.set_query("tax_account", ()=>{
|
||||
return {
|
||||
"filters": {
|
||||
"company": frappe.defaults.get_default("company"),
|
||||
"is_group": 0
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
enable_sync (frm) {
|
||||
frm.trigger("check_enabled");
|
||||
},
|
||||
|
||||
add_button_generate_secret(frm) {
|
||||
frm.add_custom_button(__('Generate Secret'), () => {
|
||||
frappe.confirm(
|
||||
__("Apps using current key won't be able to access, are you sure?"),
|
||||
() => {
|
||||
frappe.call({
|
||||
type:"POST",
|
||||
method:"erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.generate_secret",
|
||||
}).done(() => {
|
||||
frm.reload_doc();
|
||||
}).fail(() => {
|
||||
frappe.msgprint(__("Could not generate Secret"));
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
check_enabled (frm) {
|
||||
frm.set_df_property("woocommerce_server_url", "reqd", frm.doc.enable_sync);
|
||||
frm.set_df_property("api_consumer_key", "reqd", frm.doc.enable_sync);
|
||||
frm.set_df_property("api_consumer_secret", "reqd", frm.doc.enable_sync);
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Woocommerce Settings", "onload", function () {
|
||||
frappe.call({
|
||||
method: "erpnext.erpnext_integrations.doctype.woocommerce_settings.woocommerce_settings.get_series",
|
||||
callback: function (r) {
|
||||
$.each(r.message, function (key, value) {
|
||||
set_field_options(key, value);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
@ -1,175 +0,0 @@
|
||||
{
|
||||
"creation": "2018-02-12 15:10:05.495713",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enable_sync",
|
||||
"sb_00",
|
||||
"woocommerce_server_url",
|
||||
"secret",
|
||||
"cb_00",
|
||||
"api_consumer_key",
|
||||
"api_consumer_secret",
|
||||
"sb_accounting_details",
|
||||
"tax_account",
|
||||
"column_break_10",
|
||||
"f_n_f_account",
|
||||
"defaults_section",
|
||||
"creation_user",
|
||||
"warehouse",
|
||||
"sales_order_series",
|
||||
"column_break_14",
|
||||
"company",
|
||||
"delivery_after_days",
|
||||
"uom",
|
||||
"endpoints",
|
||||
"endpoint"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_sync",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Sync"
|
||||
},
|
||||
{
|
||||
"fieldname": "sb_00",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "woocommerce_server_url",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Woocommerce Server URL"
|
||||
},
|
||||
{
|
||||
"fieldname": "secret",
|
||||
"fieldtype": "Code",
|
||||
"label": "Secret",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "cb_00",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "api_consumer_key",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "API consumer key"
|
||||
},
|
||||
{
|
||||
"fieldname": "api_consumer_secret",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "API consumer secret"
|
||||
},
|
||||
{
|
||||
"fieldname": "sb_accounting_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Accounting Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "tax_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Account",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_10",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "f_n_f_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Freight and Forwarding Account",
|
||||
"options": "Account",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "defaults_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Defaults"
|
||||
},
|
||||
{
|
||||
"description": "The user that will be used to create Customers, Items and Sales Orders. This user should have the relevant permissions.",
|
||||
"fieldname": "creation_user",
|
||||
"fieldtype": "Link",
|
||||
"label": "Creation User",
|
||||
"options": "User",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "This warehouse will be used to create Sales Orders. The fallback warehouse is \"Stores\".",
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"label": "Warehouse",
|
||||
"options": "Warehouse"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_14",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "The fallback series is \"SO-WOO-\".",
|
||||
"fieldname": "sales_order_series",
|
||||
"fieldtype": "Select",
|
||||
"label": "Sales Order Series"
|
||||
},
|
||||
{
|
||||
"description": "This is the default UOM used for items and Sales orders. The fallback UOM is \"Nos\".",
|
||||
"fieldname": "uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "UOM",
|
||||
"options": "UOM"
|
||||
},
|
||||
{
|
||||
"fieldname": "endpoints",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Endpoints"
|
||||
},
|
||||
{
|
||||
"fieldname": "endpoint",
|
||||
"fieldtype": "Code",
|
||||
"label": "Endpoint",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "This company will be used to create Sales Orders.",
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
"options": "Company",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"description": "This is the default offset (days) for the Delivery Date in Sales Orders. The fallback offset is 7 days from the order placement date.",
|
||||
"fieldname": "delivery_after_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Delivery After (Days)"
|
||||
}
|
||||
],
|
||||
"issingle": 1,
|
||||
"modified": "2019-11-04 00:45:21.232096",
|
||||
"modified_by": "Administrator",
|
||||
"module": "ERPNext Integrations",
|
||||
"name": "Woocommerce Settings",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"email": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
|
||||
class WoocommerceSettings(Document):
|
||||
def validate(self):
|
||||
self.validate_settings()
|
||||
self.create_delete_custom_fields()
|
||||
self.create_webhook_url()
|
||||
|
||||
def create_delete_custom_fields(self):
|
||||
if self.enable_sync:
|
||||
create_custom_fields(
|
||||
{
|
||||
("Customer", "Sales Order", "Item", "Address"): dict(
|
||||
fieldname="woocommerce_id",
|
||||
label="Woocommerce ID",
|
||||
fieldtype="Data",
|
||||
read_only=1,
|
||||
print_hide=1,
|
||||
),
|
||||
("Customer", "Address"): dict(
|
||||
fieldname="woocommerce_email",
|
||||
label="Woocommerce Email",
|
||||
fieldtype="Data",
|
||||
read_only=1,
|
||||
print_hide=1,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
if not frappe.get_value("Item Group", {"name": _("WooCommerce Products")}):
|
||||
item_group = frappe.new_doc("Item Group")
|
||||
item_group.item_group_name = _("WooCommerce Products")
|
||||
item_group.parent_item_group = get_root_of("Item Group")
|
||||
item_group.insert()
|
||||
|
||||
def validate_settings(self):
|
||||
if self.enable_sync:
|
||||
if not self.secret:
|
||||
self.set("secret", frappe.generate_hash())
|
||||
|
||||
if not self.woocommerce_server_url:
|
||||
frappe.throw(_("Please enter Woocommerce Server URL"))
|
||||
|
||||
if not self.api_consumer_key:
|
||||
frappe.throw(_("Please enter API Consumer Key"))
|
||||
|
||||
if not self.api_consumer_secret:
|
||||
frappe.throw(_("Please enter API Consumer Secret"))
|
||||
|
||||
def create_webhook_url(self):
|
||||
endpoint = "/api/method/erpnext.erpnext_integrations.connectors.woocommerce_connection.order"
|
||||
|
||||
try:
|
||||
url = frappe.request.url
|
||||
except RuntimeError:
|
||||
# for CI Test to work
|
||||
url = "http://localhost:8000"
|
||||
|
||||
server_url = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(url))
|
||||
|
||||
delivery_url = server_url + endpoint
|
||||
self.endpoint = delivery_url
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def generate_secret():
|
||||
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
|
||||
woocommerce_settings.secret = frappe.generate_hash()
|
||||
woocommerce_settings.save()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_series():
|
||||
return {
|
||||
"sales_order_series": frappe.get_meta("Sales Order").get_options("naming_series") or "SO-WOO-",
|
||||
}
|
@ -423,9 +423,6 @@ scheduler_events = {
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
],
|
||||
},
|
||||
"all": [
|
||||
"erpnext.crm.doctype.social_media_post.social_media_post.process_scheduled_social_media_posts",
|
||||
],
|
||||
"hourly": [
|
||||
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
|
||||
"erpnext.projects.doctype.project.project.project_status_update_reminder",
|
||||
@ -433,7 +430,7 @@ scheduler_events = {
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
],
|
||||
"hourly_long": [
|
||||
"erpnext.accounts.doctype.subscription.subscription.process_all",
|
||||
"erpnext.accounts.doctype.process_subscription.process_subscription.create_subscription_process",
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
|
||||
"erpnext.bulk_transaction.doctype.bulk_transaction_log.bulk_transaction_log.retry_failing_transaction",
|
||||
],
|
||||
@ -558,8 +555,6 @@ get_matching_queries = (
|
||||
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_matching_queries"
|
||||
)
|
||||
|
||||
get_matching_vouchers_for_bank_reconciliation = "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_matching_vouchers_for_bank_reconciliation"
|
||||
|
||||
get_amounts_not_reflected_in_system_for_bank_reconciliation_statement = "erpnext.accounts.report.bank_reconciliation_statement.bank_reconciliation_statement.get_amounts_not_reflected_in_system_for_bank_reconciliation_statement"
|
||||
|
||||
get_payment_entries_for_bank_clearance = (
|
||||
|
@ -23,17 +23,6 @@ frappe.ui.form.on('Job Card', {
|
||||
}
|
||||
});
|
||||
|
||||
let sbb_field = frm.get_docfield('serial_and_batch_bundle');
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = () => {
|
||||
return {
|
||||
'item_code': frm.doc.production_item,
|
||||
'warehouse': frm.doc.wip_warehouse,
|
||||
'voucher_type': frm.doc.doctype,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
frm.set_indicator_formatter('sub_operation',
|
||||
function(doc) {
|
||||
if (doc.status == "Pending") {
|
||||
@ -124,6 +113,17 @@ frappe.ui.form.on('Job Card', {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let sbb_field = frm.get_docfield('serial_and_batch_bundle');
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = () => {
|
||||
return {
|
||||
'item_code': frm.doc.production_item,
|
||||
'warehouse': frm.doc.wip_warehouse,
|
||||
'voucher_type': frm.doc.doctype,
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
setup_quality_inspection: function(frm) {
|
||||
|
@ -476,6 +476,15 @@ frappe.ui.form.on("Material Request Plan Item", {
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
material_request_type(frm, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
|
||||
if (row.from_warehouse &&
|
||||
row.material_request_type !== "Material Transfer") {
|
||||
frappe.model.set_value(cdt, cdn, 'from_warehouse', '');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -40,6 +40,12 @@ class ProductionPlan(Document):
|
||||
self._rename_temporary_references()
|
||||
validate_uom_is_integer(self, "stock_uom", "planned_qty")
|
||||
self.validate_sales_orders()
|
||||
self.validate_material_request_type()
|
||||
|
||||
def validate_material_request_type(self):
|
||||
for row in self.get("mr_items"):
|
||||
if row.from_warehouse and row.material_request_type != "Material Transfer":
|
||||
row.from_warehouse = ""
|
||||
|
||||
@frappe.whitelist()
|
||||
def validate_sales_orders(self, sales_order=None):
|
||||
@ -750,7 +756,9 @@ class ProductionPlan(Document):
|
||||
"items",
|
||||
{
|
||||
"item_code": item.item_code,
|
||||
"from_warehouse": item.from_warehouse,
|
||||
"from_warehouse": item.from_warehouse
|
||||
if material_request_type == "Material Transfer"
|
||||
else None,
|
||||
"qty": item.quantity,
|
||||
"schedule_date": schedule_date,
|
||||
"warehouse": item.warehouse,
|
||||
|
@ -1098,6 +1098,41 @@ class TestProductionPlan(FrappeTestCase):
|
||||
)
|
||||
self.assertEqual(reserved_qty_after_mr, before_qty)
|
||||
|
||||
def test_from_warehouse_for_purchase_material_request(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
create_item("RM-TEST-123 For Purchase", valuation_rate=100)
|
||||
bin_name = get_or_make_bin("RM-TEST-123 For Purchase", "_Test Warehouse - _TC")
|
||||
t_warehouse = create_warehouse("_Test Store - _TC")
|
||||
make_stock_entry(
|
||||
item_code="Raw Material Item 1",
|
||||
qty=5,
|
||||
rate=100,
|
||||
target=t_warehouse,
|
||||
)
|
||||
|
||||
plan = create_production_plan(item_code="Test Production Item 1", do_not_save=1)
|
||||
mr_items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": t_warehouse}]
|
||||
)
|
||||
|
||||
for d in mr_items:
|
||||
plan.append("mr_items", d)
|
||||
|
||||
plan.save()
|
||||
|
||||
for row in plan.mr_items:
|
||||
if row.material_request_type == "Material Transfer":
|
||||
self.assertEqual(row.from_warehouse, t_warehouse)
|
||||
|
||||
row.material_request_type = "Purchase"
|
||||
|
||||
plan.save()
|
||||
|
||||
for row in plan.mr_items:
|
||||
self.assertFalse(row.from_warehouse)
|
||||
|
||||
def test_skip_available_qty_for_sub_assembly_items(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
|
@ -340,5 +340,9 @@ erpnext.patches.v15_0.remove_exotel_integration
|
||||
erpnext.patches.v14_0.single_to_multi_dunning
|
||||
execute:frappe.db.set_single_value('Selling Settings', 'allow_negative_rates_for_items', 0)
|
||||
erpnext.patches.v15_0.correct_asset_value_if_je_with_workflow
|
||||
erpnext.patches.v15_0.delete_woocommerce_settings_doctype
|
||||
erpnext.patches.v14_0.migrate_deferred_accounts_to_item_defaults
|
||||
erpnext.patches.v14_0.update_invoicing_period_in_subscription
|
||||
execute:frappe.delete_doc("Page", "welcome-to-erpnext")
|
||||
# below migration patch should always run last
|
||||
erpnext.patches.v14_0.migrate_gl_to_payment_ledger
|
||||
|
@ -47,13 +47,16 @@ def execute():
|
||||
for doctype in doctypes:
|
||||
frappe.delete_doc("DocType", doctype, ignore_missing=True)
|
||||
|
||||
portal_settings = frappe.get_doc("Portal Settings")
|
||||
|
||||
for row in portal_settings.get("menu"):
|
||||
if row.reference_doctype in doctypes:
|
||||
row.delete()
|
||||
|
||||
portal_settings.save()
|
||||
titles = [
|
||||
"Fees",
|
||||
"Student Admission",
|
||||
"Grant Application",
|
||||
"Chapter",
|
||||
"Certification Application",
|
||||
]
|
||||
items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name")
|
||||
for item in items:
|
||||
frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True)
|
||||
|
||||
frappe.delete_doc("Module Def", "Education", ignore_missing=True, force=True)
|
||||
|
||||
|
@ -41,7 +41,7 @@ def execute():
|
||||
for card in cards:
|
||||
frappe.delete_doc("Number Card", card, ignore_missing=True, force=True)
|
||||
|
||||
titles = ["Lab Test", "Prescription", "Patient Appointment"]
|
||||
titles = ["Lab Test", "Prescription", "Patient Appointment", "Patient"]
|
||||
items = frappe.get_all("Portal Menu Item", filters=[["title", "in", titles]], pluck="name")
|
||||
for item in items:
|
||||
frappe.delete_doc("Portal Menu Item", item, ignore_missing=True, force=True)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user