Merge pull request #29733 from dj12djdjs/fix-user-unique-cart
fix(e-commerce): Unique Shopping Cart Per Logged In User
diff --git a/.github/helper/install.sh b/.github/helper/install.sh
index eab6d50..859146b 100644
--- a/.github/helper/install.sh
+++ b/.github/helper/install.sh
@@ -40,10 +40,14 @@
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
fi
-wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
-tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
-sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
-sudo chmod o+x /usr/local/bin/wkhtmltopdf
+
+install_whktml() {
+ wget -O /tmp/wkhtmltox.tar.xz https://github.com/frappe/wkhtmltopdf/raw/master/wkhtmltox-0.12.3_linux-generic-amd64.tar.xz
+ tar -xf /tmp/wkhtmltox.tar.xz -C /tmp
+ sudo mv /tmp/wkhtmltox/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf
+ sudo chmod o+x /usr/local/bin/wkhtmltopdf
+}
+install_whktml &
cd ~/frappe-bench || exit
@@ -57,5 +61,5 @@
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
bench start &> bench_run_logs.txt &
+CI=Yes bench build --app frappe &
bench --site test_site reinstall --yes
-bench build --app frappe
diff --git a/.github/stale.yml b/.github/stale.yml
index 8b7cb9b..1c2dcf3 100644
--- a/.github/stale.yml
+++ b/.github/stale.yml
@@ -30,6 +30,7 @@
exemptLabels:
- valid
- to-validate
+ - QA
markComment: >
This issue has been automatically marked as inactive because it has not had
recent activity and it wasn't validated by maintainer team. It will be
diff --git a/.mergify.yml b/.mergify.yml
index f3d0409..b7d1df4 100644
--- a/.mergify.yml
+++ b/.mergify.yml
@@ -14,9 +14,39 @@
close:
comment:
message: |
- @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
+ @{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
+ - name: backport to develop
+ conditions:
+ - label="backport develop"
+ actions:
+ backport:
+ branches:
+ - develop
+ assignees:
+ - "{{ author }}"
+
+ - name: backport to version-14-hotfix
+ conditions:
+ - label="backport version-14-hotfix"
+ actions:
+ backport:
+ branches:
+ - version-14-hotfix
+ assignees:
+ - "{{ author }}"
+
+ - name: backport to version-14-pre-release
+ conditions:
+ - label="backport version-14-pre-release"
+ actions:
+ backport:
+ branches:
+ - version-14-pre-release
+ assignees:
+ - "{{ author }}"
+
- name: backport to version-13-hotfix
conditions:
- label="backport version-13-hotfix"
@@ -55,4 +85,4 @@
branches:
- version-12-pre-release
assignees:
- - "{{ author }}"
\ No newline at end of file
+ - "{{ author }}"
diff --git a/erpnext/__init__.py b/erpnext/__init__.py
index 0b4696c..bef6661 100644
--- a/erpnext/__init__.py
+++ b/erpnext/__init__.py
@@ -2,8 +2,6 @@
import frappe
-from erpnext.hooks import regional_overrides
-
__version__ = '14.0.0-dev'
def get_default_company(user=None):
@@ -121,14 +119,17 @@
@erpnext.allow_regional
def myfunction():
pass'''
+
def caller(*args, **kwargs):
- region = get_region()
- fn_name = inspect.getmodule(fn).__name__ + '.' + fn.__name__
- if region in regional_overrides and fn_name in regional_overrides[region]:
- return frappe.get_attr(regional_overrides[region][fn_name])(*args, **kwargs)
- else:
+ overrides = frappe.get_hooks("regional_overrides", {}).get(get_region())
+ function_path = f"{inspect.getmodule(fn).__name__}.{fn.__name__}"
+
+ if not overrides or function_path not in overrides:
return fn(*args, **kwargs)
+ # Priority given to last installed app
+ return frappe.get_attr(overrides[function_path][-1])(*args, **kwargs)
+
return caller
def get_last_membership(member):
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
index dbf3622..46ba27c 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.js
@@ -64,6 +64,7 @@
"account_currency",
(r) => {
frm.currency = r.account_currency;
+ frm.trigger("render_chart");
}
);
}
@@ -128,7 +129,7 @@
}
},
- render_chart(frm) {
+ render_chart: frappe.utils.debounce((frm) => {
frm.cards_manager = new erpnext.accounts.bank_reconciliation.NumberCardManager(
{
$reconciliation_tool_cards: frm.get_field(
@@ -140,7 +141,7 @@
currency: frm.currency,
}
);
- },
+ }, 500),
render(frm) {
if (frm.doc.bank_account) {
diff --git a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
index 4211bd0..f3351dd 100644
--- a/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
+++ b/erpnext/accounts/doctype/bank_reconciliation_tool/bank_reconciliation_tool.py
@@ -7,6 +7,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
+from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt
from erpnext import get_company_currency
@@ -275,6 +276,10 @@
}
matching_vouchers = []
+
+ matching_vouchers.extend(get_loan_vouchers(bank_account, transaction,
+ document_types, filters))
+
for query in subquery:
matching_vouchers.extend(
frappe.db.sql(query, filters,)
@@ -311,6 +316,114 @@
return queries
+def get_loan_vouchers(bank_account, transaction, document_types, filters):
+ vouchers = []
+ amount_condition = True if "exact_match" in document_types else False
+
+ if transaction.withdrawal > 0 and "loan_disbursement" in document_types:
+ vouchers.extend(get_ld_matching_query(bank_account, amount_condition, filters))
+
+ if transaction.deposit > 0 and "loan_repayment" in document_types:
+ vouchers.extend(get_lr_matching_query(bank_account, amount_condition, filters))
+
+ return vouchers
+
+def get_ld_matching_query(bank_account, amount_condition, filters):
+ loan_disbursement = frappe.qb.DocType("Loan Disbursement")
+ matching_reference = loan_disbursement.reference_number == filters.get("reference_number")
+ matching_party = loan_disbursement.applicant_type == filters.get("party_type") and \
+ loan_disbursement.applicant == filters.get("party")
+
+ rank = (
+ frappe.qb.terms.Case()
+ .when(matching_reference, 1)
+ .else_(0)
+ )
+
+ rank1 = (
+ frappe.qb.terms.Case()
+ .when(matching_party, 1)
+ .else_(0)
+ )
+
+ query = frappe.qb.from_(loan_disbursement).select(
+ rank + rank1 + 1,
+ ConstantColumn("Loan Disbursement").as_("doctype"),
+ loan_disbursement.name,
+ loan_disbursement.disbursed_amount,
+ loan_disbursement.reference_number,
+ loan_disbursement.reference_date,
+ loan_disbursement.applicant_type,
+ loan_disbursement.disbursement_date
+ ).where(
+ loan_disbursement.docstatus == 1
+ ).where(
+ loan_disbursement.clearance_date.isnull()
+ ).where(
+ loan_disbursement.disbursement_account == bank_account
+ )
+
+ if amount_condition:
+ query.where(
+ loan_disbursement.disbursed_amount == filters.get('amount')
+ )
+ else:
+ query.where(
+ loan_disbursement.disbursed_amount <= filters.get('amount')
+ )
+
+ vouchers = query.run(as_list=True)
+
+ return vouchers
+
+def get_lr_matching_query(bank_account, amount_condition, filters):
+ loan_repayment = frappe.qb.DocType("Loan Repayment")
+ matching_reference = loan_repayment.reference_number == filters.get("reference_number")
+ matching_party = loan_repayment.applicant_type == filters.get("party_type") and \
+ loan_repayment.applicant == filters.get("party")
+
+ rank = (
+ frappe.qb.terms.Case()
+ .when(matching_reference, 1)
+ .else_(0)
+ )
+
+ rank1 = (
+ frappe.qb.terms.Case()
+ .when(matching_party, 1)
+ .else_(0)
+ )
+
+ query = frappe.qb.from_(loan_repayment).select(
+ rank + rank1 + 1,
+ ConstantColumn("Loan Repayment").as_("doctype"),
+ loan_repayment.name,
+ loan_repayment.amount_paid,
+ loan_repayment.reference_number,
+ loan_repayment.reference_date,
+ loan_repayment.applicant_type,
+ loan_repayment.posting_date
+ ).where(
+ loan_repayment.docstatus == 1
+ ).where(
+ loan_repayment.clearance_date.isnull()
+ ).where(
+ loan_repayment.payment_account == bank_account
+ )
+
+ if amount_condition:
+ query.where(
+ loan_repayment.amount_paid == filters.get('amount')
+ )
+ else:
+ query.where(
+ loan_repayment.amount_paid <= filters.get('amount')
+ )
+
+ vouchers = query.run()
+
+ return vouchers
+
def get_pe_matching_query(amount_condition, account_from_to, transaction):
# get matching payment entries query
if transaction.deposit > 0:
@@ -348,7 +461,6 @@
# We have mapping at the bank level
# 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
- company_account = frappe.get_value("Bank Account", transaction.bank_account, "account")
cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
return f"""
diff --git a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
index 51e1d6e..a476cab 100644
--- a/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/bank_transaction.py
@@ -49,7 +49,8 @@
def clear_linked_payment_entries(self, for_cancel=False):
for payment_entry in self.payment_entries:
- if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim"]:
+ if payment_entry.payment_document in ["Payment Entry", "Journal Entry", "Purchase Invoice", "Expense Claim", "Loan Repayment",
+ "Loan Disbursement"]:
self.clear_simple_entry(payment_entry, for_cancel=for_cancel)
elif payment_entry.payment_document == "Sales Invoice":
@@ -116,11 +117,18 @@
payment_entry.payment_entry, paid_amount_field)
elif payment_entry.payment_document == "Journal Entry":
- return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)")
+ return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account},
+ "sum(credit_in_account_currency)")
elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed")
+ elif payment_entry.payment_document == "Loan Disbursement":
+ return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "disbursed_amount")
+
+ elif payment_entry.payment_document == "Loan Repayment":
+ return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "amount_paid")
+
else:
frappe.throw("Please reconcile {0}: {1} manually".format(payment_entry.payment_document, payment_entry.payment_entry))
diff --git a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
index 72b6893..d84b8e0 100644
--- a/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
+++ b/erpnext/accounts/doctype/bank_transaction/test_bank_transaction.py
@@ -109,7 +109,7 @@
frappe.get_doc({
"doctype": "Bank",
"bank_name":bank_name,
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -119,7 +119,7 @@
"account_name":"Checking Account",
"bank": bank_name,
"account": account_name
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -184,7 +184,7 @@
"supplier_group":"All Supplier Groups",
"supplier_type": "Company",
"supplier_name": "Conrad Electronic"
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -203,7 +203,7 @@
"supplier_group":"All Supplier Groups",
"supplier_type": "Company",
"supplier_name": "Mr G"
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -227,7 +227,7 @@
"supplier_group":"All Supplier Groups",
"supplier_type": "Company",
"supplier_name": "Poore Simon's"
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -237,7 +237,7 @@
"customer_group":"All Customer Groups",
"customer_type": "Company",
"customer_name": "Poore Simon's"
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -266,7 +266,7 @@
"customer_group":"All Customer Groups",
"customer_type": "Company",
"customer_name": "Fayva"
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
diff --git a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json
index 22cf797..02c6875 100644
--- a/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json
+++ b/erpnext/accounts/doctype/cash_flow_mapping_template_details/cash_flow_mapping_template_details.json
@@ -1,94 +1,34 @@
{
- "allow_copy": 0,
- "allow_guest_to_view": 0,
- "allow_import": 0,
- "allow_rename": 0,
- "autoname": "field:mapping",
- "beta": 0,
- "creation": "2018-02-08 10:18:48.513608",
- "custom": 0,
- "docstatus": 0,
- "doctype": "DocType",
- "document_type": "",
- "editable_grid": 1,
- "engine": "InnoDB",
+ "actions": [],
+ "creation": "2018-02-08 10:18:48.513608",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "mapping"
+ ],
"fields": [
{
- "allow_bulk_edit": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "collapsible": 0,
- "columns": 0,
- "fieldname": "mapping",
- "fieldtype": "Link",
- "hidden": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_filter": 0,
- "in_global_search": 0,
- "in_list_view": 1,
- "in_standard_filter": 0,
- "label": "Mapping",
- "length": 0,
- "no_copy": 0,
- "options": "Cash Flow Mapping",
- "permlevel": 0,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "read_only": 0,
- "remember_last_selected_value": 0,
- "report_hide": 0,
- "reqd": 1,
- "search_index": 0,
- "set_only_once": 0,
- "unique": 0
+ "fieldname": "mapping",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Mapping",
+ "options": "Cash Flow Mapping",
+ "reqd": 1,
+ "unique": 1
}
- ],
- "has_web_view": 0,
- "hide_heading": 0,
- "hide_toolbar": 0,
- "idx": 0,
- "image_view": 0,
- "in_create": 0,
- "is_submittable": 0,
- "issingle": 0,
- "istable": 0,
- "max_attachments": 0,
- "modified": "2018-02-08 10:33:39.413930",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "Cash Flow Mapping Template Details",
- "name_case": "",
- "owner": "Administrator",
- "permissions": [
- {
- "amend": 0,
- "apply_user_permissions": 0,
- "cancel": 0,
- "create": 1,
- "delete": 1,
- "email": 1,
- "export": 1,
- "if_owner": 0,
- "import": 0,
- "permlevel": 0,
- "print": 1,
- "read": 1,
- "report": 1,
- "role": "System Manager",
- "set_user_permissions": 0,
- "share": 1,
- "submit": 0,
- "write": 1
- }
- ],
- "quick_entry": 1,
- "read_only": 0,
- "read_only_onload": 0,
- "show_name_in_global_search": 0,
- "sort_field": "modified",
- "sort_order": "DESC",
- "track_changes": 1,
- "track_seen": 0
+ ],
+ "istable": 1,
+ "links": [],
+ "modified": "2022-02-21 03:34:57.902332",
+ "modified_by": "Administrator",
+ "module": "Accounts",
+ "name": "Cash Flow Mapping Template Details",
+ "owner": "Administrator",
+ "permissions": [],
+ "quick_entry": 1,
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
index 19d8d49..6e7b80e 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/opening_invoice_creation_tool.py
@@ -166,8 +166,9 @@
frappe.scrub(row.party_type): row.party,
"is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
- "update_stock": 0,
- "invoice_number": row.invoice_number
+ "update_stock": 0, # important: https://github.com/frappe/erpnext/pull/23559
+ "invoice_number": row.invoice_number,
+ "disable_rounded_total": 1
})
accounting_dimension = get_accounting_dimensions()
diff --git a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
index 6700e9b..3eaf6a2 100644
--- a/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
+++ b/erpnext/accounts/doctype/opening_invoice_creation_tool/test_opening_invoice_creation_tool.py
@@ -1,11 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
-import unittest
-
import frappe
-from frappe.cache_manager import clear_doctype_cache
-from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension,
@@ -14,14 +10,17 @@
from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account,
)
+from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
-class TestOpeningInvoiceCreationTool(unittest.TestCase):
- def setUp(self):
+class TestOpeningInvoiceCreationTool(ERPNextTestCase):
+ @classmethod
+ def setUpClass(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company()
create_dimension()
+ return super().setUpClass()
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None, department=None):
doc = frappe.get_single("Opening Invoice Creation Tool")
@@ -31,26 +30,20 @@
return doc.make_invoices()
def test_opening_sales_invoice_creation(self):
- property_setter = make_property_setter("Sales Invoice", "update_stock", "default", 1, "Check")
- try:
- invoices = self.make_invoices(company="_Test Opening Invoice Company")
+ invoices = self.make_invoices(company="_Test Opening Invoice Company")
- self.assertEqual(len(invoices), 2)
- expected_value = {
- "keys": ["customer", "outstanding_amount", "status"],
- 0: ["_Test Customer", 300, "Overdue"],
- 1: ["_Test Customer 1", 250, "Overdue"],
- }
- self.check_expected_values(invoices, expected_value)
+ self.assertEqual(len(invoices), 2)
+ expected_value = {
+ "keys": ["customer", "outstanding_amount", "status"],
+ 0: ["_Test Customer", 300, "Overdue"],
+ 1: ["_Test Customer 1", 250, "Overdue"],
+ }
+ self.check_expected_values(invoices, expected_value)
- si = frappe.get_doc("Sales Invoice", invoices[0])
+ si = frappe.get_doc("Sales Invoice", invoices[0])
- # Check if update stock is not enabled
- self.assertEqual(si.update_stock, 0)
-
- finally:
- property_setter.delete()
- clear_doctype_cache("Sales Invoice")
+ # Check if update stock is not enabled
+ self.assertEqual(si.update_stock, 0)
def check_expected_values(self, invoices, expected_value, invoice_type="Sales"):
doctype = "Sales Invoice" if invoice_type == "Sales" else "Purchase Invoice"
diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py
index 02a144d..0d8f079 100644
--- a/erpnext/accounts/doctype/payment_entry/payment_entry.py
+++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py
@@ -1077,7 +1077,7 @@
if d.voucher_type in ("Purchase Invoice"):
d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no")
- # Get all SO / PO which are not fully billed or aginst which full advance not paid
+ # Get all SO / PO which are not fully billed or against which full advance not paid
orders_to_be_billed = []
if (args.get("party_type") != "Student"):
orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"),
diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
index 97d34e0..9b3b3aa 100644
--- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
+++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py
@@ -172,9 +172,10 @@
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_stock_availablility(self):
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
if self.is_return or self.docstatus != 1:
return
- allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock')
for d in self.get('items'):
is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item'))
if is_service_item:
@@ -186,7 +187,7 @@
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
- if allow_negative_stock:
+ if is_negative_stock_allowed(item_code=d.item_code):
return
available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse)
@@ -438,7 +439,6 @@
self.paid_amount = 0
def set_account_for_mode_of_payment(self):
- self.payments = [d for d in self.payments if d.amount or d.base_amount or d.default]
for pay in self.payments:
if not pay.account:
pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account")
diff --git a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
index b590944..1d30934 100644
--- a/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
+++ b/erpnext/accounts/doctype/sales_taxes_and_charges_template/sales_taxes_and_charges_template.py
@@ -46,7 +46,7 @@
for tax in doc.get("taxes"):
validate_taxes_and_charges(tax)
- validate_account_head(tax, doc)
+ validate_account_head(tax.idx, tax.account_head, doc.company)
validate_cost_center(tax, doc)
validate_inclusive_tax(tax, doc)
diff --git a/erpnext/accounts/general_ledger.py b/erpnext/accounts/general_ledger.py
index 55bc967..d24d56b 100644
--- a/erpnext/accounts/general_ledger.py
+++ b/erpnext/accounts/general_ledger.py
@@ -319,13 +319,18 @@
"""
if not gl_entries:
- gl_entries = frappe.get_all("GL Entry",
- fields = ["*"],
- filters = {
- "voucher_type": voucher_type,
- "voucher_no": voucher_no,
- "is_cancelled": 0
- })
+ gl_entry = frappe.qb.DocType("GL Entry")
+ gl_entries = (frappe.qb.from_(
+ gl_entry
+ ).select(
+ '*'
+ ).where(
+ gl_entry.voucher_type == voucher_type
+ ).where(
+ gl_entry.voucher_no == voucher_no
+ ).where(
+ gl_entry.is_cancelled == 0
+ ).for_update()).run(as_dict=1)
if gl_entries:
validate_accounting_period(gl_entries)
@@ -333,23 +338,24 @@
set_as_cancel(gl_entries[0]['voucher_type'], gl_entries[0]['voucher_no'])
for entry in gl_entries:
- entry['name'] = None
- debit = entry.get('debit', 0)
- credit = entry.get('credit', 0)
+ new_gle = copy.deepcopy(entry)
+ new_gle['name'] = None
+ debit = new_gle.get('debit', 0)
+ credit = new_gle.get('credit', 0)
- debit_in_account_currency = entry.get('debit_in_account_currency', 0)
- credit_in_account_currency = entry.get('credit_in_account_currency', 0)
+ debit_in_account_currency = new_gle.get('debit_in_account_currency', 0)
+ credit_in_account_currency = new_gle.get('credit_in_account_currency', 0)
- entry['debit'] = credit
- entry['credit'] = debit
- entry['debit_in_account_currency'] = credit_in_account_currency
- entry['credit_in_account_currency'] = debit_in_account_currency
+ new_gle['debit'] = credit
+ new_gle['credit'] = debit
+ new_gle['debit_in_account_currency'] = credit_in_account_currency
+ new_gle['credit_in_account_currency'] = debit_in_account_currency
- entry['remarks'] = "On cancellation of " + entry['voucher_no']
- entry['is_cancelled'] = 1
+ new_gle['remarks'] = "On cancellation of " + new_gle['voucher_no']
+ new_gle['is_cancelled'] = 1
- if entry['debit'] or entry['credit']:
- make_entry(entry, adv_adj, "Yes")
+ if new_gle['debit'] or new_gle['credit']:
+ make_entry(new_gle, adv_adj, "Yes")
def check_freezing_date(posting_date, adv_adj=False):
diff --git a/erpnext/accounts/party.py b/erpnext/accounts/party.py
index c13bc23..d6f6c5b 100644
--- a/erpnext/accounts/party.py
+++ b/erpnext/accounts/party.py
@@ -307,7 +307,7 @@
.format(frappe.bold(party_type), frappe.bold(party), frappe.bold(existing_gle_currency), frappe.bold(company)), InvalidAccountCurrency)
def validate_party_accounts(doc):
-
+ from erpnext.controllers.accounts_controller import validate_account_head
companies = []
for account in doc.get("accounts"):
@@ -330,6 +330,9 @@
if doc.default_currency != party_account_currency and doc.default_currency != company_default_currency:
frappe.throw(_("Billing currency must be equal to either default company's currency or party account currency"))
+ # validate if account is mapped for same company
+ validate_account_head(account.idx, account.account, account.company)
+
@frappe.whitelist()
def get_due_date(posting_date, party_type, party, company=None, bill_date=None):
diff --git a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json b/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json
deleted file mode 100644
index 1aa1c02..0000000
--- a/erpnext/accounts/print_format/gst_pos_invoice/gst_pos_invoice.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "align_labels_right": 0,
- "creation": "2017-08-08 12:33:04.773099",
- "custom_format": 1,
- "disabled": 0,
- "doc_type": "Sales Invoice",
- "docstatus": 0,
- "doctype": "Print Format",
- "font": "Default",
- "html": "<style>\n\t.print-format table, .print-format tr, \n\t.print-format td, .print-format div, .print-format p {\n\t\tfont-family: Tahoma, sans-serif;\n\t\tline-height: 150%;\n\t\tvertical-align: middle;\n\t}\n\t@media screen {\n\t\t.print-format {\n\t\t\twidth: 4in;\n\t\t\tpadding: 0.25in;\n\t\t\tmin-height: 8in;\n\t\t}\n\t}\n</style>\n\n{% if letter_head %}\n {{ letter_head }}\n{% endif %}\n<p class=\"text-center\">\n\t{{ doc.company }}<br>\n\t{% if doc.company_address_display %}\n\t\t{% set company_address = doc.company_address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t{% if \"GSTIN\" not in company_address %}\n\t\t\t{{ company_address }}\n\t\t\t<b>{{ _(\"GSTIN\") }}:</b>{{ doc.company_gstin }}\n\t\t{% else %}\n\t\t\t{{ company_address.replace(\"GSTIN\", \"<br>GSTIN\") }}\n\t\t{% endif %}\n\t{% endif %}\n\t<br>\n\t{% if doc.docstatus == 0 %}\n\t\t<b>{{ doc.status + \" \"+ (doc.select_print_heading or _(\"Invoice\")) }}</b><br>\n\t{% else %}\n\t\t<b>{{ doc.select_print_heading or _(\"Invoice\") }}</b><br>\n\t{% endif %}\n</p>\n<p>\n\t<b>{{ _(\"Receipt No\") }}:</b> {{ doc.name }}<br>\n\t<b>{{ _(\"Date\") }}:</b> {{ doc.get_formatted(\"posting_date\") }}<br>\n\t{% if doc.grand_total > 50000 %}\n\t\t{% set customer_address = doc.address_display.replace(\"\\n\", \" \").replace(\"<br>\", \" \") %}\n\t\t<b>{{ _(\"Customer\") }}:</b><br>\n\t\t{{ doc.customer_name }}<br>\n\t\t{{ customer_address }}\n\t{% endif %}\n</p>\n\n<hr>\n<table class=\"table table-condensed cart no-border\">\n\t<thead>\n\t\t<tr>\n\t\t\t<th width=\"50%\">{{ _(\"Item\") }}</b></th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Qty\") }}</th>\n\t\t\t<th width=\"25%\" class=\"text-right\">{{ _(\"Amount\") }}</th>\n\t\t</tr>\n\t</thead>\n\t<tbody>\n\t\t{%- for item in doc.items -%}\n\t\t<tr>\n\t\t\t<td>\n\t\t\t\t{{ item.item_code }}\n\t\t\t\t{%- if item.item_name != item.item_code -%}\n\t\t\t\t\t<br>{{ item.item_name }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.gst_hsn_code -%}\n\t\t\t\t\t<br><b>{{ _(\"HSN/SAC\") }}:</b> {{ item.gst_hsn_code }}\n\t\t\t\t{%- endif -%}\n\t\t\t\t{%- if item.serial_no -%}\n\t\t\t\t\t<br><b>{{ _(\"Serial No\") }}:</b> {{ item.serial_no }}\n\t\t\t\t{%- endif -%}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">{{ item.qty }}<br>@ {{ item.rate }}</td>\n\t\t\t<td class=\"text-right\">{{ item.get_formatted(\"amount\") }}</td>\n\t\t</tr>\n\t\t{%- endfor -%}\n\t</tbody>\n</table>\n<table class=\"table table-condensed no-border\">\n\t<tbody>\n\t\t<tr>\n\t\t\t{% if doc.flags.show_inclusive_tax_in_print %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total Excl. Tax\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"net_total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% else %}\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ _(\"Total\") }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ doc.get_formatted(\"total\", doc) }}\n\t\t\t\t</td>\n\t\t\t{% endif %}\n\t\t</tr>\n\t\t{%- for row in doc.taxes -%}\n\t\t {%- if (not row.included_in_print_rate or doc.flags.show_inclusive_tax_in_print) and row.tax_amount != 0 -%}\n\t\t\t<tr>\n\t\t\t\t<td class=\"text-right\" style=\"width: 70%\">\n\t\t\t\t\t{{ row.description }}\n\t\t\t\t</td>\n\t\t\t\t<td class=\"text-right\">\n\t\t\t\t\t{{ row.get_formatted(\"tax_amount\", doc) }}\n\t\t\t\t</td>\n\t\t\t<tr>\n\t\t {%- endif -%}\n\t\t{%- endfor -%}\n\t\t{%- if doc.discount_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t{{ _(\"Discount\") }}\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"discount_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Grand Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"grand_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- if doc.rounded_total -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Rounded Total\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"rounded_total\") }}\n\t\t\t</td>\n\t\t</tr>\n\t\t{%- endif -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Paid Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"paid_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- if doc.change_amount -%}\n\t\t<tr>\n\t\t\t<td class=\"text-right\" style=\"width: 75%\">\n\t\t\t\t<b>{{ _(\"Change Amount\") }}</b>\n\t\t\t</td>\n\t\t\t<td class=\"text-right\">\n\t\t\t\t{{ doc.get_formatted(\"change_amount\") }}\n\t\t\t</td>\n\t\t</tr>\n\t{%- endif -%}\n\t</tbody>\n</table>\n<p>{{ doc.terms or \"\" }}</p>\n<p class=\"text-center\">{{ _(\"Thank you, please visit again.\") }}</p>",
- "idx": 0,
- "line_breaks": 0,
- "modified": "2020-04-29 16:39:12.936215",
- "modified_by": "Administrator",
- "module": "Accounts",
- "name": "GST POS Invoice",
- "owner": "Administrator",
- "print_format_builder": 0,
- "print_format_type": "Jinja",
- "raw_printing": 0,
- "show_section_headings": 0,
- "standard": "Yes"
-}
\ No newline at end of file
diff --git a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
index 6c401fb..b72d266 100644
--- a/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
+++ b/erpnext/accounts/report/bank_reconciliation_statement/bank_reconciliation_statement.py
@@ -4,7 +4,12 @@
import frappe
from frappe import _
-from frappe.utils import flt, getdate, nowdate
+from frappe.query_builder.custom import ConstantColumn
+from frappe.query_builder.functions import Sum
+from frappe.utils import flt, getdate
+from pypika import CustomFunction
+
+from erpnext.accounts.utils import get_balance_on
def execute(filters=None):
@@ -18,7 +23,6 @@
data = get_entries(filters)
- from erpnext.accounts.utils import get_balance_on
balance_as_per_system = get_balance_on(filters["account"], filters["report_date"])
total_debit, total_credit = 0,0
@@ -118,7 +122,21 @@
]
def get_entries(filters):
- journal_entries = frappe.db.sql("""
+ journal_entries = get_journal_entries(filters)
+
+ payment_entries = get_payment_entries(filters)
+
+ loan_entries = get_loan_entries(filters)
+
+ pos_entries = []
+ if filters.include_pos_transactions:
+ pos_entries = get_pos_entries(filters)
+
+ return sorted(list(payment_entries)+list(journal_entries+list(pos_entries) + list(loan_entries)),
+ key=lambda k: getdate(k['posting_date']))
+
+def get_journal_entries(filters):
+ return frappe.db.sql("""
select "Journal Entry" as payment_document, jv.posting_date,
jv.name as payment_entry, jvd.debit_in_account_currency as debit,
jvd.credit_in_account_currency as credit, jvd.against_account,
@@ -130,7 +148,8 @@
and ifnull(jv.clearance_date, '4000-01-01') > %(report_date)s
and ifnull(jv.is_opening, 'No') = 'No'""", filters, as_dict=1)
- payment_entries = frappe.db.sql("""
+def get_payment_entries(filters):
+ return frappe.db.sql("""
select
"Payment Entry" as payment_document, name as payment_entry,
reference_no, reference_date as ref_date,
@@ -145,9 +164,8 @@
and ifnull(clearance_date, '4000-01-01') > %(report_date)s
""", filters, as_dict=1)
- pos_entries = []
- if filters.include_pos_transactions:
- pos_entries = frappe.db.sql("""
+def get_pos_entries(filters):
+ return frappe.db.sql("""
select
"Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit,
si.posting_date, si.debit_to as against_account, sip.clearance_date,
@@ -161,8 +179,42 @@
si.posting_date ASC, si.name DESC
""", filters, as_dict=1)
- return sorted(list(payment_entries)+list(journal_entries+list(pos_entries)),
- key=lambda k: k['posting_date'] or getdate(nowdate()))
+def get_loan_entries(filters):
+ loan_docs = []
+ for doctype in ["Loan Disbursement", "Loan Repayment"]:
+ loan_doc = frappe.qb.DocType(doctype)
+ ifnull = CustomFunction('IFNULL', ['value', 'default'])
+
+ if doctype == "Loan Disbursement":
+ amount_field = (loan_doc.disbursed_amount).as_("credit")
+ posting_date = (loan_doc.disbursement_date).as_("posting_date")
+ account = loan_doc.disbursement_account
+ else:
+ amount_field = (loan_doc.amount_paid).as_("debit")
+ posting_date = (loan_doc.posting_date).as_("posting_date")
+ account = loan_doc.payment_account
+
+ entries = frappe.qb.from_(loan_doc).select(
+ ConstantColumn(doctype).as_("payment_document"),
+ (loan_doc.name).as_("payment_entry"),
+ (loan_doc.reference_number).as_("reference_no"),
+ (loan_doc.reference_date).as_("ref_date"),
+ amount_field,
+ posting_date,
+ ).where(
+ loan_doc.docstatus == 1
+ ).where(
+ account == filters.get('account')
+ ).where(
+ posting_date <= getdate(filters.get('report_date'))
+ ).where(
+ ifnull(loan_doc.clearance_date, '4000-01-01') > getdate(filters.get('report_date'))
+ ).run(as_dict=1)
+
+ loan_docs.extend(entries)
+
+ return loan_docs
+
def get_amounts_not_reflected_in_system(filters):
je_amount = frappe.db.sql("""
@@ -182,7 +234,40 @@
pe_amount = flt(pe_amount[0][0]) if pe_amount else 0.0
- return je_amount + pe_amount
+ loan_amount = get_loan_amount(filters)
+
+ return je_amount + pe_amount + loan_amount
+
+def get_loan_amount(filters):
+ total_amount = 0
+ for doctype in ["Loan Disbursement", "Loan Repayment"]:
+ loan_doc = frappe.qb.DocType(doctype)
+ ifnull = CustomFunction('IFNULL', ['value', 'default'])
+
+ if doctype == "Loan Disbursement":
+ amount_field = Sum(loan_doc.disbursed_amount)
+ posting_date = (loan_doc.disbursement_date).as_("posting_date")
+ account = loan_doc.disbursement_account
+ else:
+ amount_field = Sum(loan_doc.amount_paid)
+ posting_date = (loan_doc.posting_date).as_("posting_date")
+ account = loan_doc.payment_account
+
+ amount = frappe.qb.from_(loan_doc).select(
+ amount_field
+ ).where(
+ loan_doc.docstatus == 1
+ ).where(
+ account == filters.get('account')
+ ).where(
+ posting_date > getdate(filters.get('report_date'))
+ ).where(
+ ifnull(loan_doc.clearance_date, '4000-01-01') <= getdate(filters.get('report_date'))
+ ).run()[0][0]
+
+ total_amount += flt(amount)
+
+ return amount
def get_balance_row(label, amount, account_currency):
if amount > 0:
diff --git a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
index 758e3e9..1e20f7b 100644
--- a/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
+++ b/erpnext/accounts/report/consolidated_financial_statement/consolidated_financial_statement.py
@@ -354,9 +354,6 @@
if d.parent_account:
account = d.parent_account_name
- # if not accounts_by_name.get(account):
- # continue
-
for company in companies:
accounts_by_name[account][company] = \
accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0)
@@ -367,7 +364,7 @@
accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0)
def get_account_heads(root_type, companies, filters):
- accounts = get_accounts(root_type, filters)
+ accounts = get_accounts(root_type, companies)
if not accounts:
return None, None, None
@@ -396,7 +393,7 @@
for account in accounts:
if account.parent_account:
- account["parent_account_name"] = name_to_account_map[account.parent_account]
+ account["parent_account_name"] = name_to_account_map.get(account.parent_account)
return accounts
@@ -419,12 +416,19 @@
return frappe.db.sql_list("""select name from `tabCompany`
where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt))
-def get_accounts(root_type, filters):
- return frappe.db.sql(""" select name, is_group, company,
- parent_account, lft, rgt, root_type, report_type, account_name, account_number
- from
- `tabAccount` where company = %s and root_type = %s
- """ , (filters.get('company'), root_type), as_dict=1)
+def get_accounts(root_type, companies):
+ accounts = []
+ added_accounts = []
+
+ for company in companies:
+ for account in frappe.get_all("Account", fields=["name", "is_group", "company",
+ "parent_account", "lft", "rgt", "root_type", "report_type", "account_name", "account_number"],
+ filters={"company": company, "root_type": root_type}):
+ if account.account_name not in added_accounts:
+ accounts.append(account)
+ added_accounts.append(account.account_name)
+
+ return accounts
def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters):
data = []
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.js b/erpnext/accounts/report/gross_profit/gross_profit.js
index 685f2d6..158ff4d 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.js
+++ b/erpnext/accounts/report/gross_profit/gross_profit.js
@@ -8,20 +8,22 @@
"label": __("Company"),
"fieldtype": "Link",
"options": "Company",
- "reqd": 1,
- "default": frappe.defaults.get_user_default("Company")
+ "default": frappe.defaults.get_user_default("Company"),
+ "reqd": 1
},
{
"fieldname":"from_date",
"label": __("From Date"),
"fieldtype": "Date",
- "default": frappe.defaults.get_user_default("year_start_date")
+ "default": frappe.defaults.get_user_default("year_start_date"),
+ "reqd": 1
},
{
"fieldname":"to_date",
"label": __("To Date"),
"fieldtype": "Date",
- "default": frappe.defaults.get_user_default("year_end_date")
+ "default": frappe.defaults.get_user_default("year_end_date"),
+ "reqd": 1
},
{
"fieldname":"sales_invoice",
@@ -42,6 +44,11 @@
"parent_field": "parent_invoice",
"initial_depth": 3,
"formatter": function(value, row, column, data, default_formatter) {
+ if (column.fieldname == "sales_invoice" && column.options == "Item" && data.indent == 0) {
+ column._options = "Sales Invoice";
+ } else {
+ column._options = "Item";
+ }
value = default_formatter(value, row, column, data);
if (data && (data.indent == 0.0 || row[1].content == "Total")) {
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.json b/erpnext/accounts/report/gross_profit/gross_profit.json
index 76c560a..0730ffd 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.json
+++ b/erpnext/accounts/report/gross_profit/gross_profit.json
@@ -1,5 +1,5 @@
{
- "add_total_row": 0,
+ "add_total_row": 1,
"columns": [],
"creation": "2013-02-25 17:03:34",
"disable_prepared_report": 0,
@@ -9,7 +9,7 @@
"filters": [],
"idx": 3,
"is_standard": "Yes",
- "modified": "2021-11-13 19:14:23.730198",
+ "modified": "2022-02-11 10:18:36.956558",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Gross Profit",
diff --git a/erpnext/accounts/report/gross_profit/gross_profit.py b/erpnext/accounts/report/gross_profit/gross_profit.py
index 84effc0..b03bb9b 100644
--- a/erpnext/accounts/report/gross_profit/gross_profit.py
+++ b/erpnext/accounts/report/gross_profit/gross_profit.py
@@ -70,43 +70,42 @@
data.append(row)
def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data):
- for idx, src in enumerate(gross_profit_data.grouped_data):
+ for src in gross_profit_data.grouped_data:
row = []
for col in group_wise_columns.get(scrub(filters.group_by)):
row.append(src.get(col))
row.append(filters.currency)
- if idx == len(gross_profit_data.grouped_data)-1:
- row[0] = "Total"
data.append(row)
def get_columns(group_wise_columns, filters):
columns = []
column_map = frappe._dict({
- "parent": _("Sales Invoice") + ":Link/Sales Invoice:120",
- "invoice_or_item": _("Sales Invoice") + ":Link/Sales Invoice:120",
- "posting_date": _("Posting Date") + ":Date:100",
- "posting_time": _("Posting Time") + ":Data:100",
- "item_code": _("Item Code") + ":Link/Item:100",
- "item_name": _("Item Name") + ":Data:100",
- "item_group": _("Item Group") + ":Link/Item Group:100",
- "brand": _("Brand") + ":Link/Brand:100",
- "description": _("Description") +":Data:100",
- "warehouse": _("Warehouse") + ":Link/Warehouse:100",
- "qty": _("Qty") + ":Float:80",
- "base_rate": _("Avg. Selling Rate") + ":Currency/currency:100",
- "buying_rate": _("Valuation Rate") + ":Currency/currency:100",
- "base_amount": _("Selling Amount") + ":Currency/currency:100",
- "buying_amount": _("Buying Amount") + ":Currency/currency:100",
- "gross_profit": _("Gross Profit") + ":Currency/currency:100",
- "gross_profit_percent": _("Gross Profit %") + ":Percent:100",
- "project": _("Project") + ":Link/Project:100",
- "sales_person": _("Sales person"),
- "allocated_amount": _("Allocated Amount") + ":Currency/currency:100",
- "customer": _("Customer") + ":Link/Customer:100",
- "customer_group": _("Customer Group") + ":Link/Customer Group:100",
- "territory": _("Territory") + ":Link/Territory:100"
+ "parent": {"label": _('Sales Invoice'), "fieldname": "parent_invoice", "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
+ "invoice_or_item": {"label": _('Sales Invoice'), "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
+ "posting_date": {"label": _('Posting Date'), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
+ "posting_time": {"label": _('Posting Time'), "fieldname": "posting_time", "fieldtype": "Data", "width": 100},
+ "item_code": {"label": _('Item Code'), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100},
+ "item_name": {"label": _('Item Name'), "fieldname": "item_name", "fieldtype": "Data", "width": 100},
+ "item_group": {"label": _('Item Group'), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100},
+ "brand": {"label": _('Brand'), "fieldtype": "Link", "options": "Brand", "width": 100},
+ "description": {"label": _('Description'), "fieldname": "description", "fieldtype": "Data", "width": 100},
+ "warehouse": {"label": _('Warehouse'), "fieldname": "warehouse", "fieldtype": "Link", "options": "warehouse", "width": 100},
+ "qty": {"label": _('Qty'), "fieldname": "qty", "fieldtype": "Float", "width": 80},
+ "base_rate": {"label": _('Avg. Selling Rate'), "fieldname": "avg._selling_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "buying_rate": {"label": _('Valuation Rate'), "fieldname": "valuation_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "base_amount": {"label": _('Selling Amount'), "fieldname": "selling_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "buying_amount": {"label": _('Buying Amount'), "fieldname": "buying_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "gross_profit": {"label": _('Gross Profit'), "fieldname": "gross_profit", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "gross_profit_percent": {"label": _('Gross Profit Percent'), "fieldname": "gross_profit_%",
+ "fieldtype": "Percent", "width": 100},
+ "project": {"label": _('Project'), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100},
+ "sales_person": {"label": _('Sales Person'), "fieldname": "sales_person", "fieldtype": "Data","width": 100},
+ "allocated_amount": {"label": _('Allocated Amount'), "fieldname": "allocated_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
+ "customer": {"label": _('Customer'), "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 100},
+ "customer_group": {"label": _('Customer Group'), "fieldname": "customer_group", "fieldtype": "Link", "options": "customer", "width": 100},
+ "territory": {"label": _('Territory'), "fieldname": "territory", "fieldtype": "Link", "options": "territory", "width": 100},
})
for col in group_wise_columns.get(scrub(filters.group_by)):
@@ -173,7 +172,7 @@
buying_amount = 0
for row in reversed(self.si_list):
- if self.skip_row(row, self.product_bundles):
+ if self.skip_row(row):
continue
row.base_amount = flt(row.base_net_amount, self.currency_precision)
@@ -223,16 +222,6 @@
self.get_average_rate_based_on_group_by()
def get_average_rate_based_on_group_by(self):
- # sum buying / selling totals for group
- self.totals = frappe._dict(
- qty=0,
- base_amount=0,
- buying_amount=0,
- gross_profit=0,
- gross_profit_percent=0,
- base_rate=0,
- buying_rate=0
- )
for key in list(self.grouped):
if self.filters.get("group_by") != "Invoice":
for i, row in enumerate(self.grouped[key]):
@@ -244,7 +233,6 @@
new_row.base_amount += flt(row.base_amount, self.currency_precision)
new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row)
- self.add_to_totals(new_row)
else:
for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0:
@@ -258,17 +246,6 @@
if (flt(row.qty) or row.base_amount):
row = self.set_average_rate(row)
self.grouped_data.append(row)
- self.add_to_totals(row)
-
- self.set_average_gross_profit(self.totals)
-
- if self.filters.get("group_by") == "Invoice":
- self.totals.indent = 0.0
- self.totals.parent_invoice = ""
- self.totals.invoice_or_item = "Total"
- self.si_list.append(self.totals)
- else:
- self.grouped_data.append(self.totals)
def is_not_invoice_row(self, row):
return (self.filters.get("group_by") == "Invoice" and row.indent != 0.0) or self.filters.get("group_by") != "Invoice"
@@ -284,11 +261,6 @@
new_row.gross_profit_percent = flt(((new_row.gross_profit / new_row.base_amount) * 100.0), self.currency_precision) \
if new_row.base_amount else 0
- def add_to_totals(self, new_row):
- for key in self.totals:
- if new_row.get(key):
- self.totals[key] += new_row[key]
-
def get_returned_invoice_items(self):
returned_invoices = frappe.db.sql("""
select
@@ -306,12 +278,12 @@
self.returned_invoices.setdefault(inv.return_against, frappe._dict())\
.setdefault(inv.item_code, []).append(inv)
- def skip_row(self, row, product_bundles):
+ def skip_row(self, row):
if self.filters.get("group_by") != "Invoice":
if not row.get(scrub(self.filters.get("group_by", ""))):
return True
- elif row.get("is_return") == 1:
- return True
+
+ return False
def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0
@@ -369,20 +341,37 @@
return self.average_buying_rate[item_code]
def get_last_purchase_rate(self, item_code, row):
- condition = ''
- if row.project:
- condition += " AND a.project=%s" % (frappe.db.escape(row.project))
- elif row.cost_center:
- condition += " AND a.cost_center=%s" % (frappe.db.escape(row.cost_center))
- if self.filters.to_date:
- condition += " AND modified='%s'" % (self.filters.to_date)
+ purchase_invoice = frappe.qb.DocType("Purchase Invoice")
+ purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
- last_purchase_rate = frappe.db.sql("""
- select (a.base_rate / a.conversion_factor)
- from `tabPurchase Invoice Item` a
- where a.item_code = %s and a.docstatus=1
- {0}
- order by a.modified desc limit 1""".format(condition), item_code)
+ query = (frappe.qb.from_(purchase_invoice_item)
+ .inner_join(
+ purchase_invoice
+ ).on(
+ purchase_invoice.name == purchase_invoice_item.parent
+ ).select(
+ purchase_invoice_item.base_rate / purchase_invoice_item.conversion_factor
+ ).where(
+ purchase_invoice.docstatus == 1
+ ).where(
+ purchase_invoice.posting_date <= self.filters.to_date
+ ).where(
+ purchase_invoice_item.item_code == item_code
+ ))
+
+ if row.project:
+ query.where(
+ purchase_invoice_item.project == row.project
+ )
+
+ if row.cost_center:
+ query.where(
+ purchase_invoice_item.cost_center == row.cost_center
+ )
+
+ query.orderby(purchase_invoice.posting_date, order=frappe.qb.desc)
+ query.limit(1)
+ last_purchase_rate = query.run()
return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0
diff --git a/erpnext/accounts/report/tax_detail/test_tax_detail.py b/erpnext/accounts/report/tax_detail/test_tax_detail.py
index bf668ab..621de82 100644
--- a/erpnext/accounts/report/tax_detail/test_tax_detail.py
+++ b/erpnext/accounts/report/tax_detail/test_tax_detail.py
@@ -61,7 +61,7 @@
# Create GL Entries:
db_doc.submit()
else:
- db_doc.insert()
+ db_doc.insert(ignore_if_duplicate=True)
except frappe.exceptions.DuplicateEntryError:
pass
diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
index caee1a1..e6cbff5 100644
--- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
+++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py
@@ -23,7 +23,7 @@
def get_result(filters, tds_docs, tds_accounts, tax_category_map):
supplier_map = get_supplier_pan_map()
tax_rate_map = get_tax_rate_map(filters)
- gle_map = get_gle_map(filters, tds_docs)
+ gle_map = get_gle_map(tds_docs)
out = []
for name, details in gle_map.items():
@@ -43,7 +43,7 @@
if entry.account in tds_accounts:
tds_deducted += (entry.credit - entry.debit)
- total_amount_credited += (entry.credit - entry.debit)
+ total_amount_credited += entry.credit
if tds_deducted:
row = {
@@ -78,7 +78,7 @@
return supplier_map
-def get_gle_map(filters, documents):
+def get_gle_map(documents):
# create gle_map of the form
# {"purchase_invoice": list of dict of all gle created for this invoice}
gle_map = {}
@@ -86,7 +86,7 @@
gle = frappe.db.get_all('GL Entry',
{
"voucher_no": ["in", documents],
- "credit": (">", 0)
+ "is_cancelled": 0
},
["credit", "debit", "account", "voucher_no", "posting_date", "voucher_type", "against", "party"],
)
@@ -184,21 +184,28 @@
payment_entries = []
journal_entries = []
tax_category_map = {}
+ or_filters = {}
+ bank_accounts = frappe.get_all('Account', {'is_group': 0, 'account_type': 'Bank'}, pluck="name")
tds_accounts = frappe.get_all("Tax Withholding Account", {'company': filters.get('company')},
pluck="account")
query_filters = {
- "credit": ('>', 0),
"account": ("in", tds_accounts),
"posting_date": ("between", [filters.get("from_date"), filters.get("to_date")]),
- "is_cancelled": 0
+ "is_cancelled": 0,
+ "against": ("not in", bank_accounts)
}
- if filters.get('supplier'):
- query_filters.update({'against': filters.get('supplier')})
+ if filters.get("supplier"):
+ del query_filters["account"]
+ del query_filters["against"]
+ or_filters = {
+ "against": filters.get('supplier'),
+ "party": filters.get('supplier')
+ }
- tds_docs = frappe.get_all("GL Entry", query_filters, ["voucher_no", "voucher_type", "against", "party"])
+ tds_docs = frappe.get_all("GL Entry", filters=query_filters, or_filters=or_filters, fields=["voucher_no", "voucher_type", "against", "party"])
for d in tds_docs:
if d.voucher_type == "Purchase Invoice":
diff --git a/erpnext/accounts/test/test_reports.py b/erpnext/accounts/test/test_reports.py
index 78c109a..4ed966d 100644
--- a/erpnext/accounts/test/test_reports.py
+++ b/erpnext/accounts/test/test_reports.py
@@ -39,10 +39,11 @@
def test_execute_all_accounts_reports(self):
"""Test that all script report in stock modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES:
- execute_script_report(
- report_name=report,
- module="Accounts",
- filters=filter,
- default_filters=DEFAULT_FILTERS,
- optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
- )
+ with self.subTest(report=report):
+ execute_script_report(
+ report_name=report,
+ module="Accounts",
+ filters=filter,
+ default_filters=DEFAULT_FILTERS,
+ optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
+ )
diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py
index 39e84e3..b17b90b 100644
--- a/erpnext/accounts/utils.py
+++ b/erpnext/accounts/utils.py
@@ -847,7 +847,7 @@
"payment_account": bank_account.name,
"currency": bank_account.account_currency,
"payment_channel": payment_channel
- }).insert(ignore_permissions=True)
+ }).insert(ignore_permissions=True, ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
# already exists, due to a reinstall?
diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py
index 6e87426..ea473fa 100644
--- a/erpnext/assets/doctype/asset/asset.py
+++ b/erpnext/assets/doctype/asset/asset.py
@@ -417,11 +417,12 @@
def validate_asset_finance_books(self, row):
if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount):
frappe.throw(_("Row {0}: Expected Value After Useful Life must be less than Gross Purchase Amount")
- .format(row.idx))
+ .format(row.idx), title=_("Invalid Schedule"))
if not row.depreciation_start_date:
if not self.available_for_use_date:
- frappe.throw(_("Row {0}: Depreciation Start Date is required").format(row.idx))
+ frappe.throw(_("Row {0}: Depreciation Start Date is required")
+ .format(row.idx), title=_("Invalid Schedule"))
row.depreciation_start_date = get_last_day(self.available_for_use_date)
if not self.is_existing_asset:
@@ -439,8 +440,9 @@
else:
self.number_of_depreciations_booked = 0
- if cint(self.number_of_depreciations_booked) > cint(row.total_number_of_depreciations):
- frappe.throw(_("Number of Depreciations Booked cannot be greater than Total Number of Depreciations"))
+ if flt(row.total_number_of_depreciations) <= cint(self.number_of_depreciations_booked):
+ frappe.throw(_("Row {0}: Total Number of Depreciations cannot be less than or equal to Number of Depreciations Booked")
+ .format(row.idx), title=_("Invalid Schedule"))
if row.depreciation_start_date and getdate(row.depreciation_start_date) < getdate(self.purchase_date):
frappe.throw(_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date")
diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py
index c08dc21..ffd1065 100644
--- a/erpnext/assets/doctype/asset/test_asset.py
+++ b/erpnext/assets/doctype/asset/test_asset.py
@@ -873,8 +873,9 @@
self.assertRaises(frappe.ValidationError, asset.save)
def test_number_of_depreciations(self):
- """Tests if an error is raised when number_of_depreciations_booked > total_number_of_depreciations."""
+ """Tests if an error is raised when number_of_depreciations_booked >= total_number_of_depreciations."""
+ # number_of_depreciations_booked > total_number_of_depreciations
asset = create_asset(
item_code = "Macbook Pro",
calculate_depreciation = 1,
@@ -889,6 +890,21 @@
self.assertRaises(frappe.ValidationError, asset.save)
+ # number_of_depreciations_booked = total_number_of_depreciations
+ asset_2 = create_asset(
+ item_code = "Macbook Pro",
+ calculate_depreciation = 1,
+ available_for_use_date = "2019-12-31",
+ total_number_of_depreciations = 5,
+ expected_value_after_useful_life = 10000,
+ depreciation_start_date = "2020-07-01",
+ opening_accumulated_depreciation = 10000,
+ number_of_depreciations_booked = 5,
+ do_not_save = 1
+ )
+
+ self.assertRaises(frappe.ValidationError, asset_2.save)
+
def test_depreciation_start_date_is_before_purchase_date(self):
asset = create_asset(
item_code = "Macbook Pro",
@@ -1264,7 +1280,7 @@
if not args.do_not_save:
try:
- asset.save()
+ asset.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
@@ -1305,7 +1321,7 @@
"is_grouped_asset": is_grouped_asset,
"asset_naming_series": naming_series
})
- item.insert()
+ item.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
return item
diff --git a/erpnext/assets/doctype/asset_category/test_asset_category.py b/erpnext/assets/doctype/asset_category/test_asset_category.py
index 3d19fa3..2f52248 100644
--- a/erpnext/assets/doctype/asset_category/test_asset_category.py
+++ b/erpnext/assets/doctype/asset_category/test_asset_category.py
@@ -23,7 +23,7 @@
})
try:
- asset_category.insert()
+ asset_category.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
diff --git a/erpnext/buying/doctype/purchase_order/test_purchase_order.py b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
index 9a63afc..645e97e 100644
--- a/erpnext/buying/doctype/purchase_order/test_purchase_order.py
+++ b/erpnext/buying/doctype/purchase_order/test_purchase_order.py
@@ -682,17 +682,18 @@
bin1 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
# Submit PO
po = create_purchase_order(item_code="_Test FG Item", is_subcontracted="Yes")
bin2 = frappe.db.get_value("Bin",
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
- fieldname=["reserved_qty_for_sub_contract", "projected_qty"], as_dict=1)
+ fieldname=["reserved_qty_for_sub_contract", "projected_qty", "modified"], as_dict=1)
self.assertEqual(bin2.reserved_qty_for_sub_contract, bin1.reserved_qty_for_sub_contract + 10)
self.assertEqual(bin2.projected_qty, bin1.projected_qty - 10)
+ self.assertNotEqual(bin1.modified, bin2.modified)
# Create stock transfer
rm_item = [{"item_code":"_Test FG Item","rm_item_code":"_Test Item","item_name":"_Test Item",
diff --git a/erpnext/buying/doctype/supplier/test_supplier.py b/erpnext/buying/doctype/supplier/test_supplier.py
index 13fe9df..0fb81b2 100644
--- a/erpnext/buying/doctype/supplier/test_supplier.py
+++ b/erpnext/buying/doctype/supplier/test_supplier.py
@@ -14,151 +14,150 @@
class TestSupplier(unittest.TestCase):
- def test_get_supplier_group_details(self):
- doc = frappe.new_doc("Supplier Group")
- doc.supplier_group_name = "_Testing Supplier Group"
- doc.payment_terms = "_Test Payment Term Template 3"
- doc.accounts = []
- test_account_details = {
- "company": "_Test Company",
- "account": "Creditors - _TC",
- }
- doc.append("accounts", test_account_details)
- doc.save()
- s_doc = frappe.new_doc("Supplier")
- s_doc.supplier_name = "Testing Supplier"
- s_doc.supplier_group = "_Testing Supplier Group"
- s_doc.payment_terms = ""
- s_doc.accounts = []
- s_doc.insert()
- s_doc.get_supplier_group_details()
- self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3")
- self.assertEqual(s_doc.accounts[0].company, "_Test Company")
- self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC")
- s_doc.delete()
- doc.delete()
+ def test_get_supplier_group_details(self):
+ doc = frappe.new_doc("Supplier Group")
+ doc.supplier_group_name = "_Testing Supplier Group"
+ doc.payment_terms = "_Test Payment Term Template 3"
+ doc.accounts = []
+ test_account_details = {
+ "company": "_Test Company",
+ "account": "Creditors - _TC",
+ }
+ doc.append("accounts", test_account_details)
+ doc.save()
+ s_doc = frappe.new_doc("Supplier")
+ s_doc.supplier_name = "Testing Supplier"
+ s_doc.supplier_group = "_Testing Supplier Group"
+ s_doc.payment_terms = ""
+ s_doc.accounts = []
+ s_doc.insert()
+ s_doc.get_supplier_group_details()
+ self.assertEqual(s_doc.payment_terms, "_Test Payment Term Template 3")
+ self.assertEqual(s_doc.accounts[0].company, "_Test Company")
+ self.assertEqual(s_doc.accounts[0].account, "Creditors - _TC")
+ s_doc.delete()
+ doc.delete()
- def test_supplier_default_payment_terms(self):
- # Payment Term based on Days after invoice date
- frappe.db.set_value(
- "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 3")
+ def test_supplier_default_payment_terms(self):
+ # Payment Term based on Days after invoice date
+ frappe.db.set_value(
+ "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 3")
- due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2016-02-21")
+ due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2016-02-21")
- due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2017-02-21")
+ due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2017-02-21")
- # Payment Term based on last day of month
- frappe.db.set_value(
- "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 1")
+ # Payment Term based on last day of month
+ frappe.db.set_value(
+ "Supplier", "_Test Supplier With Template 1", "payment_terms", "_Test Payment Term Template 1")
- due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2016-02-29")
+ due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2016-02-29")
- due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2017-02-28")
+ due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2017-02-28")
- frappe.db.set_value("Supplier", "_Test Supplier With Template 1", "payment_terms", "")
+ frappe.db.set_value("Supplier", "_Test Supplier With Template 1", "payment_terms", "")
- # Set credit limit for the supplier group instead of supplier and evaluate the due date
- frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 3")
+ # Set credit limit for the supplier group instead of supplier and evaluate the due date
+ frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 3")
- due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2016-02-21")
+ due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2016-02-21")
- # Payment terms for Supplier Group instead of supplier and evaluate the due date
- frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 1")
+ # Payment terms for Supplier Group instead of supplier and evaluate the due date
+ frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "_Test Payment Term Template 1")
- # Leap year
- due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2016-02-29")
- # # Non Leap year
- due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
- self.assertEqual(due_date, "2017-02-28")
+ # Leap year
+ due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2016-02-29")
+ # # Non Leap year
+ due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier With Template 1")
+ self.assertEqual(due_date, "2017-02-28")
- # Supplier with no default Payment Terms Template
- frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "")
- frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", "")
+ # Supplier with no default Payment Terms Template
+ frappe.db.set_value("Supplier Group", "_Test Supplier Group", "payment_terms", "")
+ frappe.db.set_value("Supplier", "_Test Supplier", "payment_terms", "")
- due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier")
- self.assertEqual(due_date, "2016-01-22")
- # # Non Leap year
- due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier")
- self.assertEqual(due_date, "2017-01-22")
+ due_date = get_due_date("2016-01-22", "Supplier", "_Test Supplier")
+ self.assertEqual(due_date, "2016-01-22")
+ # # Non Leap year
+ due_date = get_due_date("2017-01-22", "Supplier", "_Test Supplier")
+ self.assertEqual(due_date, "2017-01-22")
- def test_supplier_disabled(self):
- make_test_records("Item")
+ def test_supplier_disabled(self):
+ make_test_records("Item")
- frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
+ frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 1)
- from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
+ from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
- po = create_purchase_order(do_not_save=True)
+ po = create_purchase_order(do_not_save=True)
- self.assertRaises(PartyDisabled, po.save)
+ self.assertRaises(PartyDisabled, po.save)
- frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
+ frappe.db.set_value("Supplier", "_Test Supplier", "disabled", 0)
- po.save()
+ po.save()
- def test_supplier_country(self):
- # Test that country field exists in Supplier DocType
- supplier = frappe.get_doc('Supplier', '_Test Supplier with Country')
- self.assertTrue('country' in supplier.as_dict())
+ def test_supplier_country(self):
+ # Test that country field exists in Supplier DocType
+ supplier = frappe.get_doc('Supplier', '_Test Supplier with Country')
+ self.assertTrue('country' in supplier.as_dict())
- # Test if test supplier field record is 'Greece'
- self.assertEqual(supplier.country, "Greece")
+ # Test if test supplier field record is 'Greece'
+ self.assertEqual(supplier.country, "Greece")
- # Test update Supplier instance country value
- supplier = frappe.get_doc('Supplier', '_Test Supplier')
- supplier.country = 'Greece'
- supplier.save()
- self.assertEqual(supplier.country, "Greece")
+ # Test update Supplier instance country value
+ supplier = frappe.get_doc('Supplier', '_Test Supplier')
+ supplier.country = 'Greece'
+ supplier.save()
+ self.assertEqual(supplier.country, "Greece")
- def test_party_details_tax_category(self):
- from erpnext.accounts.party import get_party_details
+ def test_party_details_tax_category(self):
+ from erpnext.accounts.party import get_party_details
- frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
+ frappe.delete_doc_if_exists("Address", "_Test Address With Tax Category-Billing")
- # Tax Category without Address
- details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
- self.assertEqual(details.tax_category, "_Test Tax Category 1")
+ # Tax Category without Address
+ details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
+ self.assertEqual(details.tax_category, "_Test Tax Category 1")
- address = frappe.get_doc(dict(
- doctype='Address',
- address_title='_Test Address With Tax Category',
- tax_category='_Test Tax Category 2',
- address_type='Billing',
- address_line1='Station Road',
- city='_Test City',
- country='India',
- links=[dict(
- link_doctype='Supplier',
- link_name='_Test Supplier With Tax Category'
- )]
- )).insert()
+ address = frappe.get_doc(dict(
+ doctype='Address',
+ address_title='_Test Address With Tax Category',
+ tax_category='_Test Tax Category 2',
+ address_type='Billing',
+ address_line1='Station Road',
+ city='_Test City',
+ country='India',
+ links=[dict(
+ link_doctype='Supplier',
+ link_name='_Test Supplier With Tax Category'
+ )]
+ )).insert()
- # Tax Category with Address
- details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
- self.assertEqual(details.tax_category, "_Test Tax Category 2")
+ # Tax Category with Address
+ details = get_party_details("_Test Supplier With Tax Category", party_type="Supplier")
+ self.assertEqual(details.tax_category, "_Test Tax Category 2")
- # Rollback
- address.delete()
+ # Rollback
+ address.delete()
def create_supplier(**args):
- args = frappe._dict(args)
+ args = frappe._dict(args)
- try:
- doc = frappe.get_doc({
- "doctype": "Supplier",
- "supplier_name": args.supplier_name,
- "supplier_group": args.supplier_group or "Services",
- "supplier_type": args.supplier_type or "Company",
- "tax_withholding_category": args.tax_withholding_category
- }).insert()
+ if frappe.db.exists("Supplier", args.supplier_name):
+ return frappe.get_doc("Supplier", args.supplier_name)
- return doc
+ doc = frappe.get_doc({
+ "doctype": "Supplier",
+ "supplier_name": args.supplier_name,
+ "supplier_group": args.supplier_group or "Services",
+ "supplier_type": args.supplier_type or "Company",
+ "tax_withholding_category": args.tax_withholding_category
+ }).insert()
- except frappe.DuplicateEntryError:
- return frappe.get_doc("Supplier", args.supplier_name)
+ return doc
diff --git a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
index 49e3351..7908c35 100644
--- a/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
+++ b/erpnext/buying/doctype/supplier_scorecard/test_supplier_scorecard.py
@@ -49,7 +49,7 @@
"min_grade":0.0,"name":"Very Poor",
"prevent_rfqs":1,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":30.0,
"prevent_pos":1,
"warn_pos":0,
@@ -65,7 +65,7 @@
"name":"Poor",
"prevent_rfqs":1,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":50.0,
"prevent_pos":0,
"warn_pos":0,
@@ -81,7 +81,7 @@
"name":"Average",
"prevent_rfqs":0,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":80.0,
"prevent_pos":0,
"warn_pos":0,
@@ -97,7 +97,7 @@
"name":"Excellent",
"prevent_rfqs":0,
"notify_supplier":0,
- "doctype":"Supplier Scorecard Standing",
+ "doctype":"Supplier Scorecard Scoring Standing",
"max_grade":100.0,
"prevent_pos":0,
"warn_pos":0,
diff --git a/erpnext/controllers/accounts_controller.py b/erpnext/controllers/accounts_controller.py
index 994b903..a94af10 100644
--- a/erpnext/controllers/accounts_controller.py
+++ b/erpnext/controllers/accounts_controller.py
@@ -1566,13 +1566,12 @@
tax.rate = None
-def validate_account_head(tax, doc):
- company = frappe.get_cached_value('Account',
- tax.account_head, 'company')
+def validate_account_head(idx, account, company):
+ account_company = frappe.get_cached_value('Account', account, 'company')
- if company != doc.company:
+ if account_company != company:
frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}')
- .format(tax.idx, frappe.bold(tax.account_head), frappe.bold(doc.company)), title=_('Invalid Account'))
+ .format(idx, frappe.bold(account), frappe.bold(company)), title=_('Invalid Account'))
def validate_cost_center(tax, doc):
@@ -1955,7 +1954,8 @@
qty_dict["ordered_qty"] = get_ordered_qty(row.item_code, row.warehouse)
- update_bin_qty(row.item_code, row.warehouse, qty_dict)
+ if row.warehouse:
+ update_bin_qty(row.item_code, row.warehouse, qty_dict)
def validate_and_delete_children(parent, data):
deleted_children = []
diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py
index a181af7..b740476 100644
--- a/erpnext/controllers/buying_controller.py
+++ b/erpnext/controllers/buying_controller.py
@@ -249,6 +249,7 @@
"posting_time": self.get('posting_time'),
"qty": -1 * flt(d.get('stock_qty')),
"serial_no": d.get('serial_no'),
+ "batch_no": d.get("batch_no"),
"company": self.company,
"voucher_type": self.doctype,
"voucher_no": self.name,
@@ -278,7 +279,8 @@
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"qty": -1 * d.consumed_qty,
- "serial_no": d.serial_no
+ "serial_no": d.serial_no,
+ "batch_no": d.batch_no,
})
if rate > 0:
diff --git a/erpnext/controllers/employee_boarding_controller.py b/erpnext/controllers/employee_boarding_controller.py
index ae2c737..dd02ce1 100644
--- a/erpnext/controllers/employee_boarding_controller.py
+++ b/erpnext/controllers/employee_boarding_controller.py
@@ -104,11 +104,11 @@
def get_task_dates(self, activity, holiday_list):
start_date = end_date = None
- if activity.begin_on:
+ if activity.begin_on is not None:
start_date = add_days(self.boarding_begins_on, activity.begin_on)
start_date = self.update_if_holiday(start_date, holiday_list)
- if activity.duration:
+ if activity.duration is not None:
end_date = add_days(self.boarding_begins_on, activity.begin_on + activity.duration)
end_date = self.update_if_holiday(end_date, holiday_list)
diff --git a/erpnext/controllers/sales_and_purchase_return.py b/erpnext/controllers/sales_and_purchase_return.py
index df3c5f1..8c3aab4 100644
--- a/erpnext/controllers/sales_and_purchase_return.py
+++ b/erpnext/controllers/sales_and_purchase_return.py
@@ -420,6 +420,7 @@
"posting_time": sle.get('posting_time'),
"qty": sle.actual_qty,
"serial_no": sle.get('serial_no'),
+ "batch_no": sle.get("batch_no"),
"company": sle.company,
"voucher_type": sle.voucher_type,
"voucher_no": sle.voucher_no
diff --git a/erpnext/controllers/selling_controller.py b/erpnext/controllers/selling_controller.py
index 31b2209..e918cde 100644
--- a/erpnext/controllers/selling_controller.py
+++ b/erpnext/controllers/selling_controller.py
@@ -394,6 +394,7 @@
"posting_time": self.get('posting_time') or nowtime(),
"qty": qty if cint(self.get("is_return")) else (-1 * qty),
"serial_no": d.get('serial_no'),
+ "batch_no": d.get("batch_no"),
"company": self.company,
"voucher_type": self.doctype,
"voucher_no": self.name,
diff --git a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
index 8a8d425..0cfcf0e 100644
--- a/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
+++ b/erpnext/crm/doctype/opportunity_lost_reason/opportunity_lost_reason.json
@@ -3,7 +3,7 @@
"allow_events_in_timeline": 0,
"allow_guest_to_view": 0,
"allow_import": 0,
- "allow_rename": 0,
+ "allow_rename": 1,
"autoname": "field:lost_reason",
"beta": 0,
"creation": "2018-12-28 14:48:51.044975",
@@ -57,7 +57,7 @@
"issingle": 0,
"istable": 0,
"max_attachments": 0,
- "modified": "2018-12-28 14:49:43.336437",
+ "modified": "2022-02-16 10:49:43.336437",
"modified_by": "Administrator",
"module": "CRM",
"name": "Opportunity Lost Reason",
@@ -150,4 +150,4 @@
"track_changes": 0,
"track_seen": 0,
"track_views": 0
-}
\ No newline at end of file
+}
diff --git a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
index 05c874a..37b3672 100644
--- a/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
+++ b/erpnext/e_commerce/shopping_cart/test_shopping_cart.py
@@ -181,7 +181,7 @@
def create_tax_rule(self):
tax_rule = frappe.get_test_records("Tax Rule")[0]
try:
- frappe.get_doc(tax_rule).insert()
+ frappe.get_doc(tax_rule).insert(ignore_if_duplicate=True)
except (frappe.DuplicateEntryError, ConflictingTaxRule):
pass
diff --git a/erpnext/e_commerce/variant_selector/item_variants_cache.py b/erpnext/e_commerce/variant_selector/item_variants_cache.py
index bb6b3ef..3107c01 100644
--- a/erpnext/e_commerce/variant_selector/item_variants_cache.py
+++ b/erpnext/e_commerce/variant_selector/item_variants_cache.py
@@ -66,26 +66,24 @@
)
]
- # join with Website Item
- item_variants_data = frappe.get_all(
- 'Item Variant Attribute',
- {'variant_of': parent_item_code},
- ['parent', 'attribute', 'attribute_value'],
- order_by='name',
- as_list=1
+ # Get Variants and tehir Attributes that are not disabled
+ iva = frappe.qb.DocType("Item Variant Attribute")
+ item = frappe.qb.DocType("Item")
+ query = (
+ frappe.qb.from_(iva)
+ .join(item).on(item.name == iva.parent)
+ .select(
+ iva.parent, iva.attribute, iva.attribute_value
+ ).where(
+ (iva.variant_of == parent_item_code)
+ & (item.disabled == 0)
+ ).orderby(iva.name)
)
-
- disabled_items = set(
- [i.name for i in frappe.db.get_all('Item', {'disabled': 1})]
- )
+ item_variants_data = query.run()
attribute_value_item_map = frappe._dict()
item_attribute_value_map = frappe._dict()
- # dont consider variants that are disabled
- # pull all other variants
- item_variants_data = [r for r in item_variants_data if r[0] not in disabled_items]
-
for row in item_variants_data:
item_code, attribute, attribute_value = row
# (attr, value) => [item1, item2]
@@ -124,4 +122,7 @@
def enqueue_build_cache(item_code):
if frappe.cache().hget('item_cache_build_in_progress', item_code):
return
- frappe.enqueue(build_cache, item_code=item_code, queue='long')
+ frappe.enqueue(
+ "erpnext.e_commerce.variant_selector.item_variants_cache.build_cache",
+ item_code=item_code, queue='long'
+ )
diff --git a/erpnext/e_commerce/variant_selector/test_variant_selector.py b/erpnext/e_commerce/variant_selector/test_variant_selector.py
index b83961e..4d907c6 100644
--- a/erpnext/e_commerce/variant_selector/test_variant_selector.py
+++ b/erpnext/e_commerce/variant_selector/test_variant_selector.py
@@ -104,6 +104,8 @@
})
make_web_item_price(item_code="Test-Tshirt-Temp-S-R", price_list_rate=100)
+
+ frappe.local.shopping_cart_settings = None # clear cached settings values
next_values = get_next_attribute_and_values(
"Test-Tshirt-Temp",
selected_attributes={"Test Size": "Small", "Test Colour": "Red"}
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py
deleted file mode 100644
index e69de29..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/__init__.py
+++ /dev/null
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
deleted file mode 100644
index 29bc36f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_methods.py
+++ /dev/null
@@ -1,524 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-
-import csv
-import math
-import time
-from io import StringIO
-
-import dateutil
-import frappe
-from frappe import _
-
-import erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_api as mws
-
-
-#Get and Create Products
-def get_products_details():
- products = get_products_instance()
- reports = get_reports_instance()
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- market_place_list = return_as_list(mws_settings.market_place_id)
-
- for marketplace in market_place_list:
- report_id = request_and_fetch_report_id("_GET_FLAT_FILE_OPEN_LISTINGS_DATA_", None, None, market_place_list)
-
- if report_id:
- listings_response = reports.get_report(report_id=report_id)
-
- #Get ASIN Codes
- string_io = StringIO(frappe.safe_decode(listings_response.original))
- csv_rows = list(csv.reader(string_io, delimiter='\t'))
- asin_list = list(set([row[1] for row in csv_rows[1:]]))
- #break into chunks of 10
- asin_chunked_list = list(chunks(asin_list, 10))
-
- #Map ASIN Codes to SKUs
- sku_asin = [{"asin":row[1],"sku":row[0]} for row in csv_rows[1:]]
-
- #Fetch Products List from ASIN
- for asin_list in asin_chunked_list:
- products_response = call_mws_method(products.get_matching_product,marketplaceid=marketplace,
- asins=asin_list)
-
- matching_products_list = products_response.parsed
- for product in matching_products_list:
- skus = [row["sku"] for row in sku_asin if row["asin"]==product.ASIN]
- for sku in skus:
- create_item_code(product, sku)
-
-def get_products_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- products = mws.Products(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region = mws_settings.region,
- domain = mws_settings.domain
- )
-
- return products
-
-def get_reports_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- reports = mws.Reports(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region = mws_settings.region,
- domain = mws_settings.domain
- )
-
- return reports
-
-#returns list as expected by amazon API
-def return_as_list(input_value):
- if isinstance(input_value, list):
- return input_value
- else:
- return [input_value]
-
-#function to chunk product data
-def chunks(l, n):
- for i in range(0, len(l), n):
- yield l[i:i+n]
-
-def request_and_fetch_report_id(report_type, start_date=None, end_date=None, marketplaceids=None):
- reports = get_reports_instance()
- report_response = reports.request_report(report_type=report_type,
- start_date=start_date,
- end_date=end_date,
- marketplaceids=marketplaceids)
-
- report_request_id = report_response.parsed["ReportRequestInfo"]["ReportRequestId"]["value"]
- generated_report_id = None
- #poll to get generated report
- for x in range(1,10):
- report_request_list_response = reports.get_report_request_list(requestids=[report_request_id])
- report_status = report_request_list_response.parsed["ReportRequestInfo"]["ReportProcessingStatus"]["value"]
-
- if report_status == "_SUBMITTED_" or report_status == "_IN_PROGRESS_":
- #add time delay to wait for amazon to generate report
- time.sleep(15)
- continue
- elif report_status == "_CANCELLED_":
- break
- elif report_status == "_DONE_NO_DATA_":
- break
- elif report_status == "_DONE_":
- generated_report_id = report_request_list_response.parsed["ReportRequestInfo"]["GeneratedReportId"]["value"]
- break
- return generated_report_id
-
-def call_mws_method(mws_method, *args, **kwargs):
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- max_retries = mws_settings.max_retry_limit
-
- for x in range(0, max_retries):
- try:
- response = mws_method(*args, **kwargs)
- return response
- except Exception as e:
- delay = math.pow(4, x) * 125
- frappe.log_error(message=e, title=f'Method "{mws_method.__name__}" failed')
- time.sleep(delay)
- continue
-
- mws_settings.enable_sync = 0
- mws_settings.save()
-
- frappe.throw(_("Sync has been temporarily disabled because maximum retries have been exceeded"))
-
-def create_item_code(amazon_item_json, sku):
- if frappe.db.get_value("Item", sku):
- return
-
- item = frappe.new_doc("Item")
-
- new_manufacturer = create_manufacturer(amazon_item_json)
- new_brand = create_brand(amazon_item_json)
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- item.item_code = sku
- item.amazon_item_code = amazon_item_json.ASIN
- item.item_group = mws_settings.item_group
- item.description = amazon_item_json.Product.AttributeSets.ItemAttributes.Title
- item.brand = new_brand
- item.manufacturer = new_manufacturer
-
- item.image = amazon_item_json.Product.AttributeSets.ItemAttributes.SmallImage.URL
-
- temp_item_group = amazon_item_json.Product.AttributeSets.ItemAttributes.ProductGroup
-
- item_group = frappe.db.get_value("Item Group",filters={"item_group_name": temp_item_group})
-
- if not item_group:
- igroup = frappe.new_doc("Item Group")
- igroup.item_group_name = temp_item_group
- igroup.parent_item_group = mws_settings.item_group
- igroup.insert()
-
- item.append("item_defaults", {'company':mws_settings.company})
-
- item.insert(ignore_permissions=True)
- create_item_price(amazon_item_json, item.item_code)
-
- return item.name
-
-def create_manufacturer(amazon_item_json):
- if not amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer:
- return None
-
- existing_manufacturer = frappe.db.get_value("Manufacturer",
- filters={"short_name":amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer})
-
- if not existing_manufacturer:
- manufacturer = frappe.new_doc("Manufacturer")
- manufacturer.short_name = amazon_item_json.Product.AttributeSets.ItemAttributes.Manufacturer
- manufacturer.insert()
- return manufacturer.short_name
- else:
- return existing_manufacturer
-
-def create_brand(amazon_item_json):
- if not amazon_item_json.Product.AttributeSets.ItemAttributes.Brand:
- return None
-
- existing_brand = frappe.db.get_value("Brand",
- filters={"brand":amazon_item_json.Product.AttributeSets.ItemAttributes.Brand})
- if not existing_brand:
- brand = frappe.new_doc("Brand")
- brand.brand = amazon_item_json.Product.AttributeSets.ItemAttributes.Brand
- brand.insert()
- return brand.brand
- else:
- return existing_brand
-
-def create_item_price(amazon_item_json, item_code):
- item_price = frappe.new_doc("Item Price")
- item_price.price_list = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "price_list")
- if not("ListPrice" in amazon_item_json.Product.AttributeSets.ItemAttributes):
- item_price.price_list_rate = 0
- else:
- item_price.price_list_rate = amazon_item_json.Product.AttributeSets.ItemAttributes.ListPrice.Amount
-
- item_price.item_code = item_code
- item_price.insert()
-
-#Get and create Orders
-def get_orders(after_date):
- try:
- orders = get_orders_instance()
- statuses = ["PartiallyShipped", "Unshipped", "Shipped", "Canceled"]
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- market_place_list = return_as_list(mws_settings.market_place_id)
-
- orders_response = call_mws_method(orders.list_orders, marketplaceids=market_place_list,
- fulfillment_channels=["MFN", "AFN"],
- lastupdatedafter=after_date,
- orderstatus=statuses,
- max_results='50')
-
- while True:
- orders_list = []
-
- if "Order" in orders_response.parsed.Orders:
- orders_list = return_as_list(orders_response.parsed.Orders.Order)
-
- if len(orders_list) == 0:
- break
-
- for order in orders_list:
- create_sales_order(order, after_date)
-
- if not "NextToken" in orders_response.parsed:
- break
-
- next_token = orders_response.parsed.NextToken
- orders_response = call_mws_method(orders.list_orders_by_next_token, next_token)
-
- except Exception as e:
- frappe.log_error(title="get_orders", message=e)
-
-def get_orders_instance():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- orders = mws.Orders(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region= mws_settings.region,
- domain= mws_settings.domain,
- version="2013-09-01"
- )
-
- return orders
-
-def create_sales_order(order_json,after_date):
- customer_name = create_customer(order_json)
- create_address(order_json, customer_name)
-
- market_place_order_id = order_json.AmazonOrderId
-
- so = frappe.db.get_value("Sales Order",
- filters={"amazon_order_id": market_place_order_id},
- fieldname="name")
-
- taxes_and_charges = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "taxes_charges")
-
- if so:
- return
-
- if not so:
- items = get_order_items(market_place_order_id)
- delivery_date = dateutil.parser.parse(order_json.LatestShipDate).strftime("%Y-%m-%d")
- transaction_date = dateutil.parser.parse(order_json.PurchaseDate).strftime("%Y-%m-%d")
-
- so = frappe.get_doc({
- "doctype": "Sales Order",
- "naming_series": "SO-",
- "amazon_order_id": market_place_order_id,
- "marketplace_id": order_json.MarketplaceId,
- "customer": customer_name,
- "delivery_date": delivery_date,
- "transaction_date": transaction_date,
- "items": items,
- "company": frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "company")
- })
-
- try:
- if taxes_and_charges:
- charges_and_fees = get_charges_and_fees(market_place_order_id)
- for charge in charges_and_fees.get("charges"):
- so.append('taxes', charge)
-
- for fee in charges_and_fees.get("fees"):
- so.append('taxes', fee)
-
- so.insert(ignore_permissions=True)
- so.submit()
-
- except Exception as e:
- import traceback
- frappe.log_error(message=traceback.format_exc(), title="Create Sales Order")
-
-def create_customer(order_json):
- order_customer_name = ""
-
- if not("BuyerName" in order_json):
- order_customer_name = "Buyer - " + order_json.AmazonOrderId
- else:
- order_customer_name = order_json.BuyerName
-
- existing_customer_name = frappe.db.get_value("Customer",
- filters={"name": order_customer_name}, fieldname="name")
-
- if existing_customer_name:
- filters = [
- ["Dynamic Link", "link_doctype", "=", "Customer"],
- ["Dynamic Link", "link_name", "=", existing_customer_name],
- ["Dynamic Link", "parenttype", "=", "Contact"]
- ]
-
- existing_contacts = frappe.get_list("Contact", filters)
-
- if existing_contacts:
- pass
- else:
- new_contact = frappe.new_doc("Contact")
- new_contact.first_name = order_customer_name
- new_contact.append('links', {
- "link_doctype": "Customer",
- "link_name": existing_customer_name
- })
- new_contact.insert()
-
- return existing_customer_name
- else:
- mws_customer_settings = frappe.get_doc("Amazon MWS Settings")
- new_customer = frappe.new_doc("Customer")
- new_customer.customer_name = order_customer_name
- new_customer.customer_group = mws_customer_settings.customer_group
- new_customer.territory = mws_customer_settings.territory
- new_customer.customer_type = mws_customer_settings.customer_type
- new_customer.save()
-
- new_contact = frappe.new_doc("Contact")
- new_contact.first_name = order_customer_name
- new_contact.append('links', {
- "link_doctype": "Customer",
- "link_name": new_customer.name
- })
-
- new_contact.insert()
-
- return new_customer.name
-
-def create_address(amazon_order_item_json, customer_name):
-
- filters = [
- ["Dynamic Link", "link_doctype", "=", "Customer"],
- ["Dynamic Link", "link_name", "=", customer_name],
- ["Dynamic Link", "parenttype", "=", "Address"]
- ]
-
- existing_address = frappe.get_list("Address", filters)
-
- if not("ShippingAddress" in amazon_order_item_json):
- return None
- else:
- make_address = frappe.new_doc("Address")
-
- if "AddressLine1" in amazon_order_item_json.ShippingAddress:
- make_address.address_line1 = amazon_order_item_json.ShippingAddress.AddressLine1
- else:
- make_address.address_line1 = "Not Provided"
-
- if "City" in amazon_order_item_json.ShippingAddress:
- make_address.city = amazon_order_item_json.ShippingAddress.City
- else:
- make_address.city = "Not Provided"
-
- if "StateOrRegion" in amazon_order_item_json.ShippingAddress:
- make_address.state = amazon_order_item_json.ShippingAddress.StateOrRegion
-
- if "PostalCode" in amazon_order_item_json.ShippingAddress:
- make_address.pincode = amazon_order_item_json.ShippingAddress.PostalCode
-
- for address in existing_address:
- address_doc = frappe.get_doc("Address", address["name"])
- if (address_doc.address_line1 == make_address.address_line1 and
- address_doc.pincode == make_address.pincode):
- return address
-
- make_address.append("links", {
- "link_doctype": "Customer",
- "link_name": customer_name
- })
- make_address.address_type = "Shipping"
- make_address.insert()
-
-def get_order_items(market_place_order_id):
- mws_orders = get_orders_instance()
-
- order_items_response = call_mws_method(mws_orders.list_order_items, amazon_order_id=market_place_order_id)
- final_order_items = []
-
- order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem)
-
- warehouse = frappe.db.get_value("Amazon MWS Settings", "Amazon MWS Settings", "warehouse")
-
- while True:
- for order_item in order_items_list:
-
- if not "ItemPrice" in order_item:
- price = 0
- else:
- price = order_item.ItemPrice.Amount
-
- final_order_items.append({
- "item_code": get_item_code(order_item),
- "item_name": order_item.SellerSKU,
- "description": order_item.Title,
- "rate": price,
- "qty": order_item.QuantityOrdered,
- "stock_uom": "Nos",
- "warehouse": warehouse,
- "conversion_factor": "1.0"
- })
-
- if not "NextToken" in order_items_response.parsed:
- break
-
- next_token = order_items_response.parsed.NextToken
-
- order_items_response = call_mws_method(mws_orders.list_order_items_by_next_token, next_token)
- order_items_list = return_as_list(order_items_response.parsed.OrderItems.OrderItem)
-
- return final_order_items
-
-def get_item_code(order_item):
- sku = order_item.SellerSKU
- item_code = frappe.db.get_value("Item", {"item_code": sku}, "item_code")
- if item_code:
- return item_code
-
-def get_charges_and_fees(market_place_order_id):
- finances = get_finances_instance()
-
- charges_fees = {"charges":[], "fees":[]}
-
- response = call_mws_method(finances.list_financial_events, amazon_order_id=market_place_order_id)
-
- shipment_event_list = return_as_list(response.parsed.FinancialEvents.ShipmentEventList)
-
- for shipment_event in shipment_event_list:
- if shipment_event:
- shipment_item_list = return_as_list(shipment_event.ShipmentEvent.ShipmentItemList.ShipmentItem)
-
- for shipment_item in shipment_item_list:
- charges, fees = [], []
-
- if 'ItemChargeList' in shipment_item.keys():
- charges = return_as_list(shipment_item.ItemChargeList.ChargeComponent)
-
- if 'ItemFeeList' in shipment_item.keys():
- fees = return_as_list(shipment_item.ItemFeeList.FeeComponent)
-
- for charge in charges:
- if(charge.ChargeType != "Principal") and float(charge.ChargeAmount.CurrencyAmount) != 0:
- charge_account = get_account(charge.ChargeType)
- charges_fees.get("charges").append({
- "charge_type":"Actual",
- "account_head": charge_account,
- "tax_amount": charge.ChargeAmount.CurrencyAmount,
- "description": charge.ChargeType + " for " + shipment_item.SellerSKU
- })
-
- for fee in fees:
- if float(fee.FeeAmount.CurrencyAmount) != 0:
- fee_account = get_account(fee.FeeType)
- charges_fees.get("fees").append({
- "charge_type":"Actual",
- "account_head": fee_account,
- "tax_amount": fee.FeeAmount.CurrencyAmount,
- "description": fee.FeeType + " for " + shipment_item.SellerSKU
- })
-
- return charges_fees
-
-def get_finances_instance():
-
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- finances = mws.Finances(
- account_id = mws_settings.seller_id,
- access_key = mws_settings.aws_access_key_id,
- secret_key = mws_settings.secret_key,
- region= mws_settings.region,
- domain= mws_settings.domain,
- version="2015-05-01"
- )
-
- return finances
-
-def get_account(name):
- existing_account = frappe.db.get_value("Account", {"account_name": "Amazon {0}".format(name)})
- account_name = existing_account
- mws_settings = frappe.get_doc("Amazon MWS Settings")
-
- if not existing_account:
- try:
- new_account = frappe.new_doc("Account")
- new_account.account_name = "Amazon {0}".format(name)
- new_account.company = mws_settings.company
- new_account.parent_account = mws_settings.market_place_account_group
- new_account.insert(ignore_permissions=True)
- account_name = new_account.name
- except Exception as e:
- frappe.log_error(message=e, title="Create Account")
-
- return account_name
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py
deleted file mode 100755
index 4caf137..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_api.py
+++ /dev/null
@@ -1,651 +0,0 @@
-#!/usr/bin/env python
-#
-# Basic interface to Amazon MWS
-# Based on http://code.google.com/p/amazon-mws-python
-# Extended to include finances object
-
-import base64
-import hashlib
-import hmac
-import re
-from urllib.parse import quote
-
-from erpnext.erpnext_integrations.doctype.amazon_mws_settings import xml_utils
-
-try:
- from xml.etree.ElementTree import ParseError as XMLError
-except ImportError:
- from xml.parsers.expat import ExpatError as XMLError
-
-from time import gmtime, strftime
-
-from requests import request
-from requests.exceptions import HTTPError
-
-__all__ = [
- 'Feeds',
- 'Inventory',
- 'MWSError',
- 'Reports',
- 'Orders',
- 'Products',
- 'Recommendations',
- 'Sellers',
- 'Finances'
-]
-
-# See https://images-na.ssl-images-amazon.com/images/G/01/mwsportal/doc/en_US/bde/MWSDeveloperGuide._V357736853_.pdf page 8
-# for a list of the end points and marketplace IDs
-
-MARKETPLACES = {
- "CA": "https://mws.amazonservices.ca", #A2EUQ1WTGCTBG2
- "US": "https://mws.amazonservices.com", #ATVPDKIKX0DER",
- "DE": "https://mws-eu.amazonservices.com", #A1PA6795UKMFR9
- "ES": "https://mws-eu.amazonservices.com", #A1RKKUPIHCS9HS
- "FR": "https://mws-eu.amazonservices.com", #A13V1IB3VIYZZH
- "IN": "https://mws.amazonservices.in", #A21TJRUUN4KGV
- "IT": "https://mws-eu.amazonservices.com", #APJ6JRA9NG5V4
- "UK": "https://mws-eu.amazonservices.com", #A1F83G8C2ARO7P
- "JP": "https://mws.amazonservices.jp", #A1VC38T7YXB528
- "CN": "https://mws.amazonservices.com.cn", #AAHKV2X7AFYLW
- "AE": " https://mws.amazonservices.ae", #A2VIGQ35RCS4UG
- "MX": "https://mws.amazonservices.com.mx", #A1AM78C64UM0Y8
- "BR": "https://mws.amazonservices.com", #A2Q3Y263D00KWC
-}
-
-
-class MWSError(Exception):
- """
- Main MWS Exception class
- """
- # Allows quick access to the response object.
- # Do not rely on this attribute, always check if its not None.
- response = None
-
-def calc_md5(string):
- """Calculates the MD5 encryption for the given string
- """
- md = hashlib.md5()
- md.update(string)
- return base64.encodebytes(md.digest()).decode().strip()
-
-
-
-def remove_empty(d):
- """
- Helper function that removes all keys from a dictionary (d),
- that have an empty value.
- """
- for key in list(d):
- if not d[key]:
- del d[key]
- return d
-
-def remove_namespace(xml):
- xml = xml.decode('utf-8')
- regex = re.compile(' xmlns(:ns2)?="[^"]+"|(ns2:)|(xml:)')
- return regex.sub('', xml)
-
-class DictWrapper(object):
- def __init__(self, xml, rootkey=None):
- self.original = xml
- self._rootkey = rootkey
- self._mydict = xml_utils.xml2dict().fromstring(remove_namespace(xml))
- self._response_dict = self._mydict.get(list(self._mydict)[0], self._mydict)
-
- @property
- def parsed(self):
- if self._rootkey:
- return self._response_dict.get(self._rootkey)
- else:
- return self._response_dict
-
-class DataWrapper(object):
- """
- Text wrapper in charge of validating the hash sent by Amazon.
- """
- def __init__(self, data, header):
- self.original = data
- if 'content-md5' in header:
- hash_ = calc_md5(self.original)
- if header['content-md5'] != hash_:
- raise MWSError("Wrong Contentlength, maybe amazon error...")
-
- @property
- def parsed(self):
- return self.original
-
-class MWS(object):
- """ Base Amazon API class """
-
- # This is used to post/get to the different uris used by amazon per api
- # ie. /Orders/2011-01-01
- # All subclasses must define their own URI only if needed
- URI = "/"
-
- # The API version varies in most amazon APIs
- VERSION = "2009-01-01"
-
- # There seem to be some xml namespace issues. therefore every api subclass
- # is recommended to define its namespace, so that it can be referenced
- # like so AmazonAPISubclass.NS.
- # For more information see http://stackoverflow.com/a/8719461/389453
- NS = ''
-
- # Some APIs are available only to either a "Merchant" or "Seller"
- # the type of account needs to be sent in every call to the amazon MWS.
- # This constant defines the exact name of the parameter Amazon expects
- # for the specific API being used.
- # All subclasses need to define this if they require another account type
- # like "Merchant" in which case you define it like so.
- # ACCOUNT_TYPE = "Merchant"
- # Which is the name of the parameter for that specific account type.
- ACCOUNT_TYPE = "SellerId"
-
- def __init__(self, access_key, secret_key, account_id, region='US', domain='', uri="", version=""):
- self.access_key = access_key
- self.secret_key = secret_key
- self.account_id = account_id
- self.version = version or self.VERSION
- self.uri = uri or self.URI
-
- if domain:
- self.domain = domain
- elif region in MARKETPLACES:
- self.domain = MARKETPLACES[region]
- else:
- error_msg = "Incorrect region supplied ('%(region)s'). Must be one of the following: %(marketplaces)s" % {
- "marketplaces" : ', '.join(MARKETPLACES.keys()),
- "region" : region,
- }
- raise MWSError(error_msg)
-
- def make_request(self, extra_data, method="GET", **kwargs):
- """Make request to Amazon MWS API with these parameters
- """
-
- # Remove all keys with an empty value because
- # Amazon's MWS does not allow such a thing.
- extra_data = remove_empty(extra_data)
-
- params = {
- 'AWSAccessKeyId': self.access_key,
- self.ACCOUNT_TYPE: self.account_id,
- 'SignatureVersion': '2',
- 'Timestamp': self.get_timestamp(),
- 'Version': self.version,
- 'SignatureMethod': 'HmacSHA256',
- }
- params.update(extra_data)
- request_description = '&'.join(['%s=%s' % (k, quote(params[k], safe='-_.~')) for k in sorted(params)])
- signature = self.calc_signature(method, request_description)
- url = '%s%s?%s&Signature=%s' % (self.domain, self.uri, request_description, quote(signature))
- headers = {'User-Agent': 'python-amazon-mws/0.0.1 (Language=Python)'}
- headers.update(kwargs.get('extra_headers', {}))
-
- try:
- # Some might wonder as to why i don't pass the params dict as the params argument to request.
- # My answer is, here i have to get the url parsed string of params in order to sign it, so
- # if i pass the params dict as params to request, request will repeat that step because it will need
- # to convert the dict to a url parsed string, so why do it twice if i can just pass the full url :).
- response = request(method, url, data=kwargs.get('body', ''), headers=headers)
- response.raise_for_status()
- # When retrieving data from the response object,
- # be aware that response.content returns the content in bytes while response.text calls
- # response.content and converts it to unicode.
- data = response.content
-
- # I do not check the headers to decide which content structure to server simply because sometimes
- # Amazon's MWS API returns XML error responses with "text/plain" as the Content-Type.
- try:
- parsed_response = DictWrapper(data, extra_data.get("Action") + "Result")
- except XMLError:
- parsed_response = DataWrapper(data, response.headers)
-
- except HTTPError as e:
- error = MWSError(str(e))
- error.response = e.response
- raise error
-
- # Store the response object in the parsed_response for quick access
- parsed_response.response = response
- return parsed_response
-
- def get_service_status(self):
- """
- Returns a GREEN, GREEN_I, YELLOW or RED status.
- Depending on the status/availability of the API its being called from.
- """
-
- return self.make_request(extra_data=dict(Action='GetServiceStatus'))
-
- def calc_signature(self, method, request_description):
- """Calculate MWS signature to interface with Amazon
- """
- sig_data = method + '\n' + self.domain.replace('https://', '').lower() + '\n' + self.uri + '\n' + request_description
- sig_data = sig_data.encode('utf-8')
- secret_key = self.secret_key.encode('utf-8')
- digest = hmac.new(secret_key, sig_data, hashlib.sha256).digest()
- return base64.b64encode(digest).decode('utf-8')
-
- def get_timestamp(self):
- """
- Returns the current timestamp in proper format.
- """
- return strftime("%Y-%m-%dT%H:%M:%SZ", gmtime())
-
- def enumerate_param(self, param, values):
- """
- Builds a dictionary of an enumerated parameter.
- Takes any iterable and returns a dictionary.
- ie.
- enumerate_param('MarketplaceIdList.Id', (123, 345, 4343))
- returns
- {
- MarketplaceIdList.Id.1: 123,
- MarketplaceIdList.Id.2: 345,
- MarketplaceIdList.Id.3: 4343
- }
- """
- params = {}
- if values is not None:
- if not param.endswith('.'):
- param = "%s." % param
- for num, value in enumerate(values):
- params['%s%d' % (param, (num + 1))] = value
- return params
-
-
-class Feeds(MWS):
- """ Amazon MWS Feeds API """
-
- ACCOUNT_TYPE = "Merchant"
-
- def submit_feed(self, feed, feed_type, marketplaceids=None,
- content_type="text/xml", purge='false'):
- """
- Uploads a feed ( xml or .tsv ) to the seller's inventory.
- Can be used for creating/updating products on Amazon.
- """
- data = dict(Action='SubmitFeed',
- FeedType=feed_type,
- PurgeAndReplace=purge)
- data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids))
- md = calc_md5(feed)
- return self.make_request(data, method="POST", body=feed,
- extra_headers={'Content-MD5': md, 'Content-Type': content_type})
-
- def get_feed_submission_list(self, feedids=None, max_count=None, feedtypes=None,
- processingstatuses=None, fromdate=None, todate=None):
- """
- Returns a list of all feed submissions submitted in the previous 90 days.
- That match the query parameters.
- """
-
- data = dict(Action='GetFeedSubmissionList',
- MaxCount=max_count,
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate,)
- data.update(self.enumerate_param('FeedSubmissionIdList.Id', feedids))
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_submission_list_by_next_token(self, token):
- data = dict(Action='GetFeedSubmissionListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_feed_submission_count(self, feedtypes=None, processingstatuses=None, fromdate=None, todate=None):
- data = dict(Action='GetFeedSubmissionCount',
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate)
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- data.update(self.enumerate_param('FeedProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def cancel_feed_submissions(self, feedids=None, feedtypes=None, fromdate=None, todate=None):
- data = dict(Action='CancelFeedSubmissions',
- SubmittedFromDate=fromdate,
- SubmittedToDate=todate)
- data.update(self.enumerate_param('FeedSubmissionIdList.Id.', feedids))
- data.update(self.enumerate_param('FeedTypeList.Type.', feedtypes))
- return self.make_request(data)
-
- def get_feed_submission_result(self, feedid):
- data = dict(Action='GetFeedSubmissionResult', FeedSubmissionId=feedid)
- return self.make_request(data)
-
-class Reports(MWS):
- """ Amazon MWS Reports API """
-
- ACCOUNT_TYPE = "Merchant"
-
- ## REPORTS ###
-
- def get_report(self, report_id):
- data = dict(Action='GetReport', ReportId=report_id)
- return self.make_request(data)
-
- def get_report_count(self, report_types=(), acknowledged=None, fromdate=None, todate=None):
- data = dict(Action='GetReportCount',
- Acknowledged=acknowledged,
- AvailableFromDate=fromdate,
- AvailableToDate=todate)
- data.update(self.enumerate_param('ReportTypeList.Type.', report_types))
- return self.make_request(data)
-
- def get_report_list(self, requestids=(), max_count=None, types=(), acknowledged=None,
- fromdate=None, todate=None):
- data = dict(Action='GetReportList',
- Acknowledged=acknowledged,
- AvailableFromDate=fromdate,
- AvailableToDate=todate,
- MaxCount=max_count)
- data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids))
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
- def get_report_list_by_next_token(self, token):
- data = dict(Action='GetReportListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_report_request_count(self, report_types=(), processingstatuses=(), fromdate=None, todate=None):
- data = dict(Action='GetReportRequestCount',
- RequestedFromDate=fromdate,
- RequestedToDate=todate)
- data.update(self.enumerate_param('ReportTypeList.Type.', report_types))
- data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_report_request_list(self, requestids=(), types=(), processingstatuses=(),
- max_count=None, fromdate=None, todate=None):
- data = dict(Action='GetReportRequestList',
- MaxCount=max_count,
- RequestedFromDate=fromdate,
- RequestedToDate=todate)
- data.update(self.enumerate_param('ReportRequestIdList.Id.', requestids))
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- data.update(self.enumerate_param('ReportProcessingStatusList.Status.', processingstatuses))
- return self.make_request(data)
-
- def get_report_request_list_by_next_token(self, token):
- data = dict(Action='GetReportRequestListByNextToken', NextToken=token)
- return self.make_request(data)
-
- def request_report(self, report_type, start_date=None, end_date=None, marketplaceids=()):
- data = dict(Action='RequestReport',
- ReportType=report_type,
- StartDate=start_date,
- EndDate=end_date)
- data.update(self.enumerate_param('MarketplaceIdList.Id.', marketplaceids))
- return self.make_request(data)
-
- ### ReportSchedule ###
-
- def get_report_schedule_list(self, types=()):
- data = dict(Action='GetReportScheduleList')
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
- def get_report_schedule_count(self, types=()):
- data = dict(Action='GetReportScheduleCount')
- data.update(self.enumerate_param('ReportTypeList.Type.', types))
- return self.make_request(data)
-
-
-class Orders(MWS):
- """ Amazon Orders API """
-
- URI = "/Orders/2013-09-01"
- VERSION = "2013-09-01"
- NS = '{https://mws.amazonservices.com/Orders/2011-01-01}'
-
- def list_orders(self, marketplaceids, created_after=None, created_before=None, lastupdatedafter=None,
- lastupdatedbefore=None, orderstatus=(), fulfillment_channels=(),
- payment_methods=(), buyer_email=None, seller_orderid=None, max_results='100'):
-
- data = dict(Action='ListOrders',
- CreatedAfter=created_after,
- CreatedBefore=created_before,
- LastUpdatedAfter=lastupdatedafter,
- LastUpdatedBefore=lastupdatedbefore,
- BuyerEmail=buyer_email,
- SellerOrderId=seller_orderid,
- MaxResultsPerPage=max_results,
- )
- data.update(self.enumerate_param('OrderStatus.Status.', orderstatus))
- data.update(self.enumerate_param('MarketplaceId.Id.', marketplaceids))
- data.update(self.enumerate_param('FulfillmentChannel.Channel.', fulfillment_channels))
- data.update(self.enumerate_param('PaymentMethod.Method.', payment_methods))
- return self.make_request(data)
-
- def list_orders_by_next_token(self, token):
- data = dict(Action='ListOrdersByNextToken', NextToken=token)
- return self.make_request(data)
-
- def get_order(self, amazon_order_ids):
- data = dict(Action='GetOrder')
- data.update(self.enumerate_param('AmazonOrderId.Id.', amazon_order_ids))
- return self.make_request(data)
-
- def list_order_items(self, amazon_order_id):
- data = dict(Action='ListOrderItems', AmazonOrderId=amazon_order_id)
- return self.make_request(data)
-
- def list_order_items_by_next_token(self, token):
- data = dict(Action='ListOrderItemsByNextToken', NextToken=token)
- return self.make_request(data)
-
-
-class Products(MWS):
- """ Amazon MWS Products API """
-
- URI = '/Products/2011-10-01'
- VERSION = '2011-10-01'
- NS = '{http://mws.amazonservices.com/schema/Products/2011-10-01}'
-
- def list_matching_products(self, marketplaceid, query, contextid=None):
- """ Returns a list of products and their attributes, ordered by
- relevancy, based on a search query that you specify.
- Your search query can be a phrase that describes the product
- or it can be a product identifier such as a UPC, EAN, ISBN, or JAN.
- """
- data = dict(Action='ListMatchingProducts',
- MarketplaceId=marketplaceid,
- Query=query,
- QueryContextId=contextid)
- return self.make_request(data)
-
- def get_matching_product(self, marketplaceid, asins):
- """ Returns a list of products and their attributes, based on a list of
- ASIN values that you specify.
- """
- data = dict(Action='GetMatchingProduct', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_matching_product_for_id(self, marketplaceid, type, id):
- """ Returns a list of products and their attributes, based on a list of
- product identifier values (asin, sellersku, upc, ean, isbn and JAN)
- Added in Fourth Release, API version 2011-10-01
- """
- data = dict(Action='GetMatchingProductForId',
- MarketplaceId=marketplaceid,
- IdType=type)
- data.update(self.enumerate_param('IdList.Id', id))
- return self.make_request(data)
-
- def get_competitive_pricing_for_sku(self, marketplaceid, skus):
- """ Returns the current competitive pricing of a product,
- based on the SellerSKU and MarketplaceId that you specify.
- """
- data = dict(Action='GetCompetitivePricingForSKU', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_competitive_pricing_for_asin(self, marketplaceid, asins):
- """ Returns the current competitive pricing of a product,
- based on the ASIN and MarketplaceId that you specify.
- """
- data = dict(Action='GetCompetitivePricingForASIN', MarketplaceId=marketplaceid)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_lowest_offer_listings_for_sku(self, marketplaceid, skus, condition="Any", excludeme="False"):
- data = dict(Action='GetLowestOfferListingsForSKU',
- MarketplaceId=marketplaceid,
- ItemCondition=condition,
- ExcludeMe=excludeme)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_lowest_offer_listings_for_asin(self, marketplaceid, asins, condition="Any", excludeme="False"):
- data = dict(Action='GetLowestOfferListingsForASIN',
- MarketplaceId=marketplaceid,
- ItemCondition=condition,
- ExcludeMe=excludeme)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
- def get_product_categories_for_sku(self, marketplaceid, sku):
- data = dict(Action='GetProductCategoriesForSKU',
- MarketplaceId=marketplaceid,
- SellerSKU=sku)
- return self.make_request(data)
-
- def get_product_categories_for_asin(self, marketplaceid, asin):
- data = dict(Action='GetProductCategoriesForASIN',
- MarketplaceId=marketplaceid,
- ASIN=asin)
- return self.make_request(data)
-
- def get_my_price_for_sku(self, marketplaceid, skus, condition=None):
- data = dict(Action='GetMyPriceForSKU',
- MarketplaceId=marketplaceid,
- ItemCondition=condition)
- data.update(self.enumerate_param('SellerSKUList.SellerSKU.', skus))
- return self.make_request(data)
-
- def get_my_price_for_asin(self, marketplaceid, asins, condition=None):
- data = dict(Action='GetMyPriceForASIN',
- MarketplaceId=marketplaceid,
- ItemCondition=condition)
- data.update(self.enumerate_param('ASINList.ASIN.', asins))
- return self.make_request(data)
-
-
-class Sellers(MWS):
- """ Amazon MWS Sellers API """
-
- URI = '/Sellers/2011-07-01'
- VERSION = '2011-07-01'
- NS = '{http://mws.amazonservices.com/schema/Sellers/2011-07-01}'
-
- def list_marketplace_participations(self):
- """
- Returns a list of marketplaces a seller can participate in and
- a list of participations that include seller-specific information in that marketplace.
- The operation returns only those marketplaces where the seller's account is in an active state.
- """
-
- data = dict(Action='ListMarketplaceParticipations')
- return self.make_request(data)
-
- def list_marketplace_participations_by_next_token(self, token):
- """
- Takes a "NextToken" and returns the same information as "list_marketplace_participations".
- Based on the "NextToken".
- """
- data = dict(Action='ListMarketplaceParticipations', NextToken=token)
- return self.make_request(data)
-
-#### Fulfillment APIs ####
-
-class InboundShipments(MWS):
- URI = "/FulfillmentInboundShipment/2010-10-01"
- VERSION = '2010-10-01'
-
- # To be completed
-
-
-class Inventory(MWS):
- """ Amazon MWS Inventory Fulfillment API """
-
- URI = '/FulfillmentInventory/2010-10-01'
- VERSION = '2010-10-01'
- NS = "{http://mws.amazonaws.com/FulfillmentInventory/2010-10-01}"
-
- def list_inventory_supply(self, skus=(), datetime=None, response_group='Basic'):
- """ Returns information on available inventory """
-
- data = dict(Action='ListInventorySupply',
- QueryStartDateTime=datetime,
- ResponseGroup=response_group,
- )
- data.update(self.enumerate_param('SellerSkus.member.', skus))
- return self.make_request(data, "POST")
-
- def list_inventory_supply_by_next_token(self, token):
- data = dict(Action='ListInventorySupplyByNextToken', NextToken=token)
- return self.make_request(data, "POST")
-
-
-class OutboundShipments(MWS):
- URI = "/FulfillmentOutboundShipment/2010-10-01"
- VERSION = "2010-10-01"
- # To be completed
-
-
-class Recommendations(MWS):
-
- """ Amazon MWS Recommendations API """
-
- URI = '/Recommendations/2013-04-01'
- VERSION = '2013-04-01'
- NS = "{https://mws.amazonservices.com/Recommendations/2013-04-01}"
-
- def get_last_updated_time_for_recommendations(self, marketplaceid):
- """
- Checks whether there are active recommendations for each category for the given marketplace, and if there are,
- returns the time when recommendations were last updated for each category.
- """
-
- data = dict(Action='GetLastUpdatedTimeForRecommendations',
- MarketplaceId=marketplaceid)
- return self.make_request(data, "POST")
-
- def list_recommendations(self, marketplaceid, recommendationcategory=None):
- """
- Returns your active recommendations for a specific category or for all categories for a specific marketplace.
- """
-
- data = dict(Action="ListRecommendations",
- MarketplaceId=marketplaceid,
- RecommendationCategory=recommendationcategory)
- return self.make_request(data, "POST")
-
- def list_recommendations_by_next_token(self, token):
- """
- Returns the next page of recommendations using the NextToken parameter.
- """
-
- data = dict(Action="ListRecommendationsByNextToken",
- NextToken=token)
- return self.make_request(data, "POST")
-
-class Finances(MWS):
- """ Amazon Finances API"""
- URI = '/Finances/2015-05-01'
- VERSION = '2015-05-01'
- NS = "{https://mws.amazonservices.com/Finances/2015-05-01}"
-
- def list_financial_events(self , posted_after=None, posted_before=None,
- amazon_order_id=None, max_results='100'):
-
- data = dict(Action='ListFinancialEvents',
- PostedAfter=posted_after,
- PostedBefore=posted_before,
- AmazonOrderId=amazon_order_id,
- MaxResultsPerPage=max_results,
- )
- return self.make_request(data)
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js
deleted file mode 100644
index f5ea804..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.js
+++ /dev/null
@@ -1,2 +0,0 @@
-// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors
-// For license information, please see license.txt
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json
deleted file mode 100644
index 5a678e7..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.json
+++ /dev/null
@@ -1,237 +0,0 @@
-{
- "actions": [],
- "creation": "2018-07-31 05:51:41.357047",
- "doctype": "DocType",
- "editable_grid": 1,
- "engine": "InnoDB",
- "field_order": [
- "enable_amazon",
- "mws_credentials",
- "seller_id",
- "aws_access_key_id",
- "mws_auth_token",
- "secret_key",
- "column_break_4",
- "market_place_id",
- "region",
- "domain",
- "section_break_13",
- "company",
- "warehouse",
- "item_group",
- "price_list",
- "column_break_17",
- "customer_group",
- "territory",
- "customer_type",
- "market_place_account_group",
- "section_break_12",
- "after_date",
- "taxes_charges",
- "sync_products",
- "sync_orders",
- "column_break_10",
- "enable_sync",
- "max_retry_limit"
- ],
- "fields": [
- {
- "default": "0",
- "fieldname": "enable_amazon",
- "fieldtype": "Check",
- "label": "Enable Amazon"
- },
- {
- "fieldname": "mws_credentials",
- "fieldtype": "Section Break",
- "label": "MWS Credentials"
- },
- {
- "fieldname": "seller_id",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Seller ID",
- "reqd": 1
- },
- {
- "fieldname": "aws_access_key_id",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "AWS Access Key ID",
- "reqd": 1
- },
- {
- "fieldname": "mws_auth_token",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "MWS Auth Token",
- "reqd": 1
- },
- {
- "fieldname": "secret_key",
- "fieldtype": "Data",
- "in_list_view": 1,
- "label": "Secret Key",
- "reqd": 1
- },
- {
- "fieldname": "column_break_4",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "market_place_id",
- "fieldtype": "Data",
- "label": "Market Place ID",
- "reqd": 1
- },
- {
- "fieldname": "region",
- "fieldtype": "Select",
- "label": "Region",
- "options": "\nAE\nAU\nBR\nCA\nCN\nDE\nES\nFR\nIN\nJP\nIT\nMX\nUK\nUS",
- "reqd": 1
- },
- {
- "fieldname": "domain",
- "fieldtype": "Data",
- "label": "Domain",
- "reqd": 1
- },
- {
- "fieldname": "section_break_13",
- "fieldtype": "Section Break"
- },
- {
- "fieldname": "company",
- "fieldtype": "Link",
- "label": "Company",
- "options": "Company",
- "reqd": 1
- },
- {
- "fieldname": "warehouse",
- "fieldtype": "Link",
- "label": "Warehouse",
- "options": "Warehouse",
- "reqd": 1
- },
- {
- "fieldname": "item_group",
- "fieldtype": "Link",
- "label": "Item Group",
- "options": "Item Group",
- "reqd": 1
- },
- {
- "fieldname": "price_list",
- "fieldtype": "Link",
- "label": "Price List",
- "options": "Price List",
- "reqd": 1
- },
- {
- "fieldname": "column_break_17",
- "fieldtype": "Column Break"
- },
- {
- "fieldname": "customer_group",
- "fieldtype": "Link",
- "label": "Customer Group",
- "options": "Customer Group",
- "reqd": 1
- },
- {
- "fieldname": "territory",
- "fieldtype": "Link",
- "label": "Territory",
- "options": "Territory",
- "reqd": 1
- },
- {
- "fieldname": "customer_type",
- "fieldtype": "Select",
- "label": "Customer Type",
- "options": "Individual\nCompany",
- "reqd": 1
- },
- {
- "fieldname": "market_place_account_group",
- "fieldtype": "Link",
- "label": "Market Place Account Group",
- "options": "Account",
- "reqd": 1
- },
- {
- "fieldname": "section_break_12",
- "fieldtype": "Section Break"
- },
- {
- "description": "Amazon will synch data updated after this date",
- "fieldname": "after_date",
- "fieldtype": "Datetime",
- "label": "After Date",
- "reqd": 1
- },
- {
- "default": "0",
- "description": "Get financial breakup of Taxes and charges data by Amazon ",
- "fieldname": "taxes_charges",
- "fieldtype": "Check",
- "label": "Sync Taxes and Charges"
- },
- {
- "fieldname": "column_break_10",
- "fieldtype": "Column Break"
- },
- {
- "default": "3",
- "fieldname": "max_retry_limit",
- "fieldtype": "Int",
- "label": "Max Retry Limit"
- },
- {
- "description": "Always sync your products from Amazon MWS before synching the Orders details",
- "fieldname": "sync_products",
- "fieldtype": "Button",
- "label": "Sync Products",
- "options": "get_products_details"
- },
- {
- "description": "Click this button to pull your Sales Order data from Amazon MWS.",
- "fieldname": "sync_orders",
- "fieldtype": "Button",
- "label": "Sync Orders",
- "options": "get_order_details"
- },
- {
- "default": "0",
- "description": "Check this to enable a scheduled Daily synchronization routine via scheduler",
- "fieldname": "enable_sync",
- "fieldtype": "Check",
- "label": "Enable Scheduled Sync"
- }
- ],
- "issingle": 1,
- "links": [],
- "modified": "2020-04-07 14:26:20.174848",
- "modified_by": "Administrator",
- "module": "ERPNext Integrations",
- "name": "Amazon MWS 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
-}
\ No newline at end of file
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py
deleted file mode 100644
index c1f460f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/amazon_mws_settings.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies and contributors
-# For license information, please see license.txt
-
-
-import dateutil
-import frappe
-from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
-from frappe.model.document import Document
-
-from erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods import get_orders
-
-
-class AmazonMWSSettings(Document):
- def validate(self):
- if self.enable_amazon == 1:
- self.enable_sync = 1
- setup_custom_fields()
- else:
- self.enable_sync = 0
-
- @frappe.whitelist()
- def get_products_details(self):
- if self.enable_amazon == 1:
- frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_products_details')
-
- @frappe.whitelist()
- def get_order_details(self):
- if self.enable_amazon == 1:
- after_date = dateutil.parser.parse(self.after_date).strftime("%Y-%m-%d")
- frappe.enqueue('erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_methods.get_orders', after_date=after_date)
-
-def schedule_get_order_details():
- mws_settings = frappe.get_doc("Amazon MWS Settings")
- if mws_settings.enable_sync and mws_settings.enable_amazon:
- after_date = dateutil.parser.parse(mws_settings.after_date).strftime("%Y-%m-%d")
- get_orders(after_date = after_date)
-
-def setup_custom_fields():
- custom_fields = {
- "Item": [dict(fieldname='amazon_item_code', label='Amazon Item Code',
- fieldtype='Data', insert_after='series', read_only=1, print_hide=1)],
- "Sales Order": [dict(fieldname='amazon_order_id', label='Amazon Order ID',
- fieldtype='Data', insert_after='title', read_only=1, print_hide=1)]
- }
-
- create_custom_fields(custom_fields)
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py
deleted file mode 100644
index 4be7960..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/test_amazon_mws_settings.py
+++ /dev/null
@@ -1,8 +0,0 @@
-# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
-# See license.txt
-
-import unittest
-
-
-class TestAmazonMWSSettings(unittest.TestCase):
- pass
diff --git a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py b/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py
deleted file mode 100644
index d9dfc6f..0000000
--- a/erpnext/erpnext_integrations/doctype/amazon_mws_settings/xml_utils.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""
-Created on Tue Jun 26 15:42:07 2012
-
-Borrowed from https://github.com/timotheus/ebaysdk-python
-
-@author: pierre
-"""
-
-import re
-import xml.etree.ElementTree as ET
-
-
-class object_dict(dict):
- """object view of dict, you can
- >>> a = object_dict()
- >>> a.fish = 'fish'
- >>> a['fish']
- 'fish'
- >>> a['water'] = 'water'
- >>> a.water
- 'water'
- >>> a.test = {'value': 1}
- >>> a.test2 = object_dict({'name': 'test2', 'value': 2})
- >>> a.test, a.test2.name, a.test2.value
- (1, 'test2', 2)
- """
- def __init__(self, initd=None):
- if initd is None:
- initd = {}
- dict.__init__(self, initd)
-
- def __getattr__(self, item):
-
- try:
- d = self.__getitem__(item)
- except KeyError:
- return None
-
- if isinstance(d, dict) and 'value' in d and len(d) == 1:
- return d['value']
- else:
- return d
-
- # if value is the only key in object, you can omit it
- def __setstate__(self, item):
- return False
-
- def __setattr__(self, item, value):
- self.__setitem__(item, value)
-
- def getvalue(self, item, value=None):
- return self.get(item, {}).get('value', value)
-
-
-class xml2dict(object):
-
- def __init__(self):
- pass
-
- def _parse_node(self, node):
- node_tree = object_dict()
- # Save attrs and text, hope there will not be a child with same name
- if node.text:
- node_tree.value = node.text
- for (k, v) in node.attrib.items():
- k, v = self._namespace_split(k, object_dict({'value':v}))
- node_tree[k] = v
- #Save childrens
- for child in node.getchildren():
- tag, tree = self._namespace_split(child.tag,
- self._parse_node(child))
- if tag not in node_tree: # the first time, so store it in dict
- node_tree[tag] = tree
- continue
- old = node_tree[tag]
- if not isinstance(old, list):
- node_tree.pop(tag)
- node_tree[tag] = [old] # multi times, so change old dict to a list
- node_tree[tag].append(tree) # add the new one
-
- return node_tree
-
- def _namespace_split(self, tag, value):
- """
- Split the tag '{http://cs.sfsu.edu/csc867/myscheduler}patients'
- ns = http://cs.sfsu.edu/csc867/myscheduler
- name = patients
- """
- result = re.compile(r"\{(.*)\}(.*)").search(tag)
- if result:
- value.namespace, tag = result.groups()
-
- return (tag, value)
-
- def parse(self, file):
- """parse a xml file to a dict"""
- f = open(file, 'r')
- return self.fromstring(f.read())
-
- def fromstring(self, s):
- """parse a string"""
- t = ET.fromstring(s)
- root_tag, root_tree = self._namespace_split(t.tag, self._parse_node(t))
- return object_dict({root_tag: root_tree})
diff --git a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
index a8119ac..f02f76e 100644
--- a/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
+++ b/erpnext/erpnext_integrations/doctype/gocardless_settings/gocardless_settings.py
@@ -13,7 +13,7 @@
class GoCardlessSettings(Document):
- supported_currencies = ["EUR", "DKK", "GBP", "SEK"]
+ supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"]
def validate(self):
self.initialize_client()
@@ -80,7 +80,7 @@
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
- frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency))
+ frappe.throw(_("Please select another payment method. Go Cardless does not support transactions in currency '{0}'").format(currency))
def get_payment_url(self, **kwargs):
return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))
diff --git a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
index 54ed6f7..26bd19f 100644
--- a/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
+++ b/erpnext/erpnext_integrations/doctype/tally_migration/tally_migration.py
@@ -82,7 +82,7 @@
"is_private": True
})
try:
- f.insert()
+ f.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
setattr(self, key, f.file_url)
diff --git a/erpnext/erpnext_integrations/taxjar_integration.py b/erpnext/erpnext_integrations/taxjar_integration.py
index a4e2157..14c86d5 100644
--- a/erpnext/erpnext_integrations/taxjar_integration.py
+++ b/erpnext/erpnext_integrations/taxjar_integration.py
@@ -8,10 +8,6 @@
from erpnext import get_default_company, get_region
-TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
-SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head")
-TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
-TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
SUPPORTED_COUNTRY_CODES = ["AT", "AU", "BE", "BG", "CA", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GB", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
"SE", "SI", "SK", "US"]
@@ -35,12 +31,14 @@
if api_key and api_url:
client = taxjar.Client(api_key=api_key, api_url=api_url)
client.set_api_config('headers', {
- 'x-api-version': '2020-08-07'
+ 'x-api-version': '2022-01-24'
})
return client
def create_transaction(doc, method):
+ TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
+
"""Create an order transaction in TaxJar"""
if not TAXJAR_CREATE_TRANSACTIONS:
@@ -51,6 +49,7 @@
if not client:
return
+ TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD])
if not sales_tax:
@@ -79,6 +78,7 @@
def delete_transaction(doc, method):
"""Delete an existing TaxJar order transaction"""
+ TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
if not TAXJAR_CREATE_TRANSACTIONS:
return
@@ -92,6 +92,8 @@
def get_tax_data(doc):
+ SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head")
+
from_address = get_company_address_details(doc)
from_shipping_state = from_address.get("state")
from_country_code = frappe.db.get_value("Country", from_address.country, "code")
@@ -113,20 +115,20 @@
to_shipping_state = get_state_code(to_address, 'Shipping')
tax_dict = {
- 'from_country': from_country_code,
- 'from_zip': from_address.pincode,
- 'from_state': from_shipping_state,
- 'from_city': from_address.city,
- 'from_street': from_address.address_line1,
- 'to_country': to_country_code,
- 'to_zip': to_address.pincode,
- 'to_city': to_address.city,
- 'to_street': to_address.address_line1,
- 'to_state': to_shipping_state,
- 'shipping': shipping,
- 'amount': doc.net_total,
- 'plugin': 'erpnext',
- 'line_items': line_items
+ "from_country": from_country_code,
+ "from_zip": from_address.pincode,
+ "from_state": from_shipping_state,
+ "from_city": from_address.city,
+ "from_street": from_address.address_line1,
+ "to_country": to_country_code,
+ "to_zip": to_address.pincode,
+ "to_city": to_address.city,
+ "to_street": to_address.address_line1,
+ "to_state": to_shipping_state,
+ "shipping": shipping,
+ "amount": doc.net_total,
+ "plugin": "erpnext",
+ "line_items": line_items
}
return tax_dict
@@ -156,6 +158,9 @@
return tax_dict
def set_sales_tax(doc, method):
+ TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
+ TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
+
if not TAXJAR_CALCULATE_TAX:
return
@@ -206,6 +211,7 @@
doc.run_method("calculate_taxes_and_totals")
def check_for_nexus(doc, tax_dict):
+ TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}):
for item in doc.get("items"):
item.tax_collectable = flt(0)
@@ -218,6 +224,8 @@
def check_sales_tax_exemption(doc):
# if the party is exempt from sales tax, then set all tax account heads to zero
+ TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
+
sales_tax_exempted = hasattr(doc, "exempt_from_sales_tax") and doc.exempt_from_sales_tax \
or frappe.db.has_column("Customer", "exempt_from_sales_tax") \
and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax")
diff --git a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
index 45077aa..1f2619b 100644
--- a/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
+++ b/erpnext/erpnext_integrations/workspace/erpnext_integrations/erpnext_integrations.json
@@ -30,17 +30,6 @@
"type": "Link"
},
{
- "dependencies": "",
- "hidden": 0,
- "is_query_report": 0,
- "label": "Amazon MWS Settings",
- "link_count": 0,
- "link_to": "Amazon MWS Settings",
- "link_type": "DocType",
- "onboard": 0,
- "type": "Link"
- },
- {
"hidden": 0,
"is_query_report": 0,
"label": "Payments",
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index d99f23e..38fa691 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -333,7 +333,6 @@
"hourly": [
'erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group.trigger_emails',
"erpnext.accounts.doctype.subscription.subscription.process_all",
- "erpnext.erpnext_integrations.doctype.amazon_mws_settings.amazon_mws_settings.schedule_get_order_details",
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization",
"erpnext.projects.doctype.project.project.hourly_reminder",
diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py
index a2df26c..6e52eb9 100755
--- a/erpnext/hr/doctype/employee/employee.py
+++ b/erpnext/hr/doctype/employee/employee.py
@@ -142,7 +142,7 @@
"file_url": self.image,
"attached_to_doctype": "User",
"attached_to_name": self.user_id
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
# already exists
pass
diff --git a/erpnext/hr/doctype/employee_group_table/employee_group_table.json b/erpnext/hr/doctype/employee_group_table/employee_group_table.json
index 4e0045c..54eb8c6 100644
--- a/erpnext/hr/doctype/employee_group_table/employee_group_table.json
+++ b/erpnext/hr/doctype/employee_group_table/employee_group_table.json
@@ -27,12 +27,13 @@
"fetch_from": "employee.user_id",
"fieldname": "user_id",
"fieldtype": "Data",
+ "in_list_view": 1,
"label": "ERPNext User ID",
"read_only": 1
}
],
"istable": 1,
- "modified": "2019-06-06 10:41:20.313756",
+ "modified": "2022-02-13 19:44:21.302938",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee Group Table",
@@ -42,4 +43,4 @@
"sort_field": "modified",
"sort_order": "DESC",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py
index 2d129c8..0fb821d 100644
--- a/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py
+++ b/erpnext/hr/doctype/employee_onboarding/test_employee_onboarding.py
@@ -4,7 +4,7 @@
import unittest
import frappe
-from frappe.utils import getdate
+from frappe.utils import add_days, getdate
from erpnext.hr.doctype.employee_onboarding.employee_onboarding import (
IncompleteTaskError,
@@ -35,6 +35,15 @@
# boarding status
self.assertEqual(onboarding.boarding_status, 'Pending')
+ # start and end dates
+ start_date, end_date = frappe.db.get_value('Task', onboarding.activities[0].task, ['exp_start_date', 'exp_end_date'])
+ self.assertEqual(getdate(start_date), getdate(onboarding.boarding_begins_on))
+ self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[0].duration))
+
+ start_date, end_date = frappe.db.get_value('Task', onboarding.activities[1].task, ['exp_start_date', 'exp_end_date'])
+ self.assertEqual(getdate(start_date), add_days(onboarding.boarding_begins_on, onboarding.activities[0].duration))
+ self.assertEqual(getdate(end_date), add_days(start_date, onboarding.activities[1].duration))
+
# complete the task
project = frappe.get_doc('Project', onboarding.project)
for task in frappe.get_all('Task', dict(project=project.name)):
@@ -57,10 +66,7 @@
self.assertEqual(employee.employee_name, 'Test Researcher')
def tearDown(self):
- for entry in frappe.get_all('Employee Onboarding'):
- doc = frappe.get_doc('Employee Onboarding', entry.name)
- doc.cancel()
- doc.delete()
+ frappe.db.rollback()
def get_job_applicant():
@@ -87,23 +93,31 @@
def create_employee_onboarding():
applicant = get_job_applicant()
job_offer = get_job_offer(applicant.name)
- holiday_list = make_holiday_list()
+
+ holiday_list = make_holiday_list('_Test Employee Boarding')
+ holiday_list = frappe.get_doc('Holiday List', holiday_list)
+ holiday_list.holidays = []
+ holiday_list.save()
onboarding = frappe.new_doc('Employee Onboarding')
onboarding.job_applicant = applicant.name
onboarding.job_offer = job_offer.name
onboarding.date_of_joining = onboarding.boarding_begins_on = getdate()
onboarding.company = '_Test Company'
- onboarding.holiday_list = holiday_list
+ onboarding.holiday_list = holiday_list.name
onboarding.designation = 'Researcher'
onboarding.append('activities', {
'activity_name': 'Assign ID Card',
'role': 'HR User',
- 'required_for_employee_creation': 1
+ 'required_for_employee_creation': 1,
+ 'begin_on': 0,
+ 'duration': 1
})
onboarding.append('activities', {
'activity_name': 'Assign a laptop',
- 'role': 'HR User'
+ 'role': 'HR User',
+ 'begin_on': 1,
+ 'duration': 1
})
onboarding.status = 'Pending'
onboarding.insert()
diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py
index 30e19f1..59fb2fd 100644
--- a/erpnext/hr/doctype/exit_interview/exit_interview.py
+++ b/erpnext/hr/doctype/exit_interview/exit_interview.py
@@ -128,4 +128,4 @@
message += _('{0} due to missing email information for employee(s): {1}').format(
frappe.bold('Sending Failed'), ', '.join(email_failure))
- frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True)
\ No newline at end of file
+ frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
index 41a9558..c11a821 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/leave_policy_assignment.py
@@ -8,11 +8,10 @@
import frappe
from frappe import _, bold
from frappe.model.document import Document
-from frappe.utils import date_diff, flt, formatdate, get_datetime, get_last_day, getdate
+from frappe.utils import date_diff, flt, formatdate, get_last_day, getdate
class LeavePolicyAssignment(Document):
-
def validate(self):
self.validate_policy_assignment_overlap()
self.set_dates()
@@ -94,10 +93,12 @@
new_leaves_allocated = 0
elif leave_type_details.get(leave_type).is_earned_leave == 1:
- if self.assignment_based_on == "Leave Period":
- new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
- else:
+ if not self.assignment_based_on:
new_leaves_allocated = 0
+ else:
+ # get leaves for past months if assignment is based on Leave Period / Joining Date
+ new_leaves_allocated = self.get_leaves_for_passed_months(leave_type, new_leaves_allocated, leave_type_details, date_of_joining)
+
# Calculate leaves at pro-rata basis for employees joining after the beginning of the given leave period
elif getdate(date_of_joining) > getdate(self.effective_from):
remaining_period = ((date_diff(self.effective_to, date_of_joining) + 1) / (date_diff(self.effective_to, self.effective_from) + 1))
@@ -108,25 +109,24 @@
def get_leaves_for_passed_months(self, leave_type, new_leaves_allocated, leave_type_details, date_of_joining):
from erpnext.hr.utils import get_monthly_earned_leave
- current_month = get_datetime(frappe.flags.current_date).month or get_datetime().month
- current_year = get_datetime(frappe.flags.current_date).year or get_datetime().year
+ current_date = frappe.flags.current_date or getdate()
+ if current_date > getdate(self.effective_to):
+ current_date = getdate(self.effective_to)
- from_date = frappe.db.get_value("Leave Period", self.leave_period, "from_date")
- if getdate(date_of_joining) > getdate(from_date):
- from_date = date_of_joining
-
- from_date_month = get_datetime(from_date).month
- from_date_year = get_datetime(from_date).year
+ from_date = getdate(self.effective_from)
+ if getdate(date_of_joining) > from_date:
+ from_date = getdate(date_of_joining)
months_passed = 0
+ based_on_doj = leave_type_details.get(leave_type).based_on_date_of_joining
- if current_year == from_date_year and current_month > from_date_month:
- months_passed = current_month - from_date_month
- months_passed = add_current_month_if_applicable(months_passed)
+ if current_date.year == from_date.year and current_date.month >= from_date.month:
+ months_passed = current_date.month - from_date.month
+ months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
- elif current_year > from_date_year:
- months_passed = (12 - from_date_month) + current_month
- months_passed = add_current_month_if_applicable(months_passed)
+ elif current_date.year > from_date.year:
+ months_passed = (12 - from_date.month) + current_date.month
+ months_passed = add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj)
if months_passed > 0:
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
@@ -138,13 +138,19 @@
return new_leaves_allocated
-def add_current_month_if_applicable(months_passed):
+def add_current_month_if_applicable(months_passed, date_of_joining, based_on_doj):
date = getdate(frappe.flags.current_date) or getdate()
- last_day_of_month = get_last_day(date)
- # if its the last day of the month, then that month should also be considered
- if last_day_of_month == date:
- months_passed += 1
+ if based_on_doj:
+ # if leave type allocation is based on DOJ, and the date of assignment creation is same as DOJ,
+ # then the month should be considered
+ if date.day == date_of_joining.day:
+ months_passed += 1
+ else:
+ last_day_of_month = get_last_day(date)
+ # if its the last day of the month, then that month should be considered
+ if last_day_of_month == date:
+ months_passed += 1
return months_passed
@@ -183,7 +189,7 @@
def get_leave_type_details():
leave_type_details = frappe._dict()
leave_types = frappe.get_all("Leave Type",
- fields=["name", "is_lwp", "is_earned_leave", "is_compensatory",
+ fields=["name", "is_lwp", "is_earned_leave", "is_compensatory", "based_on_date_of_joining",
"is_carry_forward", "expire_carry_forwarded_leaves_after_days", "earned_leave_frequency", "rounding"])
for d in leave_types:
leave_type_details.setdefault(d.name, d)
diff --git a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
index 8c76ca1..a19ddce 100644
--- a/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
+++ b/erpnext/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py
@@ -20,36 +20,31 @@
class TestLeavePolicyAssignment(unittest.TestCase):
def setUp(self):
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
- frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
+ frappe.db.delete(doctype)
+
+ employee = get_employee()
+ self.original_doj = employee.date_of_joining
+ self.employee = employee
def test_grant_leaves(self):
leave_period = get_leave_period()
- employee = get_employee()
-
- # create the leave policy with leave type "_Test Leave Type", allocation = 10
+ # allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
-
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
-
- leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
- leave_policy_assignment_doc.reload()
-
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
- "employee": employee.name,
+ "employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
-
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
@@ -61,63 +56,46 @@
def test_allow_to_grant_all_leave_after_cancellation_of_every_leave_allocation(self):
leave_period = get_leave_period()
- employee = get_employee()
-
# create the leave policy with leave type "_Test Leave Type", allocation = 10
leave_policy = create_leave_policy()
leave_policy.submit()
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
-
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
-
- leave_policy_assignment_doc = frappe.get_doc("Leave Policy Assignment", leave_policy_assignments[0])
- leave_policy_assignment_doc.reload()
-
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# every leave is allocated no more leave can be granted now
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 1)
-
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 1)
leave_allocation = frappe.get_list("Leave Allocation", filters={
- "employee": employee.name,
+ "employee": self.employee.name,
"leave_policy":leave_policy.name,
"leave_policy_assignment": leave_policy_assignments[0],
"docstatus": 1})[0]
leave_alloc_doc = frappe.get_doc("Leave Allocation", leave_allocation)
-
- # User all allowed to grant leave when there is no allocation against assignment
leave_alloc_doc.cancel()
leave_alloc_doc.delete()
-
- leave_policy_assignment_doc.reload()
-
-
- # User are now allowed to grant leave
- self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
+ self.assertEqual(frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "leaves_allocated"), 0)
def test_earned_leave_allocation(self):
leave_period = create_leave_period("Test Earned Leave Period")
- employee = get_employee()
leave_type = create_earned_leave_type("Test Earned Leave")
leave_policy = frappe.get_doc({
"doctype": "Leave Policy",
"title": "Test Leave Policy",
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
- }).insert()
+ }).submit()
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
@@ -125,16 +103,8 @@
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 0)
- def test_earned_leave_allocation_for_passed_months(self):
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -1)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
+ def test_earned_leave_alloc_for_passed_months_based_on_leave_period(self):
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -1)))
# Case 1: assignment created one month after the leave period, should allocate 1 leave
frappe.flags.current_date = get_first_day(getdate())
@@ -143,24 +113,15 @@
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 1)
- def test_earned_leave_allocation_for_passed_months_on_month_end(self):
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -2)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
-
+ def test_earned_leave_alloc_for_passed_months_on_month_end_based_on_leave_period(self):
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# Case 2: assignment created on the last day of the leave period's latter month
# should allocate 1 leave for current month even though the month has not ended
# since the daily job might have already executed
@@ -171,7 +132,7 @@
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
@@ -188,33 +149,17 @@
}, "total_leaves_allocated")
self.assertEqual(leaves_allocated, 3)
- def test_earned_leave_allocation_for_passed_months_with_carry_forwarded_leaves(self):
+ def test_earned_leave_alloc_for_passed_months_with_cf_leaves_based_on_leave_period(self):
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
- employee = get_employee()
- leave_type = create_earned_leave_type("Test Earned Leave")
- leave_period = create_leave_period("Test Earned Leave Period",
- start_date=get_first_day(add_months(getdate(), -2)))
- leave_policy = frappe.get_doc({
- "doctype": "Leave Policy",
- "title": "Test Leave Policy",
- "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
- }).insert()
-
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)))
# initial leave allocation = 5
- leave_allocation = create_leave_allocation(
- employee=employee.name,
- employee_name=employee.employee_name,
- leave_type=leave_type.name,
- from_date=add_months(getdate(), -12),
- to_date=add_months(getdate(), -3),
- new_leaves_allocated=5,
- carry_forward=0)
+ leave_allocation = create_leave_allocation(employee=self.employee.name, employee_name=self.employee.employee_name, leave_type="Test Earned Leave",
+ from_date=add_months(getdate(), -12), to_date=add_months(getdate(), -3), new_leaves_allocated=5, carry_forward=0)
leave_allocation.submit()
# Case 3: assignment created on the last day of the leave period's latter month with carry forwarding
frappe.flags.current_date = get_last_day(add_months(getdate(), -1))
-
data = {
"assignment_based_on": "Leave Period",
"leave_policy": leave_policy.name,
@@ -222,7 +167,7 @@
"carry_forward": 1
}
# carry forwarded leaves = 5, 3 leaves allocated for passed months
- leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
details = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
@@ -236,15 +181,122 @@
from erpnext.hr.utils import is_earned_leave_already_allocated
frappe.flags.current_date = get_last_day(getdate())
- allocation = frappe.get_doc('Leave Allocation', details.name)
+ allocation = frappe.get_doc("Leave Allocation", details.name)
# 1 leave is still pending to be allocated, irrespective of carry forwarded leaves
self.assertFalse(is_earned_leave_already_allocated(allocation, leave_policy.leave_policy_details[0].annual_allocation))
+ def test_earned_leave_alloc_for_passed_months_based_on_joining_date(self):
+ # tests leave alloc for earned leaves for assignment based on joining date in policy assignment
+ leave_type = create_earned_leave_type("Test Earned Leave")
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).submit()
+
+ # joining date set to 2 months back
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the last day of the current month
+ frappe.flags.current_date = get_last_day(getdate())
+ data = {
+ "assignment_based_on": "Joining Date",
+ "leave_policy": leave_policy.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
+ self.assertEqual(effective_from, self.employee.date_of_joining)
+ self.assertEqual(leaves_allocated, 3)
+
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_last_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_grant_leaves_on_doj_for_earned_leaves_based_on_leave_period(self):
+ # tests leave alloc based on leave period for earned leaves with "based on doj" configuration in leave type
+ leave_period, leave_policy = setup_leave_period_and_policy(get_first_day(add_months(getdate(), -2)), based_on_doj=True)
+
+ # joining date set to 2 months back
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the same day of the current month, should allocate leaves including the current month
+ frappe.flags.current_date = get_first_day(getdate())
+
+ data = {
+ "assignment_based_on": "Leave Period",
+ "leave_policy": leave_policy.name,
+ "leave_period": leave_period.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ # if the daily job is not completed yet, there is another check present
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_first_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {
+ "leave_policy_assignment": leave_policy_assignments[0]
+ }, "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
+ def test_grant_leaves_on_doj_for_earned_leaves_based_on_joining_date(self):
+ # tests leave alloc based on joining date for earned leaves with "based on doj" configuration in leave type
+ leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj=True)
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).submit()
+
+ # joining date set to 2 months back
+ # leave should be allocated for current month too since this day is same as the joining day
+ self.employee.date_of_joining = get_first_day(add_months(getdate(), -2))
+ self.employee.save()
+
+ # assignment created on the first day of the current month
+ frappe.flags.current_date = get_first_day(getdate())
+ data = {
+ "assignment_based_on": "Joining Date",
+ "leave_policy": leave_policy.name
+ }
+ leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ effective_from = frappe.db.get_value("Leave Policy Assignment", leave_policy_assignments[0], "effective_from")
+ self.assertEqual(effective_from, self.employee.date_of_joining)
+ self.assertEqual(leaves_allocated, 3)
+
+ # to ensure leave is not already allocated to avoid duplication
+ from erpnext.hr.utils import allocate_earned_leaves
+ frappe.flags.current_date = get_first_day(getdate())
+ allocate_earned_leaves()
+
+ leaves_allocated = frappe.db.get_value("Leave Allocation", {"leave_policy_assignment": leave_policy_assignments[0]},
+ "total_leaves_allocated")
+ self.assertEqual(leaves_allocated, 3)
+
def tearDown(self):
frappe.db.rollback()
+ frappe.db.set_value("Employee", self.employee.name, "date_of_joining", self.original_doj)
+ frappe.flags.current_date = None
-def create_earned_leave_type(leave_type):
+def create_earned_leave_type(leave_type, based_on_doj=False):
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
return frappe.get_doc(dict(
@@ -253,7 +305,8 @@
is_earned_leave=1,
earned_leave_frequency="Monthly",
rounding=0.5,
- is_carry_forward=1
+ is_carry_forward=1,
+ based_on_date_of_joining=based_on_doj
)).insert()
@@ -269,4 +322,17 @@
to_date=add_months(start_date, 12),
company="_Test Company",
is_active=1
- )).insert()
\ No newline at end of file
+ )).insert()
+
+
+def setup_leave_period_and_policy(start_date, based_on_doj=False):
+ leave_type = create_earned_leave_type("Test Earned Leave", based_on_doj)
+ leave_period = create_leave_period("Test Earned Leave Period",
+ start_date=start_date)
+ leave_policy = frappe.get_doc({
+ "doctype": "Leave Policy",
+ "title": "Test Leave Policy",
+ "leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 12}]
+ }).insert()
+
+ return leave_period, leave_policy
\ No newline at end of file
diff --git a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py
index acd50f2..abb2887 100644
--- a/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py
+++ b/erpnext/hr/doctype/vehicle_log/test_vehicle_log.py
@@ -82,7 +82,7 @@
"vehicle_value": flt(500000)
})
try:
- vehicle.insert()
+ vehicle.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
return license_plate
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 7fd3a98..c174047 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -261,10 +261,10 @@
from_date=allocation.from_date
- if e_leave_type.based_on_date_of_joining_date:
+ if e_leave_type.based_on_date_of_joining:
from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining")
- if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date):
+ if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining):
update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates)
def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type, ignore_duplicates=False):
@@ -343,7 +343,7 @@
allocation.unused_leaves = 0
allocation.create_leave_ledger_entry()
-def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date):
+def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining):
import calendar
from dateutil import relativedelta
@@ -354,7 +354,7 @@
#last day of month
last_day = calendar.monthrange(to_date.year, to_date.month)[1]
- if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day):
+ if (from_date.day == to_date.day and based_on_date_of_joining) or (not based_on_date_of_joining and to_date.day == last_day):
if frequency == "Monthly":
return True
elif frequency == "Quarterly" and rd.months % 3:
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
index 7811d56..50926d7 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.json
@@ -14,11 +14,15 @@
"applicant",
"section_break_7",
"disbursement_date",
+ "clearance_date",
"column_break_8",
"disbursed_amount",
"accounting_dimensions_section",
"cost_center",
- "customer_details_section",
+ "accounting_details",
+ "disbursement_account",
+ "column_break_16",
+ "loan_account",
"bank_account",
"disbursement_references_section",
"reference_date",
@@ -107,11 +111,6 @@
"label": "Disbursement Details"
},
{
- "fieldname": "customer_details_section",
- "fieldtype": "Section Break",
- "label": "Customer Details"
- },
- {
"fetch_from": "against_loan.applicant_type",
"fieldname": "applicant_type",
"fieldtype": "Select",
@@ -149,15 +148,48 @@
"fieldname": "reference_number",
"fieldtype": "Data",
"label": "Reference Number"
+ },
+ {
+ "fieldname": "clearance_date",
+ "fieldtype": "Date",
+ "label": "Clearance Date",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "accounting_details",
+ "fieldtype": "Section Break",
+ "label": "Accounting Details"
+ },
+ {
+ "fetch_from": "against_loan.disbursement_account",
+ "fieldname": "disbursement_account",
+ "fieldtype": "Link",
+ "label": "Disbursement Account",
+ "options": "Account",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_16",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "against_loan.loan_account",
+ "fieldname": "loan_account",
+ "fieldtype": "Link",
+ "label": "Loan Account",
+ "options": "Account",
+ "read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2021-04-19 18:09:32.175355",
+ "modified": "2022-02-17 18:23:44.157598",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Disbursement",
+ "naming_rule": "Expression (old style)",
"owner": "Administrator",
"permissions": [
{
@@ -194,5 +226,6 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
index df3aadf..54a03b9 100644
--- a/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
+++ b/erpnext/loan_management/doctype/loan_disbursement/loan_disbursement.py
@@ -42,9 +42,6 @@
if not self.posting_date:
self.posting_date = self.disbursement_date or nowdate()
- if not self.bank_account and self.applicant_type == "Customer":
- self.bank_account = frappe.db.get_value("Customer", self.applicant, "default_bank_account")
-
def validate_disbursal_amount(self):
possible_disbursal_amount = get_disbursal_amount(self.against_loan)
@@ -117,12 +114,11 @@
def make_gl_entries(self, cancel=0, adv_adj=0):
gle_map = []
- loan_details = frappe.get_doc("Loan", self.against_loan)
gle_map.append(
self.get_gl_dict({
- "account": loan_details.loan_account,
- "against": loan_details.disbursement_account,
+ "account": self.loan_account,
+ "against": self.disbursement_account,
"debit": self.disbursed_amount,
"debit_in_account_currency": self.disbursed_amount,
"against_voucher_type": "Loan",
@@ -137,8 +133,8 @@
gle_map.append(
self.get_gl_dict({
- "account": loan_details.disbursement_account,
- "against": loan_details.loan_account,
+ "account": self.disbursement_account,
+ "against": self.loan_account,
"credit": self.disbursed_amount,
"credit_in_account_currency": self.disbursed_amount,
"against_voucher_type": "Loan",
diff --git a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
index 0de073f..1c800a0 100644
--- a/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
+++ b/erpnext/loan_management/doctype/loan_interest_accrual/loan_interest_accrual.py
@@ -74,39 +74,6 @@
})
)
- if self.payable_principal_amount:
- gle_map.append(
- self.get_gl_dict({
- "account": self.loan_account,
- "party_type": self.applicant_type,
- "party": self.applicant,
- "against": self.interest_income_account,
- "debit": self.payable_principal_amount,
- "debit_in_account_currency": self.interest_amount,
- "against_voucher_type": "Loan",
- "against_voucher": self.loan,
- "remarks": _("Interest accrued from {0} to {1} against loan: {2}").format(
- self.last_accrual_date, self.posting_date, self.loan),
- "cost_center": erpnext.get_default_cost_center(self.company),
- "posting_date": self.posting_date
- })
- )
-
- gle_map.append(
- self.get_gl_dict({
- "account": self.interest_income_account,
- "against": self.loan_account,
- "credit": self.payable_principal_amount,
- "credit_in_account_currency": self.interest_amount,
- "against_voucher_type": "Loan",
- "against_voucher": self.loan,
- "remarks": ("Interest accrued from {0} to {1} against loan: {2}").format(
- self.last_accrual_date, self.posting_date, self.loan),
- "cost_center": erpnext.get_default_cost_center(self.company),
- "posting_date": self.posting_date
- })
- )
-
if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
index 93ef217..480e010 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.json
@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "LM-REP-.####",
- "creation": "2019-09-03 14:44:39.977266",
+ "creation": "2022-01-25 10:30:02.767941",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -13,6 +13,7 @@
"column_break_3",
"company",
"posting_date",
+ "clearance_date",
"rate_of_interest",
"payroll_payable_account",
"is_term_loan",
@@ -37,7 +38,12 @@
"total_penalty_paid",
"total_interest_paid",
"repayment_details",
- "amended_from"
+ "amended_from",
+ "accounting_details_section",
+ "payment_account",
+ "penalty_income_account",
+ "column_break_36",
+ "loan_account"
],
"fields": [
{
@@ -260,12 +266,52 @@
"fieldname": "repay_from_salary",
"fieldtype": "Check",
"label": "Repay From Salary"
+ },
+ {
+ "fieldname": "clearance_date",
+ "fieldtype": "Date",
+ "label": "Clearance Date",
+ "no_copy": 1,
+ "read_only": 1
+ },
+ {
+ "fieldname": "accounting_details_section",
+ "fieldtype": "Section Break",
+ "label": "Accounting Details"
+ },
+ {
+ "fetch_from": "against_loan.payment_account",
+ "fieldname": "payment_account",
+ "fieldtype": "Link",
+ "label": "Repayment Account",
+ "options": "Account",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_36",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fetch_from": "against_loan.loan_account",
+ "fieldname": "loan_account",
+ "fieldtype": "Link",
+ "label": "Loan Account",
+ "options": "Account",
+ "read_only": 1
+ },
+ {
+ "fetch_from": "against_loan.penalty_income_account",
+ "fieldname": "penalty_income_account",
+ "fieldtype": "Link",
+ "hidden": 1,
+ "label": "Penalty Income Account",
+ "options": "Account"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-06 01:51:06.707782",
+ "modified": "2022-02-18 19:10:07.742298",
"modified_by": "Administrator",
"module": "Loan Management",
"name": "Loan Repayment",
diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
index acf3a65..67c2b1e 100644
--- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
+++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py
@@ -310,7 +310,6 @@
def make_gl_entries(self, cancel=0, adv_adj=0):
gle_map = []
- loan_details = frappe.get_doc("Loan", self.against_loan)
if self.shortfall_amount and self.amount_paid > self.shortfall_amount:
remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(self.shortfall_amount,
@@ -323,13 +322,13 @@
if self.repay_from_salary:
payment_account = self.payroll_payable_account
else:
- payment_account = loan_details.payment_account
+ payment_account = self.payment_account
if self.total_penalty_paid:
gle_map.append(
self.get_gl_dict({
- "account": loan_details.loan_account,
- "against": loan_details.payment_account,
+ "account": self.loan_account,
+ "against": payment_account,
"debit": self.total_penalty_paid,
"debit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan",
@@ -344,8 +343,8 @@
gle_map.append(
self.get_gl_dict({
- "account": loan_details.penalty_income_account,
- "against": payment_account,
+ "account": self.penalty_income_account,
+ "against": self.loan_account,
"credit": self.total_penalty_paid,
"credit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan",
@@ -359,23 +358,24 @@
gle_map.append(
self.get_gl_dict({
"account": payment_account,
- "against": loan_details.loan_account + ", " + loan_details.interest_income_account
- + ", " + loan_details.penalty_income_account,
+ "against": self.loan_account + ", " + self.penalty_income_account,
"debit": self.amount_paid,
"debit_in_account_currency": self.amount_paid,
"against_voucher_type": "Loan",
"against_voucher": self.against_loan,
"remarks": remarks,
"cost_center": self.cost_center,
- "posting_date": getdate(self.posting_date)
+ "posting_date": getdate(self.posting_date),
+ "party_type": self.applicant_type if self.repay_from_salary else '',
+ "party": self.applicant if self.repay_from_salary else ''
})
)
gle_map.append(
self.get_gl_dict({
- "account": loan_details.loan_account,
- "party_type": loan_details.applicant_type,
- "party": loan_details.applicant,
+ "account": self.loan_account,
+ "party_type": self.applicant_type,
+ "party": self.applicant,
"against": payment_account,
"credit": self.amount_paid,
"credit_in_account_currency": self.amount_paid,
diff --git a/erpnext/manufacturing/doctype/job_card/job_card.py b/erpnext/manufacturing/doctype/job_card/job_card.py
index 8d00019..9f4ace2 100644
--- a/erpnext/manufacturing/doctype/job_card/job_card.py
+++ b/erpnext/manufacturing/doctype/job_card/job_card.py
@@ -62,7 +62,7 @@
if self.get('time_logs'):
for d in self.get('time_logs'):
- if get_datetime(d.from_time) > get_datetime(d.to_time):
+ if d.to_time and get_datetime(d.from_time) > get_datetime(d.to_time):
frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx))
data = self.get_overlap_for(d)
diff --git a/erpnext/manufacturing/doctype/production_plan/production_plan.py b/erpnext/manufacturing/doctype/production_plan/production_plan.py
index 4290ca3..80003da 100644
--- a/erpnext/manufacturing/doctype/production_plan/production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/production_plan.py
@@ -28,9 +28,24 @@
class ProductionPlan(Document):
def validate(self):
+ self.set_pending_qty_in_row_without_reference()
self.calculate_total_planned_qty()
self.set_status()
+ def set_pending_qty_in_row_without_reference(self):
+ "Set Pending Qty in independent rows (not from SO or MR)."
+ if self.docstatus > 0: # set only to initialise value before submit
+ return
+
+ for item in self.po_items:
+ if not item.get("sales_order") or not item.get("material_request"):
+ item.pending_qty = item.planned_qty
+
+ def calculate_total_planned_qty(self):
+ self.total_planned_qty = 0
+ for d in self.po_items:
+ self.total_planned_qty += flt(d.planned_qty)
+
def validate_data(self):
for d in self.get('po_items'):
if not d.bom_no:
@@ -263,11 +278,6 @@
'qty': so_detail['qty']
})
- def calculate_total_planned_qty(self):
- self.total_planned_qty = 0
- for d in self.po_items:
- self.total_planned_qty += flt(d.planned_qty)
-
def calculate_total_produced_qty(self):
self.total_produced_qty = 0
for d in self.po_items:
@@ -275,10 +285,11 @@
self.db_set("total_produced_qty", self.total_produced_qty, update_modified=False)
- def update_produced_qty(self, produced_qty, production_plan_item):
+ def update_produced_pending_qty(self, produced_qty, production_plan_item):
for data in self.po_items:
if data.name == production_plan_item:
data.produced_qty = produced_qty
+ data.pending_qty = flt(data.planned_qty - produced_qty)
data.db_update()
self.calculate_total_produced_qty()
@@ -308,7 +319,7 @@
if self.total_produced_qty > 0:
self.status = "In Process"
- if self.check_have_work_orders_completed():
+ if self.all_items_completed():
self.status = "Completed"
if self.status != 'Completed':
@@ -341,6 +352,7 @@
def get_production_items(self):
item_dict = {}
+
for d in self.po_items:
item_details = {
"production_item" : d.item_code,
@@ -357,12 +369,12 @@
"production_plan" : self.name,
"production_plan_item" : d.name,
"product_bundle_item" : d.product_bundle_item,
- "planned_start_date" : d.planned_start_date
+ "planned_start_date" : d.planned_start_date,
+ "project" : self.project
}
- item_details.update({
- "project": self.project or frappe.db.get_value("Sales Order", d.sales_order, "project")
- })
+ if not item_details['project'] and d.sales_order:
+ item_details['project'] = frappe.get_cached_value("Sales Order", d.sales_order, "project")
if self.get_items_from == "Material Request":
item_details.update({
@@ -380,39 +392,59 @@
@frappe.whitelist()
def make_work_order(self):
+ from erpnext.manufacturing.doctype.work_order.work_order import get_default_warehouse
+
wo_list, po_list = [], []
subcontracted_po = {}
+ default_warehouses = get_default_warehouse()
- self.validate_data()
- self.make_work_order_for_finished_goods(wo_list)
- self.make_work_order_for_subassembly_items(wo_list, subcontracted_po)
+ self.make_work_order_for_finished_goods(wo_list, default_warehouses)
+ self.make_work_order_for_subassembly_items(wo_list, subcontracted_po, default_warehouses)
self.make_subcontracted_purchase_order(subcontracted_po, po_list)
self.show_list_created_message('Work Order', wo_list)
self.show_list_created_message('Purchase Order', po_list)
- def make_work_order_for_finished_goods(self, wo_list):
+ def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
items_data = self.get_production_items()
for key, item in items_data.items():
if self.sub_assembly_items:
item['use_multi_level_bom'] = 0
+ set_default_warehouses(item, default_warehouses)
work_order = self.create_work_order(item)
if work_order:
wo_list.append(work_order)
- def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po):
+ def make_work_order_for_subassembly_items(self, wo_list, subcontracted_po, default_warehouses):
for row in self.sub_assembly_items:
if row.type_of_manufacturing == 'Subcontract':
subcontracted_po.setdefault(row.supplier, []).append(row)
continue
- args = {}
- self.prepare_args_for_sub_assembly_items(row, args)
- work_order = self.create_work_order(args)
+ work_order_data = {
+ 'wip_warehouse': default_warehouses.get('wip_warehouse'),
+ 'fg_warehouse': default_warehouses.get('fg_warehouse')
+ }
+
+ self.prepare_data_for_sub_assembly_items(row, work_order_data)
+ work_order = self.create_work_order(work_order_data)
if work_order:
wo_list.append(work_order)
+ def prepare_data_for_sub_assembly_items(self, row, wo_data):
+ for field in ["production_item", "item_name", "qty", "fg_warehouse",
+ "description", "bom_no", "stock_uom", "bom_level",
+ "production_plan_item", "schedule_date"]:
+ if row.get(field):
+ wo_data[field] = row.get(field)
+
+ wo_data.update({
+ "use_multi_level_bom": 0,
+ "production_plan": self.name,
+ "production_plan_sub_assembly_item": row.name
+ })
+
def make_subcontracted_purchase_order(self, subcontracted_po, purchase_orders):
if not subcontracted_po:
return
@@ -423,7 +455,7 @@
po.schedule_date = getdate(po_list[0].schedule_date) if po_list[0].schedule_date else nowdate()
po.is_subcontracted = 'Yes'
for row in po_list:
- args = {
+ po_data = {
'item_code': row.production_item,
'warehouse': row.fg_warehouse,
'production_plan_sub_assembly_item': row.name,
@@ -433,9 +465,9 @@
for field in ['schedule_date', 'qty', 'uom', 'stock_uom', 'item_name',
'description', 'production_plan_item']:
- args[field] = row.get(field)
+ po_data[field] = row.get(field)
- po.append('items', args)
+ po.append('items', po_data)
po.set_missing_values()
po.flags.ignore_mandatory = True
@@ -452,24 +484,9 @@
doc_list = [get_link_to_form(doctype, p) for p in doc_list]
msgprint(_("{0} created").format(comma_and(doc_list)))
- def prepare_args_for_sub_assembly_items(self, row, args):
- for field in ["production_item", "item_name", "qty", "fg_warehouse",
- "description", "bom_no", "stock_uom", "bom_level",
- "production_plan_item", "schedule_date"]:
- args[field] = row.get(field)
-
- args.update({
- "use_multi_level_bom": 0,
- "production_plan": self.name,
- "production_plan_sub_assembly_item": row.name
- })
-
def create_work_order(self, item):
- from erpnext.manufacturing.doctype.work_order.work_order import (
- OverProductionError,
- get_default_warehouse,
- )
- warehouse = get_default_warehouse()
+ from erpnext.manufacturing.doctype.work_order.work_order import OverProductionError
+
wo = frappe.new_doc("Work Order")
wo.update(item)
wo.planned_start_date = item.get('planned_start_date') or item.get('schedule_date')
@@ -478,11 +495,11 @@
wo.fg_warehouse = item.get("warehouse")
wo.set_work_order_operations()
+ wo.set_required_items()
- if not wo.fg_warehouse:
- wo.fg_warehouse = warehouse.get('fg_warehouse')
try:
wo.flags.ignore_mandatory = True
+ wo.flags.ignore_validate = True
wo.insert()
return wo.name
except OverProductionError:
@@ -574,21 +591,32 @@
self.append("sub_assembly_items", data)
- def check_have_work_orders_completed(self):
- wo_status = frappe.db.get_list(
+ def all_items_completed(self):
+ all_items_produced = all(flt(d.planned_qty) - flt(d.produced_qty) < 0.000001
+ for d in self.po_items)
+ if not all_items_produced:
+ return False
+
+ wo_status = frappe.get_all(
"Work Order",
- filters={"production_plan": self.name},
+ filters={
+ "production_plan": self.name,
+ "status": ("not in", ["Closed", "Stopped"]),
+ "docstatus": ("<", 2),
+ },
fields="status",
- pluck="status"
+ pluck="status",
)
- return all(s == "Completed" for s in wo_status)
+ all_work_orders_completed = all(s == "Completed" for s in wo_status)
+ return all_work_orders_completed
@frappe.whitelist()
def download_raw_materials(doc, warehouses=None):
if isinstance(doc, str):
doc = frappe._dict(json.loads(doc))
- item_list = [['Item Code', 'Description', 'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
+ item_list = [['Item Code', 'Item Name', 'Description',
+ 'Stock UOM', 'Warehouse', 'Required Qty as per BOM',
'Projected Qty', 'Available Qty In Hand', 'Ordered Qty', 'Planned Qty',
'Reserved Qty for Production', 'Safety Stock', 'Required Qty']]
@@ -597,7 +625,8 @@
items = get_items_for_material_requests(doc, warehouses=warehouses, get_parent_warehouse_data=True)
for d in items:
- item_list.append([d.get('item_code'), d.get('description'), d.get('stock_uom'), d.get('warehouse'),
+ item_list.append([d.get('item_code'), d.get('item_name'),
+ d.get('description'), d.get('stock_uom'), d.get('warehouse'),
d.get('required_bom_qty'), d.get('projected_qty'), d.get('actual_qty'), d.get('ordered_qty'),
d.get('planned_qty'), d.get('reserved_qty_for_production'), d.get('safety_stock'), d.get('quantity')])
@@ -1023,3 +1052,8 @@
if d.value:
get_sub_assembly_items(d.value, bom_data, stock_qty, indent=indent+1)
+
+def set_default_warehouses(row, default_warehouses):
+ for field in ['wip_warehouse', 'fg_warehouse']:
+ if not row.get(field):
+ row[field] = default_warehouses.get(field)
diff --git a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
index 276e708..d88e10a 100644
--- a/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
+++ b/erpnext/manufacturing/doctype/production_plan/test_production_plan.py
@@ -11,6 +11,7 @@
)
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation,
)
@@ -36,15 +37,21 @@
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
- def test_production_plan(self):
+ def test_production_plan_mr_creation(self):
+ "Test if MRs are created for unavailable raw materials."
pln = create_production_plan(item_code='Test Production Item 1')
self.assertTrue(len(pln.mr_items), 2)
- pln.make_material_request()
- pln = frappe.get_doc('Production Plan', pln.name)
+ pln.make_material_request()
+ pln.reload()
self.assertTrue(pln.status, 'Material Requested')
- material_requests = frappe.get_all('Material Request Item', fields = ['distinct parent'],
- filters = {'production_plan': pln.name}, as_list=1)
+
+ material_requests = frappe.get_all(
+ 'Material Request Item',
+ fields = ['distinct parent'],
+ filters = {'production_plan': pln.name},
+ as_list=1
+ )
self.assertTrue(len(material_requests), 2)
@@ -66,27 +73,42 @@
pln.cancel()
def test_production_plan_start_date(self):
+ "Test if Work Order has same Planned Start Date as Prod Plan."
planned_date = add_to_date(date=None, days=3)
- plan = create_production_plan(item_code='Test Production Item 1', planned_start_date=planned_date)
+ plan = create_production_plan(
+ item_code='Test Production Item 1',
+ planned_start_date=planned_date
+ )
plan.make_work_order()
- work_orders = frappe.get_all('Work Order', fields = ['name', 'planned_start_date'],
- filters = {'production_plan': plan.name})
+ work_orders = frappe.get_all(
+ 'Work Order',
+ fields = ['name', 'planned_start_date'],
+ filters = {'production_plan': plan.name}
+ )
self.assertEqual(work_orders[0].planned_start_date, planned_date)
for wo in work_orders:
frappe.delete_doc('Work Order', wo.name)
- frappe.get_doc('Production Plan', plan.name).cancel()
+ plan.reload()
+ plan.cancel()
def test_production_plan_for_existing_ordered_qty(self):
+ """
+ - Enable 'ignore_existing_ordered_qty'.
+ - Test if MR Planning table pulls Raw Material Qty even if it is in stock.
+ """
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=110)
sr2 = create_stock_reconciliation(item_code="Raw Material Item 2",
target="_Test Warehouse - _TC", qty=1, rate=120)
- pln = create_production_plan(item_code='Test Production Item 1', ignore_existing_ordered_qty=0)
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ ignore_existing_ordered_qty=1
+ )
self.assertTrue(len(pln.mr_items), 1)
self.assertTrue(flt(pln.mr_items[0].quantity), 1.0)
@@ -95,23 +117,39 @@
pln.cancel()
def test_production_plan_with_non_stock_item(self):
- pln = create_production_plan(item_code='Test Production Item 1', include_non_stock_items=0)
+ "Test if MR Planning table includes Non Stock RM."
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ include_non_stock_items=1
+ )
self.assertTrue(len(pln.mr_items), 3)
pln.cancel()
def test_production_plan_without_multi_level(self):
- pln = create_production_plan(item_code='Test Production Item 1', use_multi_level_bom=0)
+ "Test MR Planning table for non exploded BOM."
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ use_multi_level_bom=0
+ )
self.assertTrue(len(pln.mr_items), 2)
pln.cancel()
def test_production_plan_without_multi_level_for_existing_ordered_qty(self):
+ """
+ - Disable 'ignore_existing_ordered_qty'.
+ - Test if MR Planning table avoids pulling Raw Material Qty as it is in stock for
+ non exploded BOM.
+ """
sr1 = create_stock_reconciliation(item_code="Raw Material Item 1",
target="_Test Warehouse - _TC", qty=1, rate=130)
sr2 = create_stock_reconciliation(item_code="Subassembly Item 1",
target="_Test Warehouse - _TC", qty=1, rate=140)
- pln = create_production_plan(item_code='Test Production Item 1',
- use_multi_level_bom=0, ignore_existing_ordered_qty=0)
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ use_multi_level_bom=0,
+ ignore_existing_ordered_qty=0
+ )
self.assertTrue(len(pln.mr_items), 0)
sr1.cancel()
@@ -119,6 +157,7 @@
pln.cancel()
def test_production_plan_sales_orders(self):
+ "Test if previously fulfilled SO (with WO) is pulled into Prod Plan."
item = 'Test Production Item 1'
so = make_sales_order(item_code=item, qty=1)
sales_order = so.name
@@ -166,24 +205,25 @@
self.assertEqual(sales_orders, [])
def test_production_plan_combine_items(self):
+ "Test combining FG items in Production Plan."
item = 'Test Production Item 1'
- so = make_sales_order(item_code=item, qty=1)
+ so1 = make_sales_order(item_code=item, qty=1)
pln = frappe.new_doc('Production Plan')
- pln.company = so.company
+ pln.company = so1.company
pln.get_items_from = 'Sales Order'
pln.append('sales_orders', {
- 'sales_order': so.name,
- 'sales_order_date': so.transaction_date,
- 'customer': so.customer,
- 'grand_total': so.grand_total
+ 'sales_order': so1.name,
+ 'sales_order_date': so1.transaction_date,
+ 'customer': so1.customer,
+ 'grand_total': so1.grand_total
})
- so = make_sales_order(item_code=item, qty=2)
+ so2 = make_sales_order(item_code=item, qty=2)
pln.append('sales_orders', {
- 'sales_order': so.name,
- 'sales_order_date': so.transaction_date,
- 'customer': so.customer,
- 'grand_total': so.grand_total
+ 'sales_order': so2.name,
+ 'sales_order_date': so2.transaction_date,
+ 'customer': so2.customer,
+ 'grand_total': so2.grand_total
})
pln.combine_items = 1
pln.get_items()
@@ -214,28 +254,37 @@
so_wo_qty = frappe.db.get_value('Sales Order Item', so_item, 'work_order_qty')
self.assertEqual(so_wo_qty, 0.0)
- latest_plan = frappe.get_doc('Production Plan', pln.name)
- latest_plan.cancel()
+ pln.reload()
+ pln.cancel()
def test_pp_to_mr_customer_provided(self):
- #Material Request from Production Plan for Customer Provided
+ " Test Material Request from Production Plan for Customer Provided Item."
create_item('CUST-0987', is_customer_provided_item = 1, customer = '_Test Customer', is_purchase_item = 0)
create_item('Production Item CUST')
+
for item, raw_materials in {'Production Item CUST': ['Raw Material Item 1', 'CUST-0987']}.items():
if not frappe.db.get_value('BOM', {'item': item}):
make_bom(item = item, raw_materials = raw_materials)
production_plan = create_production_plan(item_code = 'Production Item CUST')
production_plan.make_material_request()
- material_request = frappe.db.get_value('Material Request Item', {'production_plan': production_plan.name, 'item_code': 'CUST-0987'}, 'parent')
+
+ material_request = frappe.db.get_value(
+ 'Material Request Item',
+ {'production_plan': production_plan.name, 'item_code': 'CUST-0987'},
+ 'parent'
+ )
mr = frappe.get_doc('Material Request', material_request)
+
self.assertTrue(mr.material_request_type, 'Customer Provided')
self.assertTrue(mr.customer, '_Test Customer')
def test_production_plan_with_multi_level_bom(self):
- #|Item Code | Qty |
- #|Test BOM 1 | 1 |
- #| Test BOM 2 | 2 |
- #| Test BOM 3 | 3 |
+ """
+ Item Code | Qty |
+ |Test BOM 1 | 1 |
+ |Test BOM 2 | 2 |
+ |Test BOM 3 | 3 |
+ """
for item_code in ["Test BOM 1", "Test BOM 2", "Test BOM 3", "Test RM BOM 1"]:
create_item(item_code, is_stock_item=1)
@@ -264,15 +313,18 @@
pln.make_work_order()
#last level sub-assembly work order produce qty
- to_produce_qty = frappe.db.get_value("Work Order",
- {"production_plan": pln.name, "production_item": "Test BOM 3"}, "qty")
+ to_produce_qty = frappe.db.get_value(
+ "Work Order",
+ {"production_plan": pln.name, "production_item": "Test BOM 3"},
+ "qty"
+ )
self.assertEqual(to_produce_qty, 18.0)
pln.cancel()
frappe.delete_doc("Production Plan", pln.name)
def test_get_warehouse_list_group(self):
- """Check if required warehouses are returned"""
+ "Check if required child warehouses are returned."
warehouse_json = '[{\"warehouse\":\"_Test Warehouse Group - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -284,6 +336,7 @@
msg=f"Following warehouses were expected {', '.join(missing_warehouse)}")
def test_get_warehouse_list_single(self):
+ "Check if same warehouse is returned in absence of child warehouses."
warehouse_json = '[{\"warehouse\":\"_Test Scrap Warehouse - _TC\"}]'
warehouses = set(get_warehouse_list(warehouse_json))
@@ -292,6 +345,7 @@
self.assertEqual(warehouses, expected_warehouses)
def test_get_sales_order_with_variant(self):
+ "Check if Template BOM is fetched in absence of Variant BOM."
rm_item = create_item('PIV_RM', valuation_rate = 100)
if not frappe.db.exists('Item', {"item_code": 'PIV'}):
item = create_item('PIV', valuation_rate = 100)
@@ -348,16 +402,13 @@
frappe.db.rollback()
def test_subassmebly_sorting(self):
- """ Test subassembly sorting in case of multiple items with nested BOMs"""
+ "Test subassembly sorting in case of multiple items with nested BOMs."
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
prefix = "_TestLevel_"
boms = {
"Assembly": {
"SubAssembly1": {"ChildPart1": {}, "ChildPart2": {},},
- "SubAssembly2": {"ChildPart3": {}},
- "SubAssembly3": {"SubSubAssy1": {"ChildPart4": {}}},
- "ChildPart5": {},
"ChildPart6": {},
"SubAssembly4": {"SubSubAssy2": {"ChildPart7": {}}},
},
@@ -386,6 +437,7 @@
self.assertIn("SuperSecret", plan.sub_assembly_items[0].production_item)
def test_multiple_work_order_for_production_plan_item(self):
+ "Test producing Prod Plan (making WO) in parts."
def create_work_order(item, pln, qty):
# Get Production Items
items_data = pln.get_production_items()
@@ -441,7 +493,121 @@
pln.reload()
self.assertEqual(pln.po_items[0].ordered_qty, 0)
+ def test_production_plan_pending_qty_with_sales_order(self):
+ """
+ Test Prod Plan impact via: SO -> Prod Plan -> WO -> SE -> SE (cancel)
+ """
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+
+ make_stock_entry(item_code="Raw Material Item 1",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+ make_stock_entry(item_code="Raw Material Item 2",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+
+ item = 'Test Production Item 1'
+ so = make_sales_order(item_code=item, qty=1)
+
+ pln = create_production_plan(
+ company=so.company,
+ get_items_from="Sales Order",
+ sales_order=so,
+ skip_getting_mr_items=True
+ )
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ wo = make_wo_order_test_record(
+ item_code=item, qty=1,
+ company=so.company,
+ wip_warehouse='Work In Progress - _TC',
+ fg_warehouse='Finished Goods - _TC',
+ skip_transfer=1,
+ do_not_submit=True
+ )
+ wo.production_plan = pln.name
+ wo.production_plan_item = pln.po_items[0].name
+ wo.submit()
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
+ se.submit()
+
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 0)
+
+ se.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ def test_production_plan_pending_qty_independent_items(self):
+ "Test Prod Plan impact if items are added independently (no from SO or MR)."
+ from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+
+ make_stock_entry(item_code="Raw Material Item 1",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+ make_stock_entry(item_code="Raw Material Item 2",
+ target="Work In Progress - _TC",
+ qty=2, basic_rate=100
+ )
+
+ pln = create_production_plan(
+ item_code='Test Production Item 1',
+ skip_getting_mr_items=True
+ )
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ wo = make_wo_order_test_record(
+ item_code='Test Production Item 1', qty=1,
+ company=pln.company,
+ wip_warehouse='Work In Progress - _TC',
+ fg_warehouse='Finished Goods - _TC',
+ skip_transfer=1,
+ do_not_submit=True
+ )
+ wo.production_plan = pln.name
+ wo.production_plan_item = pln.po_items[0].name
+ wo.submit()
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 1))
+ se.submit()
+
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 0)
+
+ se.cancel()
+ pln.reload()
+ self.assertEqual(pln.po_items[0].pending_qty, 1)
+
+ def test_qty_based_status(self):
+ pp = frappe.new_doc("Production Plan")
+ pp.po_items = [
+ frappe._dict(planned_qty=5, produce_qty=4)
+ ]
+ self.assertFalse(pp.all_items_completed())
+
+ pp.po_items = [
+ frappe._dict(planned_qty=5, produce_qty=10),
+ frappe._dict(planned_qty=5, produce_qty=4)
+ ]
+ self.assertFalse(pp.all_items_completed())
+
+
def create_production_plan(**args):
+ """
+ sales_order (obj): Sales Order Doc Object
+ get_items_from (str): Sales Order/Material Request
+ skip_getting_mr_items (bool): Whether or not to plan for new MRs
+ """
args = frappe._dict(args)
pln = frappe.get_doc({
@@ -449,20 +615,35 @@
'company': args.company or '_Test Company',
'customer': args.customer or '_Test Customer',
'posting_date': nowdate(),
- 'include_non_stock_items': args.include_non_stock_items or 1,
- 'include_subcontracted_items': args.include_subcontracted_items or 1,
- 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 1,
- 'po_items': [{
+ 'include_non_stock_items': args.include_non_stock_items or 0,
+ 'include_subcontracted_items': args.include_subcontracted_items or 0,
+ 'ignore_existing_ordered_qty': args.ignore_existing_ordered_qty or 0,
+ 'get_items_from': 'Sales Order'
+ })
+
+ if not args.get("sales_order"):
+ pln.append('po_items', {
'use_multi_level_bom': args.use_multi_level_bom or 1,
'item_code': args.item_code,
'bom_no': frappe.db.get_value('Item', args.item_code, 'default_bom'),
'planned_qty': args.planned_qty or 1,
'planned_start_date': args.planned_start_date or now_datetime()
- }]
- })
- mr_items = get_items_for_material_requests(pln.as_dict())
- for d in mr_items:
- pln.append('mr_items', d)
+ })
+
+ if args.get("get_items_from") == "Sales Order" and args.get("sales_order"):
+ so = args.get("sales_order")
+ pln.append('sales_orders', {
+ 'sales_order': so.name,
+ 'sales_order_date': so.transaction_date,
+ 'customer': so.customer,
+ 'grand_total': so.grand_total
+ })
+ pln.get_items()
+
+ if not args.get("skip_getting_mr_items"):
+ mr_items = get_items_for_material_requests(pln.as_dict())
+ for d in mr_items:
+ pln.append('mr_items', d)
if not args.do_not_save:
pln.insert()
diff --git a/erpnext/manufacturing/doctype/work_order/work_order.py b/erpnext/manufacturing/doctype/work_order/work_order.py
index 7315249..ed6a029 100644
--- a/erpnext/manufacturing/doctype/work_order/work_order.py
+++ b/erpnext/manufacturing/doctype/work_order/work_order.py
@@ -76,7 +76,6 @@
self.set_required_items(reset_only_qty = len(self.get("required_items")))
-
def validate_sales_order(self):
if self.sales_order:
self.check_sales_order_on_hold_or_close()
@@ -273,7 +272,7 @@
produced_qty = total_qty[0][0] if total_qty else 0
- production_plan.run_method("update_produced_qty", produced_qty, self.production_plan_item)
+ production_plan.run_method("update_produced_pending_qty", produced_qty, self.production_plan_item)
def before_submit(self):
self.create_serial_no_batch_no()
@@ -546,7 +545,7 @@
if node.is_bom:
operations.extend(_get_operations(node.name, qty=node.exploded_qty))
- bom_qty = frappe.db.get_value("BOM", self.bom_no, "quantity")
+ bom_qty = frappe.get_cached_value("BOM", self.bom_no, "quantity")
operations.extend(_get_operations(self.bom_no, qty=1.0/bom_qty))
for correct_index, operation in enumerate(operations, start=1):
@@ -627,7 +626,7 @@
frappe.delete_doc("Job Card", d.name)
def validate_production_item(self):
- if frappe.db.get_value("Item", self.production_item, "has_variants"):
+ if frappe.get_cached_value("Item", self.production_item, "has_variants"):
frappe.throw(_("Work Order cannot be raised against a Item Template"), ItemHasVariantError)
if self.production_item:
diff --git a/erpnext/manufacturing/report/test_reports.py b/erpnext/manufacturing/report/test_reports.py
index 9f51ded..e436fdc 100644
--- a/erpnext/manufacturing/report/test_reports.py
+++ b/erpnext/manufacturing/report/test_reports.py
@@ -55,10 +55,11 @@
def test_execute_all_manufacturing_reports(self):
"""Test that all script report in manufacturing modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES:
- execute_script_report(
- report_name=report,
- module="Manufacturing",
- filters=filter,
- default_filters=DEFAULT_FILTERS,
- optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
- )
+ with self.subTest(report=report):
+ execute_script_report(
+ report_name=report,
+ module="Manufacturing",
+ filters=filter,
+ default_filters=DEFAULT_FILTERS,
+ optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
+ )
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index d300340..7560f2f 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -329,7 +329,6 @@
erpnext.patches.v14_0.set_payroll_cost_centers
erpnext.patches.v13_0.agriculture_deprecation_warning
erpnext.patches.v13_0.hospitality_deprecation_warning
-erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v13_0.update_asset_quantity_field
erpnext.patches.v13_0.delete_bank_reconciliation_detail
erpnext.patches.v13_0.enable_provisional_accounting
@@ -351,3 +350,8 @@
erpnext.patches.v13_0.shopping_cart_to_ecommerce
erpnext.patches.v13_0.update_disbursement_account
erpnext.patches.v13_0.update_reserved_qty_closed_wo
+erpnext.patches.v13_0.update_exchange_rate_settings
+erpnext.patches.v14_0.delete_amazon_mws_doctype
+erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
+erpnext.patches.v13_0.update_accounts_in_loan_docs
+erpnext.patches.v14_0.update_batch_valuation_flag
diff --git a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py
index 9b083ca..8dec9ff 100644
--- a/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py
+++ b/erpnext/patches/v12_0/recalculate_requested_qty_in_bin.py
@@ -9,6 +9,8 @@
FROM `tabBin`""",as_dict=1)
for entry in bin_details:
+ if not (entry.item_code and entry.warehouse):
+ continue
update_bin_qty(entry.get("item_code"), entry.get("warehouse"), {
"indented_qty": get_indented_qty(entry.get("item_code"), entry.get("warehouse"))
})
diff --git a/erpnext/patches/v12_0/rename_mws_settings_fields.py b/erpnext/patches/v12_0/rename_mws_settings_fields.py
deleted file mode 100644
index d5bf38d..0000000
--- a/erpnext/patches/v12_0/rename_mws_settings_fields.py
+++ /dev/null
@@ -1,12 +0,0 @@
-# Copyright (c) 2020, Frappe and Contributors
-# License: GNU General Public License v3. See license.txt
-
-import frappe
-
-
-def execute():
- count = frappe.db.sql("SELECT COUNT(*) FROM `tabSingles` WHERE doctype='Amazon MWS Settings' AND field='enable_sync';")[0][0]
- if count == 0:
- frappe.db.sql("UPDATE `tabSingles` SET field='enable_sync' WHERE doctype='Amazon MWS Settings' AND field='enable_synch';")
-
- frappe.reload_doc("ERPNext Integrations", "doctype", "Amazon MWS Settings")
diff --git a/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
new file mode 100644
index 0000000..f097ab9
--- /dev/null
+++ b/erpnext/patches/v13_0/set_work_order_qty_in_so_from_mr.py
@@ -0,0 +1,36 @@
+import frappe
+
+
+def execute():
+ """
+ 1. Get submitted Work Orders with MR, MR Item and SO set
+ 2. Get SO Item detail from MR Item detail in WO, and set in WO
+ 3. Update work_order_qty in SO
+ """
+ work_order = frappe.qb.DocType("Work Order")
+ query = (
+ frappe.qb.from_(work_order)
+ .select(
+ work_order.name, work_order.produced_qty,
+ work_order.material_request,
+ work_order.material_request_item,
+ work_order.sales_order
+ ).where(
+ (work_order.material_request.isnotnull())
+ & (work_order.material_request_item.isnotnull())
+ & (work_order.sales_order.isnotnull())
+ & (work_order.docstatus == 1)
+ & (work_order.produced_qty > 0)
+ )
+ )
+ results = query.run(as_dict=True)
+
+ for row in results:
+ so_item = frappe.get_value(
+ "Material Request Item", row.material_request_item, "sales_order_item"
+ )
+ frappe.db.set_value("Work Order", row.name, "sales_order_item", so_item)
+
+ if so_item:
+ wo = frappe.get_doc("Work Order", row.name)
+ wo.update_work_order_qty_in_so()
diff --git a/erpnext/patches/v13_0/update_accounts_in_loan_docs.py b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py
new file mode 100644
index 0000000..440f912
--- /dev/null
+++ b/erpnext/patches/v13_0/update_accounts_in_loan_docs.py
@@ -0,0 +1,37 @@
+import frappe
+
+
+def execute():
+ ld = frappe.qb.DocType("Loan Disbursement").as_("ld")
+ lr = frappe.qb.DocType("Loan Repayment").as_("lr")
+ loan = frappe.qb.DocType("Loan")
+
+ frappe.qb.update(
+ ld
+ ).inner_join(
+ loan
+ ).on(
+ loan.name == ld.against_loan
+ ).set(
+ ld.disbursement_account, loan.disbursement_account
+ ).set(
+ ld.loan_account, loan.loan_account
+ ).where(
+ ld.docstatus < 2
+ ).run()
+
+ frappe.qb.update(
+ lr
+ ).inner_join(
+ loan
+ ).on(
+ loan.name == lr.against_loan
+ ).set(
+ lr.payment_account, loan.payment_account
+ ).set(
+ lr.loan_account, loan.loan_account
+ ).set(
+ lr.penalty_income_account, loan.penalty_income_account
+ ).where(
+ lr.docstatus < 2
+ ).run()
diff --git a/erpnext/patches/v14_0/delete_amazon_mws_doctype.py b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py
new file mode 100644
index 0000000..525da6c
--- /dev/null
+++ b/erpnext/patches/v14_0/delete_amazon_mws_doctype.py
@@ -0,0 +1,5 @@
+import frappe
+
+
+def execute():
+ frappe.delete_doc("DocType", "Amazon MWS Settings", ignore_missing=True)
\ No newline at end of file
diff --git a/erpnext/patches/v14_0/update_batch_valuation_flag.py b/erpnext/patches/v14_0/update_batch_valuation_flag.py
new file mode 100644
index 0000000..55c8c48
--- /dev/null
+++ b/erpnext/patches/v14_0/update_batch_valuation_flag.py
@@ -0,0 +1,11 @@
+import frappe
+
+
+def execute():
+ """
+ - Don't use batchwise valuation for existing batches.
+ - Only batches created after this patch shoule use it.
+ """
+
+ batch = frappe.qb.DocType("Batch")
+ frappe.qb.update(batch).set(batch.use_batchwise_valuation, 0).run()
diff --git a/erpnext/patches/v14_0/update_opportunity_currency_fields.py b/erpnext/patches/v14_0/update_opportunity_currency_fields.py
index 1307147..75049a6 100644
--- a/erpnext/patches/v14_0/update_opportunity_currency_fields.py
+++ b/erpnext/patches/v14_0/update_opportunity_currency_fields.py
@@ -6,9 +6,6 @@
def execute():
- frappe.reload_doc('crm', 'doctype', 'opportunity')
- frappe.reload_doc('crm', 'doctype', 'opportunity_item')
-
opportunities = frappe.db.get_list('Opportunity', filters={
'opportunity_amount': ['>', 0]
}, fields=['name', 'company', 'currency', 'opportunity_amount'])
@@ -20,15 +17,11 @@
if opportunity.currency != company_currency:
conversion_rate = get_exchange_rate(opportunity.currency, company_currency)
base_opportunity_amount = flt(conversion_rate) * flt(opportunity.opportunity_amount)
- grand_total = flt(opportunity.opportunity_amount)
- base_grand_total = flt(conversion_rate) * flt(opportunity.opportunity_amount)
else:
conversion_rate = 1
- base_opportunity_amount = grand_total = base_grand_total = flt(opportunity.opportunity_amount)
+ base_opportunity_amount = flt(opportunity.opportunity_amount)
frappe.db.set_value('Opportunity', opportunity.name, {
'conversion_rate': conversion_rate,
- 'base_opportunity_amount': base_opportunity_amount,
- 'grand_total': grand_total,
- 'base_grand_total': base_grand_total
+ 'base_opportunity_amount': base_opportunity_amount
}, update_modified=False)
diff --git a/erpnext/patches/v4_2/repost_reserved_qty.py b/erpnext/patches/v4_2/repost_reserved_qty.py
index c2ca9be..ed4b19d 100644
--- a/erpnext/patches/v4_2/repost_reserved_qty.py
+++ b/erpnext/patches/v4_2/repost_reserved_qty.py
@@ -29,9 +29,11 @@
""")
for item_code, warehouse in repost_for:
- update_bin_qty(item_code, warehouse, {
- "reserved_qty": get_reserved_qty(item_code, warehouse)
- })
+ if not (item_code and warehouse):
+ continue
+ update_bin_qty(item_code, warehouse, {
+ "reserved_qty": get_reserved_qty(item_code, warehouse)
+ })
frappe.db.sql("""delete from tabBin
where exists(
diff --git a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py
index 42b0b04..dd79410 100644
--- a/erpnext/patches/v4_2/update_requested_and_ordered_qty.py
+++ b/erpnext/patches/v4_2/update_requested_and_ordered_qty.py
@@ -14,6 +14,8 @@
union
select item_code, warehouse from `tabStock Ledger Entry`) a"""):
try:
+ if not (item_code and warehouse):
+ continue
count += 1
update_bin_qty(item_code, warehouse, {
"indented_qty": get_indented_qty(item_code, warehouse),
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.js b/erpnext/payroll/doctype/gratuity/gratuity.js
index d4f7c9c..3d69c46 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.js
+++ b/erpnext/payroll/doctype/gratuity/gratuity.js
@@ -3,6 +3,14 @@
frappe.ui.form.on('Gratuity', {
setup: function (frm) {
+ frm.set_query("salary_component", function () {
+ return {
+ filters: {
+ type: "Earning"
+ }
+ };
+ });
+
frm.set_query("expense_account", function () {
return {
filters: {
@@ -24,7 +32,7 @@
});
},
refresh: function (frm) {
- if (frm.doc.docstatus == 1 && frm.doc.status == "Unpaid") {
+ if (frm.doc.docstatus == 1 && !frm.doc.pay_via_salary_slip && frm.doc.status == "Unpaid") {
frm.add_custom_button(__("Create Payment Entry"), function () {
return frappe.call({
method: 'erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry',
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.json b/erpnext/payroll/doctype/gratuity/gratuity.json
index 1970895..1fd1cec 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.json
+++ b/erpnext/payroll/doctype/gratuity/gratuity.json
@@ -1,7 +1,7 @@
{
"actions": [],
"autoname": "HR-GRA-PAY-.#####",
- "creation": "2020-08-05 20:52:13.024683",
+ "creation": "2022-01-27 16:24:28.200061",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
@@ -16,6 +16,9 @@
"company",
"gratuity_rule",
"section_break_5",
+ "pay_via_salary_slip",
+ "payroll_date",
+ "salary_component",
"payable_account",
"expense_account",
"mode_of_payment",
@@ -78,18 +81,20 @@
"reqd": 1
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "expense_account",
"fieldtype": "Link",
"label": "Expense Account",
- "options": "Account",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Account"
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "mode_of_payment",
"fieldtype": "Link",
"label": "Mode of Payment",
- "options": "Mode of Payment",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Mode of Payment"
},
{
"fieldname": "gratuity_rule",
@@ -151,23 +156,45 @@
"read_only": 1
},
{
+ "depends_on": "eval: !doc.pay_via_salary_slip",
"fieldname": "payable_account",
"fieldtype": "Link",
"label": "Payable Account",
- "options": "Account",
- "reqd": 1
+ "mandatory_depends_on": "eval: !doc.pay_via_salary_slip",
+ "options": "Account"
},
{
"fieldname": "cost_center",
"fieldtype": "Link",
"label": "Cost Center",
"options": "Cost Center"
+ },
+ {
+ "default": "1",
+ "fieldname": "pay_via_salary_slip",
+ "fieldtype": "Check",
+ "label": "Pay via Salary Slip"
+ },
+ {
+ "depends_on": "pay_via_salary_slip",
+ "fieldname": "payroll_date",
+ "fieldtype": "Date",
+ "label": "Payroll Date",
+ "mandatory_depends_on": "pay_via_salary_slip"
+ },
+ {
+ "depends_on": "pay_via_salary_slip",
+ "fieldname": "salary_component",
+ "fieldtype": "Link",
+ "label": "Salary Component",
+ "mandatory_depends_on": "pay_via_salary_slip",
+ "options": "Salary Component"
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
- "modified": "2022-01-19 12:54:37.306145",
+ "modified": "2022-02-02 14:00:45.536152",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Gratuity",
diff --git a/erpnext/payroll/doctype/gratuity/gratuity.py b/erpnext/payroll/doctype/gratuity/gratuity.py
index 476990a..939634a 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/gratuity.py
@@ -21,7 +21,10 @@
self.status = "Unpaid"
def on_submit(self):
- self.create_gl_entries()
+ if self.pay_via_salary_slip:
+ self.create_additional_salary()
+ else:
+ self.create_gl_entries()
def on_cancel(self):
self.ignore_linked_doctypes = ['GL Entry']
@@ -64,6 +67,19 @@
return gl_entry
+ def create_additional_salary(self):
+ if self.pay_via_salary_slip:
+ additional_salary = frappe.new_doc('Additional Salary')
+ additional_salary.employee = self.employee
+ additional_salary.salary_component = self.salary_component
+ additional_salary.overwrite_salary_structure_amount = 0
+ additional_salary.amount = self.amount
+ additional_salary.payroll_date = self.payroll_date
+ additional_salary.company = self.company
+ additional_salary.ref_doctype = self.doctype
+ additional_salary.ref_docname = self.name
+ additional_salary.submit()
+
def set_total_advance_paid(self):
paid_amount = frappe.db.sql("""
select ifnull(sum(debit_in_account_currency), 0) as paid_amount
diff --git a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
index aeadba1..771a6fe 100644
--- a/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
+++ b/erpnext/payroll/doctype/gratuity/gratuity_dashboard.py
@@ -10,7 +10,7 @@
'transactions': [
{
'label': _('Payment'),
- 'items': ['Payment Entry']
+ 'items': ['Payment Entry', 'Additional Salary']
}
]
}
diff --git a/erpnext/payroll/doctype/gratuity/test_gratuity.py b/erpnext/payroll/doctype/gratuity/test_gratuity.py
index 93cba06..90e8061 100644
--- a/erpnext/payroll/doctype/gratuity/test_gratuity.py
+++ b/erpnext/payroll/doctype/gratuity/test_gratuity.py
@@ -18,27 +18,25 @@
test_dependencies = ["Salary Component", "Salary Slip", "Account"]
class TestGratuity(unittest.TestCase):
- @classmethod
- def setUpClass(cls):
+ def setUp(self):
+ frappe.db.delete("Gratuity")
+ frappe.db.delete("Additional Salary", {"ref_doctype": "Gratuity"})
+
make_earning_salary_component(setup=True, test_tax=True, company_list=['_Test Company'])
make_deduction_salary_component(setup=True, test_tax=True, company_list=['_Test Company'])
- def setUp(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
-
def test_get_last_salary_slip_should_return_none_for_new_employee(self):
new_employee = make_employee("new_employee@salary.com", company='_Test Company')
salary_slip = get_last_salary_slip(new_employee)
assert salary_slip is None
- def test_check_gratuity_amount_based_on_current_slab(self):
+ def test_check_gratuity_amount_based_on_current_slab_and_additional_salary_creation(self):
employee, sal_slip = create_employee_and_get_last_salary_slip()
rule = get_gratuity_rule("Rule Under Unlimited Contract on termination (UAE)")
+ gratuity = create_gratuity(pay_via_salary_slip=1, employee=employee, rule=rule.name)
- gratuity = create_gratuity(employee=employee, rule=rule.name)
-
- #work experience calculation
+ # work experience calculation
date_of_joining, relieving_date = frappe.db.get_value('Employee', employee, ['date_of_joining', 'relieving_date'])
employee_total_workings_days = (get_datetime(relieving_date) - get_datetime(date_of_joining)).days
@@ -64,6 +62,9 @@
self.assertEqual(flt(gratuity_amount, 2), flt(gratuity.amount, 2))
+ # additional salary creation (Pay via salary slip)
+ self.assertTrue(frappe.db.exists("Additional Salary", {"ref_docname": gratuity.name}))
+
def test_check_gratuity_amount_based_on_all_previous_slabs(self):
employee, sal_slip = create_employee_and_get_last_salary_slip()
rule = get_gratuity_rule("Rule Under Limited Contract (UAE)")
@@ -117,8 +118,8 @@
self.assertEqual(flt(gratuity.paid_amount,2), flt(gratuity.amount, 2))
def tearDown(self):
- frappe.db.sql("DELETE FROM `tabGratuity`")
- frappe.db.sql("DELETE FROM `tabAdditional Salary` WHERE ref_doctype = 'Gratuity'")
+ frappe.db.rollback()
+
def get_gratuity_rule(name):
rule = frappe.db.exists("Gratuity Rule", name)
@@ -141,9 +142,14 @@
gratuity.employee = args.employee
gratuity.posting_date = getdate()
gratuity.gratuity_rule = args.rule or "Rule Under Limited Contract (UAE)"
- gratuity.expense_account = args.expense_account or 'Payment Account - _TC'
- gratuity.payable_account = args.payable_account or get_payable_account("_Test Company")
- gratuity.mode_of_payment = args.mode_of_payment or 'Cash'
+ gratuity.pay_via_salary_slip = args.pay_via_salary_slip or 0
+ if gratuity.pay_via_salary_slip:
+ gratuity.payroll_date = getdate()
+ gratuity.salary_component = "Performance Bonus"
+ else:
+ gratuity.expense_account = args.expense_account or 'Payment Account - _TC'
+ gratuity.payable_account = args.payable_account or get_payable_account("_Test Company")
+ gratuity.mode_of_payment = args.mode_of_payment or 'Cash'
gratuity.save()
gratuity.submit()
diff --git a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
index db88c06..a634dfe 100644
--- a/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/payroll_entry.py
@@ -527,11 +527,12 @@
""" % cond, {"sal_struct": tuple(sal_struct), "from_date": end_date, "payroll_payable_account": payroll_payable_account}, as_dict=True)
def remove_payrolled_employees(emp_list, start_date, end_date):
+ new_emp_list = []
for employee_details in emp_list:
- if frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
- emp_list.remove(employee_details)
+ if not frappe.db.exists("Salary Slip", {"employee": employee_details.employee, "start_date": start_date, "end_date": end_date, "docstatus": 1}):
+ new_emp_list.append(employee_details)
- return emp_list
+ return new_emp_list
@frappe.whitelist()
def get_start_end_dates(payroll_frequency, start_date=None, company=None):
diff --git a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
index 5f836db..3b7f4b2 100644
--- a/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
+++ b/erpnext/payroll/doctype/payroll_entry/test_payroll_entry.py
@@ -124,7 +124,7 @@
if not frappe.db.exists("Account", "_Test Payroll Payable - _TC"):
create_account(account_name="_Test Payroll Payable",
- company="_Test Company", parent_account="Current Liabilities - _TC")
+ company="_Test Company", parent_account="Current Liabilities - _TC", account_type="Payable")
if not frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") or \
frappe.db.get_value("Company", "_Test Company", "default_payroll_payable_account") != "_Test Payroll Payable - _TC":
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index f727ff4..d2a3998 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -1268,7 +1268,7 @@
for i, earning in enumerate(self.earnings):
if earning.salary_component == salary_component:
self.earnings[i].amount = wages_amount
- self.gross_pay += self.earnings[i].amount
+ self.gross_pay += flt(self.earnings[i].amount, earning.precision("amount"))
self.net_pay = flt(self.gross_pay) - flt(self.total_deduction)
def compute_year_to_date(self):
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 30b604b..6a5debf 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -6,6 +6,7 @@
import unittest
import frappe
+from frappe.model.document import Document
from frappe.utils import (
add_days,
add_months,
@@ -687,20 +688,25 @@
def make_salary_component(salary_components, test_tax, company_list=None):
for salary_component in salary_components:
- if not frappe.db.exists('Salary Component', salary_component["salary_component"]):
- if test_tax:
- if salary_component["type"] == "Earning":
- salary_component["is_tax_applicable"] = 1
- elif salary_component["salary_component"] == "TDS":
- salary_component["variable_based_on_taxable_salary"] = 1
- salary_component["amount_based_on_formula"] = 0
- salary_component["amount"] = 0
- salary_component["formula"] = ""
- salary_component["condition"] = ""
- salary_component["doctype"] = "Salary Component"
- salary_component["salary_component_abbr"] = salary_component["abbr"]
- frappe.get_doc(salary_component).insert()
- get_salary_component_account(salary_component["salary_component"], company_list)
+ if frappe.db.exists('Salary Component', salary_component["salary_component"]):
+ continue
+
+ if test_tax:
+ if salary_component["type"] == "Earning":
+ salary_component["is_tax_applicable"] = 1
+ elif salary_component["salary_component"] == "TDS":
+ salary_component["variable_based_on_taxable_salary"] = 1
+ salary_component["amount_based_on_formula"] = 0
+ salary_component["amount"] = 0
+ salary_component["formula"] = ""
+ salary_component["condition"] = ""
+
+ salary_component["salary_component_abbr"] = salary_component["abbr"]
+ doc = frappe.new_doc("Salary Component")
+ doc.update(salary_component)
+ doc.insert()
+
+ get_salary_component_account(doc, company_list)
def get_salary_component_account(sal_comp, company_list=None):
company = erpnext.get_default_company()
@@ -708,7 +714,9 @@
if company_list and company not in company_list:
company_list.append(company)
- sal_comp = frappe.get_doc("Salary Component", sal_comp)
+ if not isinstance(sal_comp, Document):
+ sal_comp = frappe.get_doc("Salary Component", sal_comp)
+
if not sal_comp.get("accounts"):
for d in company_list:
company_abbr = frappe.get_cached_value('Company', d, 'abbr')
@@ -726,7 +734,7 @@
})
sal_comp.save()
-def create_account(account_name, company, parent_account):
+def create_account(account_name, company, parent_account, account_type=None):
company_abbr = frappe.get_cached_value('Company', company, 'abbr')
account = frappe.db.get_value("Account", account_name + " - " + company_abbr)
if not account:
@@ -1011,13 +1019,13 @@
frappe.db.set_value('HR Settings', None, 'leave_status_notification_template', None)
frappe.db.set_value('HR Settings', None, 'leave_approval_notification_template', None)
-def make_holiday_list():
+def make_holiday_list(holiday_list_name=None):
fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company())
- holiday_list = frappe.db.exists("Holiday List", "Salary Slip Test Holiday List")
+ holiday_list = frappe.db.exists("Holiday List", holiday_list_name or "Salary Slip Test Holiday List")
if not holiday_list:
holiday_list = frappe.get_doc({
"doctype": "Holiday List",
- "holiday_list_name": "Salary Slip Test Holiday List",
+ "holiday_list_name": holiday_list_name or "Salary Slip Test Holiday List",
"from_date": fiscal_year[1],
"to_date": fiscal_year[2],
"weekly_off": "Sunday"
diff --git a/erpnext/portal/doctype/homepage_section/test_homepage_section.py b/erpnext/portal/doctype/homepage_section/test_homepage_section.py
index b30d983..c3be146 100644
--- a/erpnext/portal/doctype/homepage_section/test_homepage_section.py
+++ b/erpnext/portal/doctype/homepage_section/test_homepage_section.py
@@ -21,7 +21,7 @@
{'title': 'Card 2', 'subtitle': 'Subtitle 2', 'content': 'This is test card 2', 'image': 'test.jpg'},
],
'no_of_columns': 3
- }).insert()
+ }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py
index 989bcd1..8b60357 100644
--- a/erpnext/projects/doctype/timesheet/test_timesheet.py
+++ b/erpnext/projects/doctype/timesheet/test_timesheet.py
@@ -151,6 +151,35 @@
settings.ignore_employee_time_overlap = initial_setting
settings.save()
+ def test_timesheet_not_overlapping_with_continuous_timelogs(self):
+ emp = make_employee("test_employee_6@salary.com")
+
+ update_activity_type("_Test Activity Type")
+ timesheet = frappe.new_doc("Timesheet")
+ timesheet.employee = emp
+ timesheet.append(
+ 'time_logs',
+ {
+ "billable": 1,
+ "activity_type": "_Test Activity Type",
+ "from_time": now_datetime(),
+ "to_time": now_datetime() + datetime.timedelta(hours=3),
+ "company": "_Test Company"
+ }
+ )
+ timesheet.append(
+ 'time_logs',
+ {
+ "billable": 1,
+ "activity_type": "_Test Activity Type",
+ "from_time": now_datetime() + datetime.timedelta(hours=3),
+ "to_time": now_datetime() + datetime.timedelta(hours=4),
+ "company": "_Test Company"
+ }
+ )
+
+ timesheet.save() # should not throw an error
+
def test_to_time(self):
emp = make_employee("test_employee_6@salary.com")
from_time = now_datetime()
diff --git a/erpnext/projects/doctype/timesheet/timesheet.py b/erpnext/projects/doctype/timesheet/timesheet.py
index dd0b5f9..b44d501 100644
--- a/erpnext/projects/doctype/timesheet/timesheet.py
+++ b/erpnext/projects/doctype/timesheet/timesheet.py
@@ -7,7 +7,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import add_to_date, flt, getdate, time_diff_in_hours
+from frappe.utils import add_to_date, flt, get_datetime, getdate, time_diff_in_hours
from erpnext.controllers.queries import get_match_cond
from erpnext.hr.utils import validate_active_employee
@@ -145,7 +145,7 @@
if not (data.from_time and data.hours):
return
- _to_time = add_to_date(data.from_time, hours=data.hours, as_datetime=True)
+ _to_time = get_datetime(add_to_date(data.from_time, hours=data.hours, as_datetime=True))
if data.to_time != _to_time:
data.to_time = _to_time
@@ -171,39 +171,54 @@
.format(args.idx, self.name, existing.name), OverlapError)
def get_overlap_for(self, fieldname, args, value):
- cond = "ts.`{0}`".format(fieldname)
- if fieldname == 'workstation':
- cond = "tsd.`{0}`".format(fieldname)
+ timesheet = frappe.qb.DocType("Timesheet")
+ timelog = frappe.qb.DocType("Timesheet Detail")
- existing = frappe.db.sql("""select ts.name as name, tsd.from_time as from_time, tsd.to_time as to_time from
- `tabTimesheet Detail` tsd, `tabTimesheet` ts where {0}=%(val)s and tsd.parent = ts.name and
- (
- (%(from_time)s > tsd.from_time and %(from_time)s < tsd.to_time) or
- (%(to_time)s > tsd.from_time and %(to_time)s < tsd.to_time) or
- (%(from_time)s <= tsd.from_time and %(to_time)s >= tsd.to_time))
- and tsd.name!=%(name)s
- and ts.name!=%(parent)s
- and ts.docstatus < 2""".format(cond),
- {
- "val": value,
- "from_time": args.from_time,
- "to_time": args.to_time,
- "name": args.name or "No Name",
- "parent": args.parent or "No Name"
- }, as_dict=True)
- # check internal overlap
- for time_log in self.time_logs:
- if not (time_log.from_time and time_log.to_time
- and args.from_time and args.to_time): continue
+ from_time = get_datetime(args.from_time)
+ to_time = get_datetime(args.to_time)
- if (fieldname != 'workstation' or args.get(fieldname) == time_log.get(fieldname)) and \
- args.idx != time_log.idx and ((args.from_time > time_log.from_time and args.from_time < time_log.to_time) or
- (args.to_time > time_log.from_time and args.to_time < time_log.to_time) or
- (args.from_time <= time_log.from_time and args.to_time >= time_log.to_time)):
- return self
+ existing = (
+ frappe.qb.from_(timesheet)
+ .join(timelog)
+ .on(timelog.parent == timesheet.name)
+ .select(timesheet.name.as_('name'), timelog.from_time.as_('from_time'), timelog.to_time.as_('to_time'))
+ .where(
+ (timelog.name != (args.name or "No Name"))
+ & (timesheet.name != (args.parent or "No Name"))
+ & (timesheet.docstatus < 2)
+ & (timesheet[fieldname] == value)
+ & (
+ ((from_time > timelog.from_time) & (from_time < timelog.to_time))
+ | ((to_time > timelog.from_time) & (to_time < timelog.to_time))
+ | ((from_time <= timelog.from_time) & (to_time >= timelog.to_time))
+ )
+ )
+ ).run(as_dict=True)
+
+ if self.check_internal_overlap(fieldname, args):
+ return self
return existing[0] if existing else None
+ def check_internal_overlap(self, fieldname, args):
+ for time_log in self.time_logs:
+ if not (time_log.from_time and time_log.to_time
+ and args.from_time and args.to_time):
+ continue
+
+ from_time = get_datetime(time_log.from_time)
+ to_time = get_datetime(time_log.to_time)
+ args_from_time = get_datetime(args.from_time)
+ args_to_time = get_datetime(args.to_time)
+
+ if (args.get(fieldname) == time_log.get(fieldname)) and (args.idx != time_log.idx) and (
+ (args_from_time > from_time and args_from_time < to_time)
+ or (args_to_time > from_time and args_to_time < to_time)
+ or (args_from_time <= from_time and args_to_time >= to_time)
+ ):
+ return True
+ return False
+
def update_cost(self):
for data in self.time_logs:
if data.activity_type or data.is_billable:
diff --git a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
index ee04c61..90fdb83 100644
--- a/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
+++ b/erpnext/projects/doctype/timesheet_detail/timesheet_detail.json
@@ -14,12 +14,6 @@
"to_time",
"hours",
"completed",
- "section_break_7",
- "completed_qty",
- "workstation",
- "column_break_12",
- "operation",
- "operation_id",
"project_details",
"project",
"project_name",
@@ -84,43 +78,6 @@
"label": "Completed"
},
{
- "fieldname": "section_break_7",
- "fieldtype": "Section Break"
- },
- {
- "depends_on": "eval:parent.work_order",
- "fieldname": "completed_qty",
- "fieldtype": "Float",
- "label": "Completed Qty"
- },
- {
- "depends_on": "eval:parent.work_order",
- "fieldname": "workstation",
- "fieldtype": "Link",
- "label": "Workstation",
- "options": "Workstation",
- "read_only": 1
- },
- {
- "fieldname": "column_break_12",
- "fieldtype": "Column Break"
- },
- {
- "depends_on": "eval:parent.work_order",
- "fieldname": "operation",
- "fieldtype": "Link",
- "label": "Operation",
- "options": "Operation",
- "read_only": 1
- },
- {
- "depends_on": "eval:parent.work_order",
- "fieldname": "operation_id",
- "fieldtype": "Data",
- "hidden": 1,
- "label": "Operation Id"
- },
- {
"fieldname": "project_details",
"fieldtype": "Section Break"
},
@@ -267,7 +224,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-05-18 12:19:33.205940",
+ "modified": "2022-02-17 16:53:34.878798",
"modified_by": "Administrator",
"module": "Projects",
"name": "Timesheet Detail",
@@ -275,5 +232,6 @@
"permissions": [],
"quick_entry": 1,
"sort_field": "modified",
- "sort_order": "ASC"
+ "sort_order": "ASC",
+ "states": []
}
\ No newline at end of file
diff --git a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
index ca73393..214a1be 100644
--- a/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
+++ b/erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
@@ -182,6 +182,12 @@
onchange: () => this.update_options(),
},
{
+ fieldtype: "Check",
+ label: "Loan Repayment",
+ fieldname: "loan_repayment",
+ onchange: () => this.update_options(),
+ },
+ {
fieldname: "column_break_5",
fieldtype: "Column Break",
},
@@ -191,7 +197,6 @@
fieldname: "sales_invoice",
onchange: () => this.update_options(),
},
-
{
fieldtype: "Check",
label: "Purchase Invoice",
@@ -199,6 +204,12 @@
onchange: () => this.update_options(),
},
{
+ fieldtype: "Check",
+ label: "Show Only Exact Amount",
+ fieldname: "exact_match",
+ onchange: () => this.update_options(),
+ },
+ {
fieldname: "column_break_5",
fieldtype: "Column Break",
},
@@ -210,8 +221,8 @@
},
{
fieldtype: "Check",
- label: "Show Only Exact Amount",
- fieldname: "exact_match",
+ label: "Loan Disbursement",
+ fieldname: "loan_disbursement",
onchange: () => this.update_options(),
},
{
diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js
index aa3e2f3..00373a6 100644
--- a/erpnext/public/js/controllers/transaction.js
+++ b/erpnext/public/js/controllers/transaction.js
@@ -525,6 +525,7 @@
item.weight_per_unit = 0;
item.weight_uom = '';
+ item.conversion_factor = 0;
if(['Sales Invoice'].includes(this.frm.doc.doctype)) {
update_stock = cint(me.frm.doc.update_stock);
@@ -719,6 +720,7 @@
'posting_time': posting_time,
'qty': item.qty * item.conversion_factor,
'serial_no': item.serial_no,
+ 'batch_no': item.batch_no,
'voucher_type': voucher_type,
'company': company,
'allow_zero_valuation_rate': item.allow_zero_valuation_rate
@@ -2283,19 +2285,15 @@
}
coupon_code() {
- var me = this;
- if (this.frm.doc.coupon_code) {
- frappe.run_serially([
+ if (this.frm.doc.coupon_code || this.frm._last_coupon_code) {
+ // reset pricing rules if coupon code is set or is unset
+ const _ignore_pricing_rule = this.frm.doc.ignore_pricing_rule;
+ return frappe.run_serially([
() => this.frm.doc.ignore_pricing_rule=1,
- () => me.ignore_pricing_rule(),
- () => this.frm.doc.ignore_pricing_rule=0,
- () => me.apply_pricing_rule(),
- () => this.frm.save()
- ]);
- } else {
- frappe.run_serially([
- () => this.frm.doc.ignore_pricing_rule=1,
- () => me.ignore_pricing_rule()
+ () => this.frm.trigger('ignore_pricing_rule'),
+ () => this.frm.doc.ignore_pricing_rule=_ignore_pricing_rule,
+ () => this.frm.trigger('apply_pricing_rule'),
+ () => this.frm._last_coupon_code = this.frm.doc.coupon_code
]);
}
}
diff --git a/erpnext/public/js/setup_wizard.js b/erpnext/public/js/setup_wizard.js
index e746ce9..83b69ae 100644
--- a/erpnext/public/js/setup_wizard.js
+++ b/erpnext/public/js/setup_wizard.js
@@ -78,11 +78,11 @@
slide.get_input("company_name").on("change", function () {
var parts = slide.get_input("company_name").val().split(" ");
var abbr = $.map(parts, function (p) { return p ? p.substr(0, 1) : null }).join("");
- slide.get_field("company_abbr").set_value(abbr.slice(0, 5).toUpperCase());
+ slide.get_field("company_abbr").set_value(abbr.slice(0, 10).toUpperCase());
}).val(frappe.boot.sysdefaults.company_name || "").trigger("change");
slide.get_input("company_abbr").on("change", function () {
- if (slide.get_input("company_abbr").val().length > 5) {
+ if (slide.get_input("company_abbr").val().length > 10) {
frappe.msgprint(__("Company Abbreviation cannot have more than 5 characters"));
slide.get_field("company_abbr").set_value("");
}
@@ -96,7 +96,7 @@
if (!this.values.company_abbr) {
return false;
}
- if (this.values.company_abbr.length > 5) {
+ if (this.values.company_abbr.length > 10) {
return false;
}
return true;
diff --git a/erpnext/public/scss/shopping_cart.scss b/erpnext/public/scss/shopping_cart.scss
index 4b645b9..666043b 100644
--- a/erpnext/public/scss/shopping_cart.scss
+++ b/erpnext/public/scss/shopping_cart.scss
@@ -338,14 +338,14 @@
.btn-add-to-wishlist {
svg use {
- stroke: #F47A7A;
+ --icon-stroke: #F47A7A;
}
}
.btn-view-in-wishlist {
svg use {
fill: #F47A7A;
- stroke: none;
+ --icon-stroke: none;
}
}
@@ -1022,7 +1022,7 @@
.not-wished {
cursor: pointer;
- stroke: #F47A7A !important;
+ --icon-stroke: #F47A7A !important;
&:hover {
fill: #F47A7A;
@@ -1030,7 +1030,7 @@
}
.wished {
- stroke: none;
+ --icon-stroke: none;
fill: #F47A7A !important;
}
diff --git a/erpnext/regional/india/setup.py b/erpnext/regional/india/setup.py
index 074bd52..e835690 100644
--- a/erpnext/regional/india/setup.py
+++ b/erpnext/regional/india/setup.py
@@ -53,10 +53,7 @@
hsn_code.description = d["description"]
hsn_code.hsn_code = d[code_field]
hsn_code.name = d[code_field]
- try:
- hsn_code.db_insert()
- except frappe.DuplicateEntryError:
- pass
+ hsn_code.db_insert(ignore_if_duplicate=True)
def add_custom_roles_for_reports():
for report_name in ('GST Sales Register', 'GST Purchase Register',
diff --git a/erpnext/regional/india/utils.py b/erpnext/regional/india/utils.py
index 8715ef5..d443f9c 100644
--- a/erpnext/regional/india/utils.py
+++ b/erpnext/regional/india/utils.py
@@ -219,7 +219,6 @@
if not party_details.place_of_supply: return party_details
if not party_details.company_gstin: return party_details
- if not party_details.supplier_gstin: return party_details
if ((doctype in ("Sales Invoice", "Delivery Note", "Sales Order") and party_details.company_gstin
and party_details.company_gstin[:2] != party_details.place_of_supply[:2]) or (doctype in ("Purchase Invoice",
diff --git a/erpnext/regional/report/datev/datev.js b/erpnext/regional/report/datev/datev.js
index 4124e3d..03c729e 100644
--- a/erpnext/regional/report/datev/datev.js
+++ b/erpnext/regional/report/datev/datev.js
@@ -40,7 +40,11 @@
});
query_report.page.add_menu_item(__("Download DATEV File"), () => {
- const filters = JSON.stringify(query_report.get_values());
+ const filters = encodeURIComponent(
+ JSON.stringify(
+ query_report.get_values()
+ )
+ );
window.open(`/api/method/erpnext.regional.report.datev.datev.download_datev_csv?filters=${filters}`);
});
diff --git a/erpnext/regional/report/gstr_1/gstr_1.js b/erpnext/regional/report/gstr_1/gstr_1.js
index 4b98978..9999a6d 100644
--- a/erpnext/regional/report/gstr_1/gstr_1.js
+++ b/erpnext/regional/report/gstr_1/gstr_1.js
@@ -17,7 +17,7 @@
"fieldtype": "Link",
"options": "Address",
"get_query": function () {
- var company = frappe.query_report.get_filter_value('company');
+ let company = frappe.query_report.get_filter_value('company');
if (company) {
return {
"query": 'frappe.contacts.doctype.address.address.address_query',
@@ -27,6 +27,11 @@
}
},
{
+ "fieldname": "company_gstin",
+ "label": __("Company GSTIN"),
+ "fieldtype": "Select"
+ },
+ {
"fieldname": "from_date",
"label": __("From Date"),
"fieldtype": "Date",
@@ -60,10 +65,21 @@
}
],
onload: function (report) {
+ let filters = report.get_values();
+
+ frappe.call({
+ method: 'erpnext.regional.report.gstr_1.gstr_1.get_company_gstins',
+ args: {
+ company: filters.company
+ },
+ callback: function(r) {
+ frappe.query_report.page.fields_dict.company_gstin.df.options = r.message;
+ frappe.query_report.page.fields_dict.company_gstin.refresh();
+ }
+ });
+
report.page.add_inner_button(__("Download as JSON"), function () {
- var filters = report.get_values();
-
frappe.call({
method: 'erpnext.regional.report.gstr_1.gstr_1.get_json',
args: {
diff --git a/erpnext/regional/report/gstr_1/gstr_1.py b/erpnext/regional/report/gstr_1/gstr_1.py
index e50ff18..8fcb6bb 100644
--- a/erpnext/regional/report/gstr_1/gstr_1.py
+++ b/erpnext/regional/report/gstr_1/gstr_1.py
@@ -28,7 +28,7 @@
posting_date,
base_grand_total,
base_rounded_total,
- COALESCE(NULLIF(customer_gstin,''), NULLIF(billing_address_gstin, '')) as customer_gstin,
+ NULLIF(billing_address_gstin, '') as billing_address_gstin,
place_of_supply,
ecommerce_gstin,
reverse_charge,
@@ -253,13 +253,14 @@
for opts in (("company", " and company=%(company)s"),
("from_date", " and posting_date>=%(from_date)s"),
("to_date", " and posting_date<=%(to_date)s"),
- ("company_address", " and company_address=%(company_address)s")):
+ ("company_address", " and company_address=%(company_address)s"),
+ ("company_gstin", " and company_gstin=%(company_gstin)s")):
if self.filters.get(opts[0]):
conditions += opts[1]
if self.filters.get("type_of_business") == "B2B":
- conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
+ conditions += "AND IFNULL(gst_category, '') in ('Registered Regular', 'Registered Composition', 'Deemed Export', 'SEZ') AND is_return != 1 AND is_debit_note !=1"
if self.filters.get("type_of_business") in ("B2C Large", "B2C Small"):
b2c_limit = frappe.db.get_single_value('GST Settings', 'b2c_limit')
@@ -383,7 +384,7 @@
for invoice, items in self.invoice_items.items():
if invoice not in self.items_based_on_tax_rate and invoice not in unidentified_gst_accounts_invoice \
and self.invoices.get(invoice, {}).get('export_type') == "Without Payment of Tax" \
- and self.invoices.get(invoice, {}).get('gst_category') == "Overseas":
+ and self.invoices.get(invoice, {}).get('gst_category') in ("Overseas", "SEZ"):
self.items_based_on_tax_rate.setdefault(invoice, {}).setdefault(0, items.keys())
def get_columns(self):
@@ -409,7 +410,7 @@
if self.filters.get("type_of_business") == "B2B":
self.invoice_columns = [
{
- "fieldname": "customer_gstin",
+ "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -516,7 +517,7 @@
elif self.filters.get("type_of_business") == "CDNR-REG":
self.invoice_columns = [
{
- "fieldname": "customer_gstin",
+ "fieldname": "billing_address_gstin",
"label": "GSTIN/UIN of Recipient",
"fieldtype": "Data",
"width": 150
@@ -817,7 +818,7 @@
res = {}
if filters["type_of_business"] == "B2B":
for item in report_data[:-1]:
- res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
+ res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_b2b_json(res, gstin)
gst_json["b2b"] = out
@@ -841,7 +842,7 @@
gst_json["exp"] = out
elif filters["type_of_business"] == "CDNR-REG":
for item in report_data[:-1]:
- res.setdefault(item["customer_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
+ res.setdefault(item["billing_address_gstin"], {}).setdefault(item["invoice_number"],[]).append(item)
out = get_cdnr_reg_json(res, gstin)
gst_json["cdnr"] = out
@@ -875,7 +876,7 @@
}
def get_b2b_json(res, gstin):
- inv_type, out = {"Registered Regular": "R", "Deemed Export": "DE", "URD": "URD", "SEZ": "SEZ"}, []
+ out = []
for gst_in in res:
b2b_item, inv = {"ctin": gst_in, "inv": []}, []
if not gst_in: continue
@@ -889,7 +890,7 @@
inv_item = get_basic_invoice_detail(invoice[0])
inv_item["pos"] = "%02d" % int(invoice[0]["place_of_supply"].split('-')[0])
inv_item["rchrg"] = invoice[0]["reverse_charge"]
- inv_item["inv_typ"] = inv_type.get(invoice[0].get("gst_category", ""),"")
+ inv_item["inv_typ"] = get_invoice_type(invoice[0])
if inv_item["pos"]=="00": continue
inv_item["itms"] = []
@@ -1044,7 +1045,7 @@
"ntty": invoice[0]["document_type"],
"pos": "%02d" % int(invoice[0]["place_of_supply"].split('-')[0]),
"rchrg": invoice[0]["reverse_charge"],
- "inv_typ": get_invoice_type_for_cdnr(invoice[0])
+ "inv_typ": get_invoice_type(invoice[0])
}
inv_item["itms"] = []
@@ -1069,7 +1070,7 @@
"val": abs(flt(items[0]["invoice_value"])),
"ntty": items[0]["document_type"],
"pos": "%02d" % int(items[0]["place_of_supply"].split('-')[0]),
- "typ": get_invoice_type_for_cdnrur(items[0])
+ "typ": get_invoice_type(items[0])
}
inv_item["itms"] = []
@@ -1110,29 +1111,21 @@
return out
-def get_invoice_type_for_cdnr(row):
- if row.get('gst_category') == 'SEZ':
- if row.get('export_type') == 'WPAY':
- invoice_type = 'SEWP'
- else:
- invoice_type = 'SEWOP'
- elif row.get('gst_category') == 'Deemed Export':
- invoice_type = 'DE'
- elif row.get('gst_category') == 'Registered Regular':
- invoice_type = 'R'
+def get_invoice_type(row):
+ gst_category = row.get('gst_category')
- return invoice_type
+ if gst_category == 'SEZ':
+ return 'SEWP' if row.get('export_type') == 'WPAY' else 'SEWOP'
-def get_invoice_type_for_cdnrur(row):
- if row.get('gst_category') == 'Overseas':
- if row.get('export_type') == 'WPAY':
- invoice_type = 'EXPWP'
- else:
- invoice_type = 'EXPWOP'
- elif row.get('gst_category') == 'Unregistered':
- invoice_type = 'B2CL'
+ if gst_category == 'Overseas':
+ return 'EXPWP' if row.get('export_type') == 'WPAY' else 'EXPWOP'
- return invoice_type
+ return ({
+ 'Deemed Export': 'DE',
+ 'Registered Regular': 'R',
+ 'Registered Composition': 'R',
+ 'Unregistered': 'B2CL'
+ }).get(gst_category)
def get_basic_invoice_detail(row):
return {
@@ -1154,7 +1147,7 @@
# calculate tax amount added
tax = flt((row["taxable_value"]*rate)/100.0, 2)
frappe.errprint([tax, tax/2])
- if row.get("customer_gstin") and gstin[0:2] == row["customer_gstin"][0:2]:
+ if row.get("billing_address_gstin") and gstin[0:2] == row["billing_address_gstin"][0:2]:
itm_det.update({"camt": flt(tax/2.0, 2), "samt": flt(tax/2.0, 2)})
else:
itm_det.update({"iamt": tax})
@@ -1199,4 +1192,24 @@
if invoice_detail.place_of_supply.split("-")[0] != invoice_detail.company_gstin[:2]:
return True
else:
- return False
\ No newline at end of file
+ return False
+
+
+@frappe.whitelist()
+def get_company_gstins(company):
+ address = frappe.qb.DocType("Address")
+ links = frappe.qb.DocType("Dynamic Link")
+
+ addresses = frappe.qb.from_(address).inner_join(links).on(
+ address.name == links.parent
+ ).select(
+ address.gstin
+ ).where(
+ links.link_doctype == 'Company'
+ ).where(
+ links.link_name == company
+ ).run(as_dict=1)
+
+ address_list = [''] + [d.gstin for d in addresses]
+
+ return address_list
\ No newline at end of file
diff --git a/erpnext/regional/saudi_arabia/setup.py b/erpnext/regional/saudi_arabia/setup.py
index 15d524d..d2ef6f3 100644
--- a/erpnext/regional/saudi_arabia/setup.py
+++ b/erpnext/regional/saudi_arabia/setup.py
@@ -102,7 +102,7 @@
]
}
- create_custom_fields(custom_fields, update=True)
+ create_custom_fields(custom_fields, ignore_validate=True, update=True)
def update_regional_tax_settings(country, company):
create_ksa_vat_setting(company)
diff --git a/erpnext/selling/doctype/sales_order/test_sales_order.py b/erpnext/selling/doctype/sales_order/test_sales_order.py
index acf048e..73c5bd2 100644
--- a/erpnext/selling/doctype/sales_order/test_sales_order.py
+++ b/erpnext/selling/doctype/sales_order/test_sales_order.py
@@ -6,7 +6,7 @@
import frappe
import frappe.permissions
from frappe.core.doctype.user_permission.test_user_permission import create_user
-from frappe.utils import add_days, flt, getdate, nowdate
+from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.controllers.accounts_controller import update_child_qty_rate
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
@@ -1399,6 +1399,48 @@
so.load_from_db()
self.assertEqual(so.billing_status, 'Fully Billed')
+ def test_so_back_updated_from_wo_via_mr(self):
+ "SO -> MR (Manufacture) -> WO. Test if WO Qty is updated in SO."
+ from erpnext.manufacturing.doctype.work_order.work_order import (
+ make_stock_entry as make_se_from_wo,
+ )
+ from erpnext.stock.doctype.material_request.material_request import raise_work_orders
+
+ so = make_sales_order(item_list=[{"item_code": "_Test FG Item","qty": 2, "rate":100}])
+
+ mr = make_material_request(so.name)
+ mr.material_request_type = "Manufacture"
+ mr.schedule_date = today()
+ mr.submit()
+
+ # WO from MR
+ wo_name = raise_work_orders(mr.name)[0]
+ wo = frappe.get_doc("Work Order", wo_name)
+ wo.wip_warehouse = "Work In Progress - _TC"
+ wo.skip_transfer = True
+
+ self.assertEqual(wo.sales_order, so.name)
+ self.assertEqual(wo.sales_order_item, so.items[0].name)
+
+ wo.submit()
+ make_stock_entry(item_code="_Test Item", # Stock RM
+ target="Work In Progress - _TC",
+ qty=4, basic_rate=100
+ )
+ make_stock_entry(item_code="_Test Item Home Desktop 100", # Stock RM
+ target="Work In Progress - _TC",
+ qty=4, basic_rate=100
+ )
+
+ se = frappe.get_doc(make_se_from_wo(wo.name, "Manufacture", 2))
+ se.submit() # Finish WO
+
+ mr.reload()
+ wo.reload()
+ so.reload()
+ self.assertEqual(so.items[0].work_order_qty, wo.produced_qty)
+ self.assertEqual(mr.status, "Manufactured")
+
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable
diff --git a/erpnext/selling/doctype/sales_order_item/sales_order_item.json b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
index 95f6c4e..080d517 100644
--- a/erpnext/selling/doctype/sales_order_item/sales_order_item.json
+++ b/erpnext/selling/doctype/sales_order_item/sales_order_item.json
@@ -83,8 +83,8 @@
"planned_qty",
"column_break_69",
"work_order_qty",
- "delivered_qty",
"produced_qty",
+ "delivered_qty",
"returned_qty",
"shopping_cart_section",
"additional_notes",
@@ -701,10 +701,8 @@
"width": "50px"
},
{
- "description": "For Production",
"fieldname": "produced_qty",
"fieldtype": "Float",
- "hidden": 1,
"label": "Produced Quantity",
"oldfieldname": "produced_qty",
"oldfieldtype": "Currency",
@@ -802,7 +800,7 @@
"idx": 1,
"istable": 1,
"links": [],
- "modified": "2021-10-05 12:27:25.014789",
+ "modified": "2022-02-21 13:55:08.883104",
"modified_by": "Administrator",
"module": "Selling",
"name": "Sales Order Item",
@@ -811,5 +809,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js
index b9b6559..1e9f6d7 100644
--- a/erpnext/selling/page/point_of_sale/pos_payment.js
+++ b/erpnext/selling/page/point_of_sale/pos_payment.js
@@ -169,6 +169,24 @@
}
});
+ frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => {
+ if (!frm.doc.ignore_pricing_rule && frm.doc.coupon_code) {
+ frappe.run_serially([
+ () => frm.doc.ignore_pricing_rule=1,
+ () => frm.trigger('ignore_pricing_rule'),
+ () => frm.doc.ignore_pricing_rule=0,
+ () => frm.trigger('apply_pricing_rule'),
+ () => frm.save(),
+ () => this.update_totals_section(frm.doc)
+ ]);
+ } else if (frm.doc.ignore_pricing_rule && frm.doc.coupon_code) {
+ frappe.show_alert({
+ message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."),
+ indicator: "orange"
+ });
+ }
+ });
+
this.setup_listener_for_payments();
this.$payment_modes.on('click', '.shortcut', function() {
diff --git a/erpnext/accounts/print_format/gst_pos_invoice/__init__.py b/erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py
similarity index 100%
rename from erpnext/accounts/print_format/gst_pos_invoice/__init__.py
rename to erpnext/selling/report/payment_terms_status_for_sales_order/__init__.py
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
new file mode 100644
index 0000000..0e36b3f
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js
@@ -0,0 +1,84 @@
+// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+function get_filters() {
+ let filters = [
+ {
+ "fieldname":"company",
+ "label": __("Company"),
+ "fieldtype": "Link",
+ "options": "Company",
+ "default": frappe.defaults.get_user_default("Company"),
+ "reqd": 1
+ },
+ {
+ "fieldname":"period_start_date",
+ "label": __("Start Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1)
+ },
+ {
+ "fieldname":"period_end_date",
+ "label": __("End Date"),
+ "fieldtype": "Date",
+ "reqd": 1,
+ "default": frappe.datetime.get_today()
+ },
+ {
+ "fieldname":"sales_order",
+ "label": __("Sales Order"),
+ "fieldtype": "MultiSelectList",
+ "width": 100,
+ "options": "Sales Order",
+ "get_data": function(txt) {
+ return frappe.db.get_link_options("Sales Order", txt, this.filters());
+ },
+ "filters": () => {
+ return {
+ docstatus: 1,
+ payment_terms_template: ['not in', ['']],
+ company: frappe.query_report.get_filter_value("company"),
+ transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]]
+ }
+ },
+ on_change: function(){
+ frappe.query_report.refresh();
+ }
+ }
+ ]
+
+ return filters;
+}
+
+frappe.query_reports["Payment Terms Status for Sales Order"] = {
+ "filters": get_filters(),
+ "formatter": function(value, row, column, data, default_formatter){
+ if(column.fieldname == 'invoices' && value) {
+ invoices = value.split(',');
+ const invoice_formatter = (prev_value, curr_value) => {
+ if(prev_value != "") {
+ return prev_value + ", " + default_formatter(curr_value, row, column, data);
+ }
+ else {
+ return default_formatter(curr_value, row, column, data);
+ }
+ }
+ return invoices.reduce(invoice_formatter, "")
+ }
+ else if (column.fieldname == 'paid_amount' && value){
+ formatted_value = default_formatter(value, row, column, data);
+ if(value > 0) {
+ formatted_value = "<span style='color:green;'>" + formatted_value + "</span>"
+ }
+ return formatted_value;
+ }
+ else if (column.fieldname == 'status' && value == 'Completed'){
+ return "<span style='color:green;'>" + default_formatter(value, row, column, data) + "</span>";
+ }
+
+ return default_formatter(value, row, column, data);
+ },
+
+};
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json
new file mode 100644
index 0000000..850fa4d
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.json
@@ -0,0 +1,38 @@
+{
+ "add_total_row": 1,
+ "columns": [],
+ "creation": "2021-12-28 10:39:34.533964",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2021-12-30 10:42:06.058457",
+ "modified_by": "Administrator",
+ "module": "Selling",
+ "name": "Payment Terms Status for Sales Order",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Sales Order",
+ "report_name": "Payment Terms Status for Sales Order",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "Sales User"
+ },
+ {
+ "role": "Sales Manager"
+ },
+ {
+ "role": "Maintenance User"
+ },
+ {
+ "role": "Accounts User"
+ },
+ {
+ "role": "Stock User"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
new file mode 100644
index 0000000..e6a56ee
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py
@@ -0,0 +1,205 @@
+# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
+# License: MIT. See LICENSE
+
+import frappe
+from frappe import _, qb, query_builder
+from frappe.query_builder import functions
+
+
+def get_columns():
+ columns = [
+ {
+ "label": _("Sales Order"),
+ "fieldname": "name",
+ "fieldtype": "Link",
+ "options": "Sales Order",
+ },
+ {
+ "label": _("Posting Date"),
+ "fieldname": "submitted",
+ "fieldtype": "Date",
+ },
+ {
+ "label": _("Payment Term"),
+ "fieldname": "payment_term",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Description"),
+ "fieldname": "description",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Due Date"),
+ "fieldname": "due_date",
+ "fieldtype": "Date",
+ },
+ {
+ "label": _("Invoice Portion"),
+ "fieldname": "invoice_portion",
+ "fieldtype": "Percent",
+ },
+ {
+ "label": _("Payment Amount"),
+ "fieldname": "base_payment_amount",
+ "fieldtype": "Currency",
+ "options": "currency",
+ },
+ {
+ "label": _("Paid Amount"),
+ "fieldname": "paid_amount",
+ "fieldtype": "Currency",
+ "options": "currency",
+ },
+ {
+ "label": _("Invoices"),
+ "fieldname": "invoices",
+ "fieldtype": "Link",
+ "options": "Sales Invoice",
+ },
+ {
+ "label": _("Status"),
+ "fieldname": "status",
+ "fieldtype": "Data",
+ },
+ {
+ "label": _("Currency"),
+ "fieldname": "currency",
+ "fieldtype": "Currency",
+ "hidden": 1
+ }
+ ]
+ return columns
+
+
+def get_conditions(filters):
+ """
+ Convert filter options to conditions used in query
+ """
+ filters = frappe._dict(filters) if filters else frappe._dict({})
+ conditions = frappe._dict({})
+
+ conditions.company = filters.company or frappe.defaults.get_user_default("company")
+ conditions.end_date = filters.period_end_date or frappe.utils.today()
+ conditions.start_date = filters.period_start_date or frappe.utils.add_months(
+ conditions.end_date, -1
+ )
+ conditions.sales_order = filters.sales_order or []
+
+ return conditions
+
+
+def get_so_with_invoices(filters):
+ """
+ Get Sales Order with payment terms template with their associated Invoices
+ """
+ sorders = []
+
+ so = qb.DocType("Sales Order")
+ ps = qb.DocType("Payment Schedule")
+ datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"])
+ ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"])
+
+ conditions = get_conditions(filters)
+ query_so = (
+ qb.from_(so)
+ .join(ps)
+ .on(ps.parent == so.name)
+ .select(
+ so.name,
+ so.transaction_date.as_("submitted"),
+ ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"),
+ ps.payment_term,
+ ps.description,
+ ps.due_date,
+ ps.invoice_portion,
+ ps.base_payment_amount,
+ ps.paid_amount,
+ )
+ .where(
+ (so.docstatus == 1)
+ & (so.payment_terms_template != "NULL")
+ & (so.company == conditions.company)
+ & (so.transaction_date[conditions.start_date : conditions.end_date])
+ )
+ .orderby(so.name, so.transaction_date, ps.due_date)
+ )
+
+ if conditions.sales_order != []:
+ query_so = query_so.where(so.name.isin(conditions.sales_order))
+
+ sorders = query_so.run(as_dict=True)
+
+ invoices = []
+ if sorders != []:
+ soi = qb.DocType("Sales Order Item")
+ si = qb.DocType("Sales Invoice")
+ sii = qb.DocType("Sales Invoice Item")
+ query_inv = (
+ qb.from_(sii)
+ .right_join(si)
+ .on(si.name == sii.parent)
+ .inner_join(soi)
+ .on(soi.name == sii.so_detail)
+ .select(sii.sales_order, sii.parent.as_("invoice"), si.base_grand_total.as_("invoice_amount"))
+ .where((sii.sales_order.isin([x.name for x in sorders])) & (si.docstatus == 1))
+ .groupby(sii.parent)
+ )
+ invoices = query_inv.run(as_dict=True)
+
+ return sorders, invoices
+
+
+def set_payment_terms_statuses(sales_orders, invoices, filters):
+ """
+ compute status for payment terms with associated sales invoice using FIFO
+ """
+
+ for so in sales_orders:
+ so.currency = frappe.get_cached_value('Company', filters.get('company'), 'default_currency')
+ so.invoices = ""
+ for inv in [x for x in invoices if x.sales_order == so.name and x.invoice_amount > 0]:
+ if so.base_payment_amount - so.paid_amount > 0:
+ amount = so.base_payment_amount - so.paid_amount
+ if inv.invoice_amount >= amount:
+ inv.invoice_amount -= amount
+ so.paid_amount += amount
+ so.invoices += "," + inv.invoice
+ so.status = "Completed"
+ break
+ else:
+ so.paid_amount += inv.invoice_amount
+ inv.invoice_amount = 0
+ so.invoices += "," + inv.invoice
+ so.status = "Partly Paid"
+
+ return sales_orders, invoices
+
+
+def prepare_chart(s_orders):
+ if len(set([x.name for x in s_orders])) == 1:
+ chart = {
+ "data": {
+ "labels": [term.payment_term for term in s_orders],
+ "datasets": [
+ {"name": "Payment Amount", "values": [x.base_payment_amount for x in s_orders],},
+ {"name": "Paid Amount", "values": [x.paid_amount for x in s_orders],},
+ ],
+ },
+ "type": "bar",
+ }
+ return chart
+
+
+def execute(filters=None):
+ columns = get_columns()
+ sales_orders, so_invoices = get_so_with_invoices(filters)
+ sales_orders, so_invoices = set_payment_terms_statuses(sales_orders, so_invoices, filters)
+
+ prepare_chart(sales_orders)
+
+ data = sales_orders
+ message = []
+ chart = prepare_chart(sales_orders)
+
+ return columns, data, message, chart
diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
new file mode 100644
index 0000000..cad41e1
--- /dev/null
+++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py
@@ -0,0 +1,198 @@
+import datetime
+
+import frappe
+from frappe.utils import add_days
+
+from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice
+from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order import (
+ execute,
+)
+from erpnext.stock.doctype.item.test_item import create_item
+from erpnext.tests.utils import ERPNextTestCase
+
+test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"]
+
+
+class TestPaymentTermsStatusForSalesOrder(ERPNextTestCase):
+ def create_payment_terms_template(self):
+ # create template for 50-50 payments
+ template = None
+ if frappe.db.exists("Payment Terms Template", "_Test 50-50"):
+ template = frappe.get_doc("Payment Terms Template", "_Test 50-50")
+ else:
+ template = frappe.get_doc(
+ {
+ "doctype": "Payment Terms Template",
+ "template_name": "_Test 50-50",
+ "terms": [
+ {
+ "doctype": "Payment Terms Template Detail",
+ "due_date_based_on": "Day(s) after invoice date",
+ "payment_term_name": "_Test 50% on 15 Days",
+ "description": "_Test 50-50",
+ "invoice_portion": 50,
+ "credit_days": 15,
+ },
+ {
+ "doctype": "Payment Terms Template Detail",
+ "due_date_based_on": "Day(s) after invoice date",
+ "payment_term_name": "_Test 50% on 30 Days",
+ "description": "_Test 50-50",
+ "invoice_portion": 50,
+ "credit_days": 30,
+ },
+ ],
+ }
+ )
+ template.insert()
+ self.template = template
+
+ def test_payment_terms_status(self):
+ self.create_payment_terms_template()
+ item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ so = make_sales_order(
+ transaction_date="2021-06-15",
+ delivery_date=add_days("2021-06-15", -30),
+ item=item.item_code,
+ qty=10,
+ rate=100000,
+ do_not_save=True,
+ )
+ so.po_no = ""
+ so.taxes_and_charges = ""
+ so.taxes = ""
+ so.payment_terms_template = self.template.name
+ so.save()
+ so.submit()
+
+ # make invoice with 60% of the total sales order value
+ sinv = make_sales_invoice(so.name)
+ sinv.taxes_and_charges = ""
+ sinv.taxes = ""
+ sinv.items[0].qty = 6
+ sinv.insert()
+ sinv.submit()
+ columns, data, message, chart = execute(
+ {
+ "company": "_Test Company",
+ "period_start_date": "2021-06-01",
+ "period_end_date": "2021-06-30",
+ "sales_order": [so.name],
+ }
+ )
+
+ expected_value = [
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Completed",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 500000.0,
+ "invoices": ","+sinv.name,
+ },
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Partly Paid",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": "INR",
+ "base_payment_amount": 500000.0,
+ "paid_amount": 100000.0,
+ "invoices": ","+sinv.name,
+ },
+ ]
+ self.assertEqual(data, expected_value)
+
+ def create_exchange_rate(self, date):
+ # make an entry in Currency Exchange list. serves as a static exchange rate
+ if frappe.db.exists({'doctype': "Currency Exchange",'date': date,'from_currency': 'USD', 'to_currency':'INR'}):
+ return
+ else:
+ doc = frappe.get_doc({
+ 'doctype': "Currency Exchange",
+ 'date': date,
+ 'from_currency': 'USD',
+ 'to_currency': frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ 'exchange_rate': 70,
+ 'for_buying': True,
+ 'for_selling': True
+ })
+ doc.insert()
+
+ def test_alternate_currency(self):
+ transaction_date = "2021-06-15"
+ self.create_payment_terms_template()
+ self.create_exchange_rate(transaction_date)
+ item = create_item(item_code="_Test Excavator", is_stock_item=0)
+ so = make_sales_order(
+ transaction_date=transaction_date,
+ currency="USD",
+ delivery_date=add_days(transaction_date, -30),
+ item=item.item_code,
+ qty=10,
+ rate=10000,
+ do_not_save=True,
+ )
+ so.po_no = ""
+ so.taxes_and_charges = ""
+ so.taxes = ""
+ so.payment_terms_template = self.template.name
+ so.save()
+ so.submit()
+
+ # make invoice with 60% of the total sales order value
+ sinv = make_sales_invoice(so.name)
+ sinv.currency = "USD"
+ sinv.taxes_and_charges = ""
+ sinv.taxes = ""
+ sinv.items[0].qty = 6
+ sinv.insert()
+ sinv.submit()
+ columns, data, message, chart = execute(
+ {
+ "company": "_Test Company",
+ "period_start_date": "2021-06-01",
+ "period_end_date": "2021-06-30",
+ "sales_order": [so.name],
+ }
+ )
+
+ # report defaults to company currency.
+ expected_value = [
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Completed",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 6, 30),
+ "invoice_portion": 50.0,
+ "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ "base_payment_amount": 3500000.0,
+ "paid_amount": 3500000.0,
+ "invoices": ","+sinv.name,
+ },
+ {
+ "name": so.name,
+ "submitted": datetime.date(2021, 6, 15),
+ "status": "Partly Paid",
+ "payment_term": None,
+ "description": "_Test 50-50",
+ "due_date": datetime.date(2021, 7, 15),
+ "invoice_portion": 50.0,
+ "currency": frappe.get_cached_value("Company", '_Test Company','default_currency'),
+ "base_payment_amount": 3500000.0,
+ "paid_amount": 700000.0,
+ "invoices": ","+sinv.name,
+ },
+ ]
+ self.assertEqual(data, expected_value)
diff --git a/erpnext/selling/sales_common.js b/erpnext/selling/sales_common.js
index 16e3847..98131f9 100644
--- a/erpnext/selling/sales_common.js
+++ b/erpnext/selling/sales_common.js
@@ -227,11 +227,11 @@
},
callback:function(r){
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
-
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
-
- me.set_batch_number(cdt, cdn);
- me.batch_no(doc, cdt, cdn);
+ if (has_batch_no) {
+ me.set_batch_number(cdt, cdn);
+ me.batch_no(doc, cdt, cdn);
+ }
}
}
});
diff --git a/erpnext/setup/utils.py b/erpnext/setup/utils.py
index 4441bb9..a4f2207 100644
--- a/erpnext/setup/utils.py
+++ b/erpnext/setup/utils.py
@@ -155,7 +155,7 @@
doc = frappe.new_doc(r.get("doctype"))
doc.update(r)
try:
- doc.insert(ignore_permissions=True)
+ doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
except frappe.DuplicateEntryError as e:
# pass DuplicateEntryError and continue
if e.args and e.args[0]==doc.doctype and e.args[1]==doc.name:
diff --git a/erpnext/stock/doctype/batch/batch.json b/erpnext/stock/doctype/batch/batch.json
index fc4cf1d..967c572 100644
--- a/erpnext/stock/doctype/batch/batch.json
+++ b/erpnext/stock/doctype/batch/batch.json
@@ -9,6 +9,8 @@
"field_order": [
"sb_disabled",
"disabled",
+ "column_break_24",
+ "use_batchwise_valuation",
"sb_batch",
"batch_id",
"item",
@@ -186,6 +188,18 @@
"fieldtype": "Float",
"label": "Produced Qty",
"read_only": 1
+ },
+ {
+ "fieldname": "column_break_24",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "use_batchwise_valuation",
+ "fieldtype": "Check",
+ "label": "Use Batch-wise Valuation",
+ "read_only": 1,
+ "set_only_once": 1
}
],
"icon": "fa fa-archive",
@@ -193,10 +207,11 @@
"image_field": "image",
"links": [],
"max_attachments": 5,
- "modified": "2021-07-08 16:22:01.343105",
+ "modified": "2022-02-21 08:08:23.999236",
"modified_by": "Administrator",
"module": "Stock",
"name": "Batch",
+ "naming_rule": "By fieldname",
"owner": "Administrator",
"permissions": [
{
@@ -217,6 +232,7 @@
"quick_entry": 1,
"sort_field": "modified",
"sort_order": "DESC",
+ "states": [],
"title_field": "batch_id",
"track_changes": 1
}
\ No newline at end of file
diff --git a/erpnext/stock/doctype/batch/batch.py b/erpnext/stock/doctype/batch/batch.py
index 96751d6..c9b4c14 100644
--- a/erpnext/stock/doctype/batch/batch.py
+++ b/erpnext/stock/doctype/batch/batch.py
@@ -110,11 +110,18 @@
def validate(self):
self.item_has_batch_enabled()
+ self.set_batchwise_valuation()
def item_has_batch_enabled(self):
if frappe.db.get_value("Item", self.item, "has_batch_no") == 0:
frappe.throw(_("The selected item cannot have Batch"))
+ def set_batchwise_valuation(self):
+ from erpnext.stock.stock_ledger import get_valuation_method
+
+ if self.is_new() and get_valuation_method(self.item) != "Moving Average":
+ self.use_batchwise_valuation = 1
+
def before_save(self):
has_expiry_date, shelf_life_in_days = frappe.db.get_value('Item', self.item, ['has_expiry_date', 'shelf_life_in_days'])
if not self.expiry_date and has_expiry_date and shelf_life_in_days:
diff --git a/erpnext/stock/doctype/batch/test_batch.py b/erpnext/stock/doctype/batch/test_batch.py
index 0a663c2..613dd3f 100644
--- a/erpnext/stock/doctype/batch/test_batch.py
+++ b/erpnext/stock/doctype/batch/test_batch.py
@@ -1,13 +1,21 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
+import json
+
import frappe
from frappe.exceptions import ValidationError
from frappe.utils import cint, flt
+from frappe.utils.data import add_to_date, getdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty
+from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
+ create_stock_reconciliation,
+)
from erpnext.stock.get_item_details import get_item_details
+from erpnext.stock.stock_ledger import get_valuation_rate
from erpnext.tests.utils import ERPNextTestCase
@@ -300,6 +308,105 @@
details = get_item_details(args)
self.assertEqual(details.get('price_list_rate'), 400)
+
+ def test_basic_batch_wise_valuation(self, batch_qty = 100):
+ item_code = "_TestBatchWiseVal"
+ warehouse = "_Test Warehouse - _TC"
+ self.make_batch_item(item_code)
+
+ rates = [42, 420]
+
+ batches = {}
+ for rate in rates:
+ se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse)
+ batches[se.items[0].batch_no] = rate
+
+ LOW, HIGH = list(batches.keys())
+
+ # consume things out of order
+ consumption_plan = [
+ (HIGH, 1),
+ (LOW, 2),
+ (HIGH, 2),
+ (HIGH, 4),
+ (LOW, 6),
+ ]
+
+ stock_value = sum(rates) * 10
+ qty_after_transaction = 20
+ for batch, qty in consumption_plan:
+ # consume out of order
+ se = make_stock_entry(item_code=item_code, source=warehouse, qty=qty, batch_no=batch)
+
+ sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name})
+
+ stock_value_difference = sle.actual_qty * batches[sle.batch_no]
+ self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference)
+
+ stock_value += stock_value_difference
+ self.assertAlmostEqual(sle.stock_value, stock_value)
+
+ qty_after_transaction += sle.actual_qty
+ self.assertAlmostEqual(sle.qty_after_transaction, qty_after_transaction)
+ self.assertAlmostEqual(sle.valuation_rate, stock_value / qty_after_transaction)
+
+ self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items
+
+ def test_moving_batch_valuation_rates(self):
+ item_code = "_TestBatchWiseVal"
+ warehouse = "_Test Warehouse - _TC"
+ self.make_batch_item(item_code)
+
+ def assertValuation(expected):
+ actual = get_valuation_rate(item_code, warehouse, "voucher_type", "voucher_no", batch_no=batch_no)
+ self.assertAlmostEqual(actual, expected)
+
+ se = make_stock_entry(item_code=item_code, qty=100, rate=10, target=warehouse)
+ batch_no = se.items[0].batch_no
+ assertValuation(10)
+
+ # consumption should never affect current valuation rate
+ make_stock_entry(item_code=item_code, qty=20, source=warehouse)
+ assertValuation(10)
+
+ make_stock_entry(item_code=item_code, qty=30, source=warehouse)
+ assertValuation(10)
+
+ # 50 * 10 = 500 current value, add more item with higher valuation
+ make_stock_entry(item_code=item_code, qty=50, rate=20, target=warehouse, batch_no=batch_no)
+ assertValuation(15)
+
+ # consuming again shouldn't do anything
+ make_stock_entry(item_code=item_code, qty=20, source=warehouse)
+ assertValuation(15)
+
+ # reset rate with stock reconiliation
+ create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=25, batch_no=batch_no)
+ assertValuation(25)
+
+ make_stock_entry(item_code=item_code, qty=20, rate=20, target=warehouse, batch_no=batch_no)
+ assertValuation((20 * 20 + 10 * 25) / (10 + 20))
+
+
+ def test_update_batch_properties(self):
+ item_code = "_TestBatchWiseVal"
+ self.make_batch_item(item_code)
+
+ se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC")
+ batch_no = se.items[0].batch_no
+ batch = frappe.get_doc("Batch", batch_no)
+
+ expiry_date = add_to_date(batch.manufacturing_date, days=30)
+
+ batch.expiry_date = expiry_date
+ batch.save()
+
+ batch.reload()
+
+ self.assertEqual(getdate(batch.expiry_date), getdate(expiry_date))
+
+
+
def create_batch(item_code, rate, create_item_price_for_batch):
pi = make_purchase_invoice(company="_Test Company",
warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1,
@@ -326,14 +433,13 @@
def make_new_batch(**args):
args = frappe._dict(args)
- try:
+ if frappe.db.exists("Batch", args.batch_id):
+ batch = frappe.get_doc("Batch", args.batch_id)
+ else:
batch = frappe.get_doc({
"doctype": "Batch",
"batch_id": args.batch_id,
"item": args.item_code,
}).insert()
- except frappe.DuplicateEntryError:
- batch = frappe.get_doc("Batch", args.batch_id)
-
return batch
diff --git a/erpnext/stock/doctype/bin/bin.py b/erpnext/stock/doctype/bin/bin.py
index d2bae65..3bc15a8 100644
--- a/erpnext/stock/doctype/bin/bin.py
+++ b/erpnext/stock/doctype/bin/bin.py
@@ -20,18 +20,6 @@
+ flt(self.indented_qty) + flt(self.planned_qty) - flt(self.reserved_qty)
- flt(self.reserved_qty_for_production) - flt(self.reserved_qty_for_sub_contract))
- def get_first_sle(self):
- sle = frappe.qb.DocType("Stock Ledger Entry")
- first_sle = (
- frappe.qb.from_(sle)
- .select("*")
- .where((sle.item_code == self.item_code) & (sle.warehouse == self.warehouse))
- .orderby(sle.posting_date, sle.posting_time, sle.creation)
- .limit(1)
- ).run(as_dict=True)
-
- return first_sle and first_sle[0] or None
-
def update_reserved_qty_for_production(self):
'''Update qty reserved for production from Production Item tables
in open work orders'''
@@ -107,13 +95,6 @@
frappe.db.add_unique("Bin", ["item_code", "warehouse"], constraint_name="unique_item_warehouse")
-def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.stock_ledger import repost_current_voucher
-
- repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
- update_qty(bin_name, args)
-
def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
'reserved_qty', 'indented_qty', 'planned_qty', 'reserved_qty_for_production',
diff --git a/erpnext/stock/doctype/item/item.js b/erpnext/stock/doctype/item/item.js
index 2a30ca1..ffea9c2 100644
--- a/erpnext/stock/doctype/item/item.js
+++ b/erpnext/stock/doctype/item/item.js
@@ -545,7 +545,7 @@
let selected_attributes = {};
me.multiple_variant_dialog.$wrapper.find('.form-column').each((i, col) => {
if(i===0) return;
- let attribute_name = $(col).find('label').html();
+ let attribute_name = $(col).find('label').html().trim();
selected_attributes[attribute_name] = [];
let checked_opts = $(col).find('.checkbox input');
checked_opts.each((i, opt) => {
@@ -594,7 +594,7 @@
const increment = r.message.increment;
let values = [];
- for(var i = from; i <= to; i += increment) {
+ for(var i = from; i <= to; i = flt(i + increment, 6)) {
values.push(i);
}
attr_val_fields[d.attribute] = values;
diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json
index b05f58a..c797187 100644
--- a/erpnext/stock/doctype/item/item.json
+++ b/erpnext/stock/doctype/item/item.json
@@ -48,6 +48,7 @@
"warranty_period",
"weight_per_unit",
"weight_uom",
+ "allow_negative_stock",
"reorder_section",
"reorder_levels",
"unit_of_measure_conversion",
@@ -907,6 +908,12 @@
"fieldname": "is_grouped_asset",
"fieldtype": "Check",
"label": "Create Grouped Asset"
+ },
+ {
+ "default": "0",
+ "fieldname": "allow_negative_stock",
+ "fieldtype": "Check",
+ "label": "Allow Negative Stock"
}
],
"icon": "fa fa-tag",
@@ -914,7 +921,7 @@
"image_field": "image",
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2022-01-18 12:57:54.273202",
+ "modified": "2022-02-11 08:07:46.663220",
"modified_by": "Administrator",
"module": "Stock",
"name": "Item",
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index b9e8b3f..494fb3b 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -398,6 +398,7 @@
if merge:
self.validate_properties_before_merge(new_name)
+ self.validate_duplicate_product_bundles_before_merge(old_name, new_name)
self.validate_duplicate_website_item_before_merge(old_name, new_name)
def after_rename(self, old_name, new_name, merge):
@@ -462,6 +463,20 @@
msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])
frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError)
+ def validate_duplicate_product_bundles_before_merge(self, old_name, new_name):
+ "Block merge if both old and new items have product bundles."
+ old_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": old_name})
+ new_bundle = frappe.get_value("Product Bundle",filters={"new_item_code": new_name})
+
+ if old_bundle and new_bundle:
+ bundle_link = get_link_to_form("Product Bundle", old_bundle)
+ old_name, new_name = frappe.bold(old_name), frappe.bold(new_name)
+
+ msg = _("Please delete Product Bundle {0}, before merging {1} into {2}").format(
+ bundle_link, old_name, new_name
+ )
+ frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError)
+
def validate_duplicate_website_item_before_merge(self, old_name, new_name):
"""
Block merge if both old and new items have website items against them.
@@ -479,8 +494,9 @@
old_web_item = [d.get("name") for d in web_items if d.get("item_code") == old_name][0]
web_item_link = get_link_to_form("Website Item", old_web_item)
+ old_name, new_name = frappe.bold(old_name), frappe.bold(new_name)
- msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} and {new_name}"
+ msg = f"Please delete linked Website Item {frappe.bold(web_item_link)} before merging {old_name} into {new_name}"
frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError)
def set_last_purchase_rate(self, new_name):
diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py
index fc45ba9..9491e17 100644
--- a/erpnext/stock/doctype/item/test_item.py
+++ b/erpnext/stock/doctype/item/test_item.py
@@ -6,6 +6,7 @@
import frappe
from frappe.test_runner import make_test_objects
+from frappe.utils import add_days, today
from erpnext.controllers.item_variant import (
InvalidItemAttributeValueError,
@@ -14,6 +15,7 @@
get_variant,
)
from erpnext.stock.doctype.item.item import (
+ DataValidationError,
InvalidBarcode,
StockExistsForTemplate,
get_item_attribute,
@@ -387,6 +389,26 @@
self.assertTrue(frappe.db.get_value("Bin",
{"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"}))
+ def test_item_merging_with_product_bundle(self):
+ from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
+
+ create_item("Test Item Bundle Item 1", is_stock_item=False)
+ create_item("Test Item Bundle Item 2", is_stock_item=False)
+ create_item("Test Item inside Bundle")
+ bundle_items = ["Test Item inside Bundle"]
+
+ # make bundles for both items
+ bundle1 = make_product_bundle("Test Item Bundle Item 1", bundle_items, qty=2)
+ make_product_bundle("Test Item Bundle Item 2", bundle_items, qty=2)
+
+ with self.assertRaises(DataValidationError):
+ frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
+
+ bundle1.delete()
+ frappe.rename_doc("Item", "Test Item Bundle Item 1", "Test Item Bundle Item 2", merge=True)
+
+ self.assertFalse(frappe.db.exists("Item", "Test Item Bundle Item 1"))
+
def test_uom_conversion_factor(self):
if frappe.db.exists('Item', 'Test Item UOM'):
frappe.delete_doc('Item', 'Test Item UOM')
@@ -608,6 +630,45 @@
item.item_group = "All Item Groups"
item.save() # if item code saved without item_code then series worked
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_item_wise_negative_stock(self):
+ """ When global settings are disabled check that item that allows
+ negative stock can still consume material in all known stock
+ transactions that consume inventory."""
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
+
+ item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+ self.assertTrue(is_negative_stock_allowed(item_code=item.name))
+
+ self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+ @change_settings("Stock Settings", {"allow_negative_stock": 0})
+ def test_backdated_negative_stock(self):
+ """ same as test above but backdated entries """
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+ item = make_item("_TestNegativeItemSetting", {"allow_negative_stock": 1, "valuation_rate": 100})
+
+ # create a future entry so all new entries are backdated
+ make_stock_entry(qty=1, item_code=item.name, target="_Test Warehouse - _TC", posting_date = add_days(today(), 5))
+ self.consume_item_code_with_differet_stock_transactions(item_code=item.name)
+
+
+ def consume_item_code_with_differet_stock_transactions(self, item_code, warehouse="_Test Warehouse - _TC"):
+ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
+ from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
+
+ typical_args = {"item_code": item_code, "warehouse": warehouse}
+
+ create_delivery_note(**typical_args)
+ create_sales_invoice(update_stock=1, **typical_args)
+ make_stock_entry(item_code=item_code, source=warehouse, qty=1, purpose="Material Issue")
+ make_stock_entry(item_code=item_code, source=warehouse, target="Stores - _TC", qty=1)
+ # standalone return
+ make_purchase_receipt(is_return=True, qty=-1, **typical_args)
+
+
def set_item_variant_settings(fields):
doc = frappe.get_doc('Item Variant Settings')
diff --git a/erpnext/stock/doctype/material_request/material_request.py b/erpnext/stock/doctype/material_request/material_request.py
index 103e8d6..51209ac 100644
--- a/erpnext/stock/doctype/material_request/material_request.py
+++ b/erpnext/stock/doctype/material_request/material_request.py
@@ -56,14 +56,13 @@
if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty):
frappe.throw(_("Material Request of maximum {0} can be made for Item {1} against Sales Order {2}").format(actual_so_qty - already_indented, item, so_no))
- # Validate
- # ---------------------
def validate(self):
super(MaterialRequest, self).validate()
self.validate_schedule_date()
self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order')
self.validate_uom_is_integer("uom", "qty")
+ self.validate_material_request_type()
if not self.status:
self.status = "Draft"
@@ -83,6 +82,12 @@
self.reset_default_field_value("set_warehouse", "items", "warehouse")
self.reset_default_field_value("set_from_warehouse", "items", "from_warehouse")
+ def validate_material_request_type(self):
+ """ Validate fields in accordance with selected type """
+
+ if self.material_request_type != "Customer Provided":
+ self.customer = None
+
def set_title(self):
'''Set title as comma separated list of items'''
if not self.title:
@@ -533,6 +538,7 @@
"stock_uom": d.stock_uom,
"expected_delivery_date": d.schedule_date,
"sales_order": d.sales_order,
+ "sales_order_item": d.get("sales_order_item"),
"bom_no": get_item_details(d.item_code).bom_no,
"material_request": mr.name,
"material_request_item": d.name,
diff --git a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
index 5ab7929..d481689 100644
--- a/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
+++ b/erpnext/stock/doctype/purchase_receipt/test_purchase_receipt.py
@@ -1540,6 +1540,7 @@
"conversion_factor": args.conversion_factor or 1.0,
"stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0),
"serial_no": args.serial_no,
+ "batch_no": args.batch_no,
"stock_uom": args.stock_uom or "_Test UOM",
"uom": uom,
"cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'),
diff --git a/erpnext/stock/doctype/putaway_rule/putaway_rule.py b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
index 523ba12..4e472a9 100644
--- a/erpnext/stock/doctype/putaway_rule/putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/putaway_rule.py
@@ -9,7 +9,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
-from frappe.utils import cint, floor, flt, nowdate
+from frappe.utils import cint, cstr, floor, flt, nowdate
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance
@@ -142,11 +142,44 @@
if items_not_accomodated:
show_unassigned_items_message(items_not_accomodated)
- items[:] = updated_table if updated_table else items # modify items table
+ if updated_table and _items_changed(items, updated_table, doctype):
+ items[:] = updated_table
+ frappe.msgprint(_("Applied putaway rules."), alert=True)
if sync and json.loads(sync): # sync with client side
return items
+def _items_changed(old, new, doctype: str) -> bool:
+ """ Check if any items changed by application of putaway rules.
+
+ If not, changing item table can have side effects since `name` items also changes.
+ """
+ if len(old) != len(new):
+ return True
+
+ old = [frappe._dict(item) if isinstance(item, dict) else item for item in old]
+
+ if doctype == "Stock Entry":
+ compare_keys = ("item_code", "t_warehouse", "transfer_qty", "serial_no")
+ sort_key = lambda item: (item.item_code, cstr(item.t_warehouse), # noqa
+ flt(item.transfer_qty), cstr(item.serial_no))
+ else:
+ # purchase receipt / invoice
+ compare_keys = ("item_code", "warehouse", "stock_qty", "received_qty", "serial_no")
+ sort_key = lambda item: (item.item_code, cstr(item.warehouse), # noqa
+ flt(item.stock_qty), flt(item.received_qty), cstr(item.serial_no))
+
+ old_sorted = sorted(old, key=sort_key)
+ new_sorted = sorted(new, key=sort_key)
+
+ # Once sorted by all relevant keys both tables should align if they are same.
+ for old_item, new_item in zip(old_sorted, new_sorted):
+ for key in compare_keys:
+ if old_item.get(key) != new_item.get(key):
+ return True
+ return False
+
+
def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
"""Returns an ordered list of putaway rules to apply on an item."""
filters = {
diff --git a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
index bd4d811..ff1c19a 100644
--- a/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
+++ b/erpnext/stock/doctype/putaway_rule/test_putaway_rule.py
@@ -35,6 +35,18 @@
new_uom.uom_name = "Bag"
new_uom.save()
+ def assertUnchangedItemsOnResave(self, doc):
+ """ Check if same items remain even after reapplication of rules.
+
+ This is required since some business logic like subcontracting
+ depends on `name` of items to be same if item isn't changed.
+ """
+ doc.reload()
+ old_items = {d.name for d in doc.items}
+ doc.save()
+ new_items = {d.name for d in doc.items}
+ self.assertSetEqual(old_items, new_items)
+
def test_putaway_rules_priority(self):
"""Test if rule is applied by priority, irrespective of free space."""
rule_1 = create_putaway_rule(item_code="_Rice", warehouse=self.warehouse_1, capacity=200,
@@ -50,6 +62,8 @@
self.assertEqual(pr.items[1].qty, 100)
self.assertEqual(pr.items[1].warehouse, self.warehouse_2)
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
rule_2.delete()
@@ -162,6 +176,8 @@
# leftover space was for 500 kg (0.5 Bag)
# Since Bag is a whole UOM, 1(out of 2) Bag will be unassigned
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
rule_2.delete()
@@ -196,6 +212,8 @@
self.assertEqual(pr.items[1].warehouse, self.warehouse_1)
self.assertEqual(pr.items[1].putaway_rule, rule_1.name)
+ self.assertUnchangedItemsOnResave(pr)
+
pr.delete()
rule_1.delete()
@@ -239,6 +257,8 @@
self.assertEqual(stock_entry_item.qty, 100) # unassigned 100 out of 200 Kg
self.assertEqual(stock_entry_item.putaway_rule, rule_2.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@@ -294,6 +314,8 @@
self.assertEqual(stock_entry.items[2].qty, 200)
self.assertEqual(stock_entry.items[2].putaway_rule, rule_2.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
@@ -344,6 +366,8 @@
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:]))
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1")
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
pr.cancel()
rule_1.delete()
@@ -366,6 +390,8 @@
self.assertEqual(stock_entry_item.qty, 100)
self.assertEqual(stock_entry_item.putaway_rule, rule_1.name)
+ self.assertUnchangedItemsOnResave(stock_entry)
+
stock_entry.delete()
rule_1.delete()
rule_2.delete()
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.js b/erpnext/stock/doctype/stock_entry/stock_entry.js
index c4b8131..5c9da3a 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.js
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.js
@@ -425,6 +425,7 @@
'posting_time' : frm.doc.posting_time,
'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse),
'serial_no' : item.serial_no,
+ 'batch_no' : item.batch_no,
'company' : frm.doc.company,
'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty),
'voucher_type' : frm.doc.doctype,
@@ -457,6 +458,7 @@
'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse),
'transfer_qty': child.transfer_qty,
'serial_no': child.serial_no,
+ 'batch_no': child.batch_no,
'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty,
'posting_date': frm.doc.posting_date,
'posting_time': frm.doc.posting_time,
@@ -680,6 +682,7 @@
'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse),
'transfer_qty' : d.transfer_qty,
'serial_no' : d.serial_no,
+ 'batch_no' : d.batch_no,
'bom_no' : d.bom_no,
'expense_account' : d.expense_account,
'cost_center' : d.cost_center,
diff --git a/erpnext/stock/doctype/stock_entry/stock_entry.py b/erpnext/stock/doctype/stock_entry/stock_entry.py
index 782fcf0..99cf4de 100644
--- a/erpnext/stock/doctype/stock_entry/stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/stock_entry.py
@@ -433,9 +433,10 @@
)
def set_actual_qty(self):
- allow_negative_stock = cint(frappe.db.get_value("Stock Settings", None, "allow_negative_stock"))
+ from erpnext.stock.stock_ledger import is_negative_stock_allowed
for d in self.get('items'):
+ allow_negative_stock = is_negative_stock_allowed(item_code=d.item_code)
previous_sle = get_previous_sle({
"item_code": d.item_code,
"warehouse": d.s_warehouse or d.t_warehouse,
@@ -509,7 +510,7 @@
d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse,
self.doctype, self.name, d.allow_zero_valuation_rate,
currency=erpnext.get_company_currency(self.company), company=self.company,
- raise_error_if_no_rate=raise_error_if_no_rate)
+ raise_error_if_no_rate=raise_error_if_no_rate, batch_no=d.batch_no)
d.basic_rate = flt(d.basic_rate, d.precision("basic_rate"))
if d.is_process_loss:
@@ -540,6 +541,7 @@
"posting_time": self.posting_time,
"qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty),
"serial_no": item.serial_no,
+ "batch_no": item.batch_no,
"voucher_type": self.doctype,
"voucher_no": self.name,
"company": self.company,
diff --git a/erpnext/stock/doctype/stock_entry/test_stock_entry.py b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
index 306f2c3..c5afa49 100644
--- a/erpnext/stock/doctype/stock_entry/test_stock_entry.py
+++ b/erpnext/stock/doctype/stock_entry/test_stock_entry.py
@@ -44,6 +44,7 @@
class TestStockEntry(ERPNextTestCase):
def tearDown(self):
+ frappe.db.rollback()
frappe.set_user("Administrator")
frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
@@ -565,6 +566,7 @@
st1.set_stock_entry_type()
st1.insert()
st1.submit()
+ st1.cancel()
frappe.set_user("Administrator")
remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com")
@@ -689,6 +691,8 @@
bom_no = frappe.db.get_value("BOM", {"item": "_Test Variant Item",
"is_default": 1, "docstatus": 1})
+ make_item_variant() # make variant of _Test Variant Item if absent
+
work_order = frappe.new_doc("Work Order")
work_order.update({
"company": "_Test Company",
@@ -1023,13 +1027,10 @@
# Check if FG cost is calculated based on RM total cost
# RM total cost = 200, FG rate = 200/4(FG qty) = 50
- self.assertEqual(se.items[1].basic_rate, 50)
+ self.assertEqual(se.items[1].basic_rate, flt(se.items[0].basic_rate/4))
self.assertEqual(se.value_difference, 0.0)
self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
- # teardown
- se.delete()
-
@change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_future_negative_sle(self):
# Initialize item, batch, warehouse, opening qty
@@ -1107,6 +1108,52 @@
posting_date='2021-09-02', # backdated consumption of 2nd batch
purpose='Material Issue')
+ def test_multi_batch_value_diff(self):
+ """ Test value difference on stock entry in case of multi-batch.
+ | Stock entry | batch | qty | rate | value diff on SE |
+ | --- | --- | --- | --- | --- |
+ | receipt | A | 1 | 10 | 30 |
+ | receipt | B | 1 | 20 | |
+ | issue | A | -1 | 10 | -30 (to assert after submit) |
+ | issue | B | -1 | 20 | |
+ """
+ from erpnext.stock.doctype.batch.test_batch import TestBatch
+
+ batch_nos = []
+
+ item_code = '_TestMultibatchFifo'
+ TestBatch.make_batch_item(item_code)
+ warehouse = '_Test Warehouse - _TC'
+ receipt = make_stock_entry(
+ item_code=item_code,
+ qty=1,
+ rate=10,
+ to_warehouse=warehouse,
+ purpose='Material Receipt',
+ do_not_save=True
+ )
+ receipt.append("items", frappe.copy_doc(receipt.items[0], ignore_no_copy=False).update({"basic_rate": 20}) )
+ receipt.save()
+ receipt.submit()
+ batch_nos.extend(row.batch_no for row in receipt.items)
+ self.assertEqual(receipt.value_difference, 30)
+
+ issue = make_stock_entry(
+ item_code=item_code,
+ qty=1,
+ from_warehouse=warehouse,
+ purpose='Material Issue',
+ do_not_save=True
+ )
+ issue.append("items", frappe.copy_doc(issue.items[0], ignore_no_copy=False))
+ for row, batch_no in zip(issue.items, batch_nos):
+ row.batch_no = batch_no
+ issue.save()
+ issue.submit()
+
+ issue.reload() # reload because reposting current voucher updates rate
+ self.assertEqual(issue.value_difference, -30)
+
def make_serialized_item(**args):
args = frappe._dict(args)
se = frappe.copy_doc(test_records[0])
diff --git a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
index a1030d5..0864ece 100644
--- a/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
+++ b/erpnext/stock/doctype/stock_ledger_entry/test_stock_ledger_entry.py
@@ -1,6 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
+import json
+from operator import itemgetter
+from uuid import uuid4
+
import frappe
from frappe.core.page.permission_manager.permission_manager import reset
from frappe.utils import add_days, today
@@ -349,6 +353,317 @@
frappe.set_user("Administrator")
user.remove_roles("Stock Manager")
+ def test_batchwise_item_valuation_moving_average(self):
+ item, warehouses, batches = setup_item_valuation_test(valuation_method="Moving Average")
+
+ # Incoming Entries for Stock Value check
+ pr_entry_list = [
+ (item, warehouses[0], batches[0], 1, 100),
+ (item, warehouses[0], batches[1], 1, 50),
+ (item, warehouses[0], batches[0], 1, 150),
+ (item, warehouses[0], batches[1], 1, 100),
+ ]
+ prs = create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list)
+ sle_details = fetch_sle_details_for_doc_list(prs, ['stock_value'])
+ sv_list = [d['stock_value'] for d in sle_details]
+ expected_sv = [100, 150, 300, 400]
+ self.assertEqual(expected_sv, sv_list, "Incorrect 'Stock Value' values")
+
+ # Outgoing Entries for Stock Value Difference check
+ dn_entry_list = [
+ (item, warehouses[0], batches[1], 1, 200),
+ (item, warehouses[0], batches[0], 1, 200),
+ (item, warehouses[0], batches[1], 1, 200),
+ (item, warehouses[0], batches[0], 1, 200)
+ ]
+ dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list)
+ sle_details = fetch_sle_details_for_doc_list(dns, ['stock_value_difference'])
+ svd_list = [-1 * d['stock_value_difference'] for d in sle_details]
+ expected_incoming_rates = expected_abs_svd = [75, 125, 75, 125]
+
+ self.assertEqual(expected_abs_svd, svd_list, "Incorrect 'Stock Value Difference' values")
+ for dn, incoming_rate in zip(dns, expected_incoming_rates):
+ self.assertEqual(
+ dn.items[0].incoming_rate, incoming_rate,
+ "Incorrect 'Incoming Rate' values fetched for DN items"
+ )
+
+
+ def assertSLEs(self, doc, expected_sles):
+ """ Compare sorted SLEs, useful for vouchers that create multiple SLEs for same line"""
+ sles = frappe.get_all("Stock Ledger Entry", fields=["*"],
+ filters={"voucher_no": doc.name, "voucher_type": doc.doctype, "is_cancelled":0},
+ order_by="timestamp(posting_date, posting_time), creation")
+
+ for exp_sle, act_sle in zip(expected_sles, sles):
+ for k, v in exp_sle.items():
+ act_value = act_sle[k]
+ if k == "stock_queue":
+ act_value = json.loads(act_value)
+ if act_value and act_value[0][0] == 0:
+ # ignore empty fifo bins
+ continue
+
+ self.assertEqual(v, act_value, msg=f"{k} doesn't match \n{exp_sle}\n{act_sle}")
+
+
+ def test_batchwise_item_valuation_stock_reco(self):
+ item, warehouses, batches = setup_item_valuation_test()
+ state = {
+ "stock_value" : 0.0,
+ "qty": 0.0
+ }
+ def update_invariants(exp_sles):
+ for sle in exp_sles:
+ state["stock_value"] += sle["stock_value_difference"]
+ state["qty"] += sle["actual_qty"]
+ sle["stock_value"] = state["stock_value"]
+ sle["qty_after_transaction"] = state["qty"]
+
+ osr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=10, rate=100, batch_no=batches[1])
+ expected_sles = [
+ {"actual_qty": 10, "stock_value_difference": 1000},
+ ]
+ update_invariants(expected_sles)
+ self.assertSLEs(osr1, expected_sles)
+
+ osr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0])
+ expected_sles = [
+ {"actual_qty": 13, "stock_value_difference": 200*13},
+ ]
+ update_invariants(expected_sles)
+ self.assertSLEs(osr2, expected_sles)
+
+ sr1 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=5, rate=50, batch_no=batches[1])
+
+ expected_sles = [
+ {"actual_qty": -10, "stock_value_difference": -10 * 100},
+ {"actual_qty": 5, "stock_value_difference": 250}
+ ]
+ update_invariants(expected_sles)
+ self.assertSLEs(sr1, expected_sles)
+
+ sr2 = create_stock_reconciliation(warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0])
+ expected_sles = [
+ {"actual_qty": -13, "stock_value_difference": -13 * 200},
+ {"actual_qty": 20, "stock_value_difference": 20 * 75}
+ ]
+ update_invariants(expected_sles)
+ self.assertSLEs(sr2, expected_sles)
+
+ def test_batch_wise_valuation_across_warehouse(self):
+ item_code, warehouses, batches = setup_item_valuation_test()
+ source = warehouses[0]
+ target = warehouses[1]
+
+ unrelated_batch = make_stock_entry(item_code=item_code, target=source, batch_no=batches[1],
+ qty=5, rate=10)
+ self.assertSLEs(unrelated_batch, [
+ {"actual_qty": 5, "stock_value_difference": 10 * 5},
+ ])
+
+ reciept = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0], qty=5, rate=10)
+ self.assertSLEs(reciept, [
+ {"actual_qty": 5, "stock_value_difference": 10 * 5},
+ ])
+
+ transfer = make_stock_entry(item_code=item_code, source=source, target=target, batch_no=batches[0], qty=5)
+ self.assertSLEs(transfer, [
+ {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source},
+ {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target}
+ ])
+
+ backdated_receipt = make_stock_entry(item_code=item_code, target=source, batch_no=batches[0],
+ qty=5, rate=20, posting_date=add_days(today(), -1))
+ self.assertSLEs(backdated_receipt, [
+ {"actual_qty": 5, "stock_value_difference": 20 * 5},
+ ])
+
+ # check reposted average rate in *future* transfer
+ self.assertSLEs(transfer, [
+ {"actual_qty": -5, "stock_value_difference": -15 * 5, "warehouse": source, "stock_value": 15 * 5 + 10 * 5},
+ {"actual_qty": 5, "stock_value_difference": 15 * 5, "warehouse": target, "stock_value": 15 * 5}
+ ])
+
+ transfer_unrelated = make_stock_entry(item_code=item_code, source=source,
+ target=target, batch_no=batches[1], qty=5)
+ self.assertSLEs(transfer_unrelated, [
+ {"actual_qty": -5, "stock_value_difference": -10 * 5, "warehouse": source, "stock_value": 15 * 5},
+ {"actual_qty": 5, "stock_value_difference": 10 * 5, "warehouse": target, "stock_value": 15 * 5 + 10 * 5}
+ ])
+
+ def test_intermediate_average_batch_wise_valuation(self):
+ """ A batch has moving average up until posting time,
+ check if same is respected when backdated entry is inserted in middle"""
+ item_code, warehouses, batches = setup_item_valuation_test()
+ warehouse = warehouses[0]
+
+ batch = batches[0]
+
+ yesterday = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batch,
+ qty=1, rate=10, posting_date=add_days(today(), -1))
+ self.assertSLEs(yesterday, [
+ {"actual_qty": 1, "stock_value_difference": 10},
+ ])
+
+ tomorrow = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
+ qty=1, rate=30, posting_date=add_days(today(), 1))
+ self.assertSLEs(tomorrow, [
+ {"actual_qty": 1, "stock_value_difference": 30},
+ ])
+
+ create_today = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
+ qty=1, rate=20)
+ self.assertSLEs(create_today, [
+ {"actual_qty": 1, "stock_value_difference": 20},
+ ])
+
+ consume_today = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0],
+ qty=1)
+ self.assertSLEs(consume_today, [
+ {"actual_qty": -1, "stock_value_difference": -15},
+ ])
+
+ consume_tomorrow = make_stock_entry(item_code=item_code, source=warehouse, batch_no=batches[0],
+ qty=2, posting_date=add_days(today(), 2))
+ self.assertSLEs(consume_tomorrow, [
+ {"stock_value_difference": -(30 + 15), "stock_value": 0, "qty_after_transaction": 0},
+ ])
+
+ def test_legacy_item_valuation_stock_entry(self):
+ columns = [
+ 'stock_value_difference',
+ 'stock_value',
+ 'actual_qty',
+ 'qty_after_transaction',
+ 'stock_queue',
+ ]
+ item, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0)
+
+ def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns):
+ for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)):
+ for col, sle_val, ex_sle_val in zip(columns, sle_vals, ex_sle_vals):
+ if col == 'stock_queue':
+ sle_val = get_stock_value_from_q(sle_val)
+ ex_sle_val = get_stock_value_from_q(ex_sle_val)
+ self.assertEqual(
+ sle_val, ex_sle_val,
+ f"Incorrect {col} value on transaction #: {i} in {detail}"
+ )
+
+ # List used to defer assertions to prevent commits cause of error skipped rollback
+ details_list = []
+
+
+ # Test Material Receipt Entries
+ se_entry_list_mr = [
+ (item, None, warehouses[0], batches[0], 1, 50, "2021-01-21"),
+ (item, None, warehouses[0], batches[1], 1, 100, "2021-01-23"),
+ ]
+ ses = create_stock_entry_entries_for_batchwise_item_valuation_test(
+ se_entry_list_mr, "Material Receipt"
+ )
+ sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
+ expected_sle_details = [
+ (50.0, 50.0, 1.0, 1.0, '[[1.0, 50.0]]'),
+ (100.0, 150.0, 1.0, 2.0, '[[1.0, 50.0], [1.0, 100.0]]'),
+ ]
+ details_list.append((
+ sle_details, expected_sle_details,
+ "Material Receipt Entries", columns
+ ))
+
+
+ # Test Material Issue Entries
+ se_entry_list_mi = [
+ (item, warehouses[0], None, batches[1], 1, None, "2021-01-29"),
+ ]
+ ses = create_stock_entry_entries_for_batchwise_item_valuation_test(
+ se_entry_list_mi, "Material Issue"
+ )
+ sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
+ expected_sle_details = [
+ (-50.0, 100.0, -1.0, 1.0, '[[1, 100.0]]')
+ ]
+ details_list.append((
+ sle_details, expected_sle_details,
+ "Material Issue Entries", columns
+ ))
+
+
+ # Run assertions
+ for details in details_list:
+ check_sle_details_against_expected(*details)
+
+ def test_mixed_valuation_batches_fifo(self):
+ item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0)
+ warehouse = warehouses[0]
+
+ state = {
+ "qty": 0.0,
+ "stock_value": 0.0
+ }
+ def update_invariants(exp_sles):
+ for sle in exp_sles:
+ state["stock_value"] += sle["stock_value_difference"]
+ state["qty"] += sle["actual_qty"]
+ sle["stock_value"] = state["stock_value"]
+ sle["qty_after_transaction"] = state["qty"]
+ return exp_sles
+
+ old1 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
+ qty=10, rate=10)
+ self.assertSLEs(old1, update_invariants([
+ {"actual_qty": 10, "stock_value_difference": 10*10, "stock_queue": [[10, 10]]},
+ ]))
+ old2 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[1],
+ qty=10, rate=20)
+ self.assertSLEs(old2, update_invariants([
+ {"actual_qty": 10, "stock_value_difference": 10*20, "stock_queue": [[10, 10], [10, 20]]},
+ ]))
+ old3 = make_stock_entry(item_code=item_code, target=warehouse, batch_no=batches[0],
+ qty=5, rate=15)
+
+ self.assertSLEs(old3, update_invariants([
+ {"actual_qty": 5, "stock_value_difference": 5*15, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
+ ]))
+
+ new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40)
+ batches.append(new1.items[0].batch_no)
+ # assert old queue remains
+ self.assertSLEs(new1, update_invariants([
+ {"actual_qty": 10, "stock_value_difference": 10*40, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
+ ]))
+
+ new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42)
+ batches.append(new2.items[0].batch_no)
+ self.assertSLEs(new2, update_invariants([
+ {"actual_qty": 10, "stock_value_difference": 10*42, "stock_queue": [[10, 10], [10, 20], [5, 15]]},
+ ]))
+
+ # consume old batch as per FIFO
+ consume_old1 = make_stock_entry(item_code=item_code, source=warehouse, qty=15, batch_no=batches[0])
+ self.assertSLEs(consume_old1, update_invariants([
+ {"actual_qty": -15, "stock_value_difference": -10*10 - 5*20, "stock_queue": [[5, 20], [5, 15]]},
+ ]))
+
+ # consume new batch as per batch
+ consume_new2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1])
+ self.assertSLEs(consume_new2, update_invariants([
+ {"actual_qty": -10, "stock_value_difference": -10*42, "stock_queue": [[5, 20], [5, 15]]},
+ ]))
+
+ # finish all old batches
+ consume_old2 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[1])
+ self.assertSLEs(consume_old2, update_invariants([
+ {"actual_qty": -10, "stock_value_difference": -5*20 - 5*15, "stock_queue": []},
+ ]))
+
+ # finish all new batches
+ consume_new1 = make_stock_entry(item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2])
+ self.assertSLEs(consume_new1, update_invariants([
+ {"actual_qty": -10, "stock_value_difference": -10*40, "stock_queue": []},
+ ]))
def create_repack_entry(**args):
args = frappe._dict(args)
@@ -412,3 +727,118 @@
make_item(d, properties=properties)
return items
+
+def setup_item_valuation_test(valuation_method="FIFO", suffix=None, use_batchwise_valuation=1, batches_list=['X', 'Y']):
+ from erpnext.stock.doctype.batch.batch import make_batch
+ from erpnext.stock.doctype.item.test_item import make_item
+ from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
+
+ if not suffix:
+ suffix = get_unique_suffix()
+
+ item = make_item(
+ f"IV - Test Item {valuation_method} {suffix}",
+ dict(valuation_method=valuation_method, has_batch_no=1, create_new_batch=1)
+ )
+ warehouses = [create_warehouse(f"IV - Test Warehouse {i}") for i in ['J', 'K']]
+ batches = [f"IV - Test Batch {i} {valuation_method} {suffix}" for i in batches_list]
+
+ for i, batch_id in enumerate(batches):
+ if not frappe.db.exists("Batch", batch_id):
+ ubw = use_batchwise_valuation
+ if isinstance(use_batchwise_valuation, (list, tuple)):
+ ubw = use_batchwise_valuation[i]
+ batch = frappe.get_doc(frappe._dict(
+ doctype="Batch",
+ batch_id=batch_id,
+ item=item.item_code,
+ use_batchwise_valuation=ubw
+ )
+ ).insert()
+ batch.use_batchwise_valuation = ubw
+ batch.db_update()
+
+ return item.item_code, warehouses, batches
+
+def create_purchase_receipt_entries_for_batchwise_item_valuation_test(pr_entry_list):
+ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
+ prs = []
+
+ for item, warehouse, batch_no, qty, rate in pr_entry_list:
+ pr = make_purchase_receipt(item=item, warehouse=warehouse, qty=qty, rate=rate, batch_no=batch_no)
+ prs.append(pr)
+
+ return prs
+
+def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list):
+ from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
+ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
+ dns = []
+ for item, warehouse, batch_no, qty, rate in dn_entry_list:
+ so = make_sales_order(
+ rate=rate,
+ qty=qty,
+ item=item,
+ warehouse=warehouse,
+ against_blanket_order=0
+ )
+
+ dn = make_delivery_note(so.name)
+ dn.items[0].batch_no = batch_no
+ dn.insert()
+ dn.submit()
+ dns.append(dn)
+ return dns
+
+def fetch_sle_details_for_doc_list(doc_list, columns, as_dict=1):
+ return frappe.db.sql(f"""
+ SELECT { ', '.join(columns)}
+ FROM `tabStock Ledger Entry`
+ WHERE
+ voucher_no IN %(voucher_nos)s
+ and docstatus = 1
+ ORDER BY timestamp(posting_date, posting_time) ASC, CREATION ASC
+ """, dict(
+ voucher_nos=[doc.name for doc in doc_list]
+ ), as_dict=as_dict)
+
+def get_stock_value_from_q(q):
+ return sum(r*q for r,q in json.loads(q))
+
+def create_stock_entry_entries_for_batchwise_item_valuation_test(se_entry_list, purpose):
+ ses = []
+ for item, source, target, batch, qty, rate, posting_date in se_entry_list:
+ args = dict(
+ item_code=item,
+ qty=qty,
+ company="_Test Company",
+ batch_no=batch,
+ posting_date=posting_date,
+ purpose=purpose
+ )
+
+ if purpose == "Material Receipt":
+ args.update(
+ dict(to_warehouse=target, rate=rate)
+ )
+
+ elif purpose == "Material Issue":
+ args.update(
+ dict(from_warehouse=source)
+ )
+
+ elif purpose == "Material Transfer":
+ args.update(
+ dict(from_warehouse=source, to_warehouse=target)
+ )
+
+ else:
+ raise ValueError(f"Invalid purpose: {purpose}")
+ ses.append(make_stock_entry(**args))
+
+ return ses
+
+def get_unique_suffix():
+ # Used to isolate valuation sensitive
+ # tests to prevent future tests from failing.
+ return str(uuid4())[:8].upper()
diff --git a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
index 86af0a0..2ffe127 100644
--- a/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/test_stock_reconciliation.py
@@ -200,7 +200,6 @@
def test_stock_reco_for_batch_item(self):
to_delete_records = []
- to_delete_serial_nos = []
# Add new serial nos
item_code = "Stock-Reco-batch-Item-1"
@@ -208,20 +207,22 @@
sr = create_stock_reconciliation(item_code=item_code,
warehouse = warehouse, qty=5, rate=200, do_not_submit=1)
- sr.save(ignore_permissions=True)
+ sr.save()
sr.submit()
- self.assertTrue(sr.items[0].batch_no)
+ batch_no = sr.items[0].batch_no
+ self.assertTrue(batch_no)
to_delete_records.append(sr.name)
sr1 = create_stock_reconciliation(item_code=item_code,
- warehouse = warehouse, qty=6, rate=300, batch_no=sr.items[0].batch_no)
+ warehouse = warehouse, qty=6, rate=300, batch_no=batch_no)
args = {
"item_code": item_code,
"warehouse": warehouse,
"posting_date": nowdate(),
"posting_time": nowtime(),
+ "batch_no": batch_no,
}
valuation_rate = get_incoming_rate(args)
@@ -230,7 +231,7 @@
sr2 = create_stock_reconciliation(item_code=item_code,
- warehouse = warehouse, qty=0, rate=0, batch_no=sr.items[0].batch_no)
+ warehouse = warehouse, qty=0, rate=0, batch_no=batch_no)
stock_value = get_stock_value_on(warehouse, nowdate(), item_code)
self.assertEqual(stock_value, 0)
diff --git a/erpnext/stock/get_item_details.py b/erpnext/stock/get_item_details.py
index 06f8fa7..9bec5f7 100644
--- a/erpnext/stock/get_item_details.py
+++ b/erpnext/stock/get_item_details.py
@@ -6,6 +6,7 @@
import frappe
from frappe import _, throw
+from frappe.model import child_table_fields, default_fields
from frappe.model.meta import get_field_precision
from frappe.utils import add_days, add_months, cint, cstr, flt, getdate
@@ -119,8 +120,15 @@
out.rate = args.rate or out.price_list_rate
out.amount = flt(args.qty) * flt(out.rate)
+ out = remove_standard_fields(out)
return out
+def remove_standard_fields(details):
+ for key in child_table_fields + default_fields:
+ details.pop(key, None)
+ return details
+
+
def update_stock(args, out):
if (args.get("doctype") == "Delivery Note" or
(args.get("doctype") == "Sales Invoice" and args.get('update_stock'))) \
@@ -343,6 +351,7 @@
args.conversion_factor = out.conversion_factor
out.stock_qty = out.qty * out.conversion_factor
+ args.stock_qty = out.stock_qty
# calculate last purchase rate
if args.get('doctype') in purchase_doctypes:
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing.py b/erpnext/stock/report/stock_ageing/stock_ageing.py
index e6dfc97..97a740e 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/stock_ageing.py
@@ -12,6 +12,7 @@
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict
+precision = cint(frappe.db.get_single_value("System Settings", "float_precision"))
def execute(filters: Filters = None) -> Tuple:
to_date = filters["to_date"]
@@ -48,10 +49,13 @@
if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse)
- row.extend([item_dict.get("total_qty"), average_age,
+ row.extend([
+ flt(item_dict.get("total_qty"), precision),
+ average_age,
range1, range2, range3, above_range3,
earliest_age, latest_age,
- details.stock_uom])
+ details.stock_uom
+ ])
data.append(row)
@@ -79,13 +83,13 @@
qty = flt(item[0]) if not item_dict["has_serial_no"] else 1.0
if age <= filters.range1:
- range1 += qty
+ range1 = flt(range1 + qty, precision)
elif age <= filters.range2:
- range2 += qty
+ range2 = flt(range2 + qty, precision)
elif age <= filters.range3:
- range3 += qty
+ range3 = flt(range3 + qty, precision)
else:
- above_range3 += qty
+ above_range3 = flt(above_range3 + qty, precision)
return range1, range2, range3, above_range3
@@ -252,6 +256,7 @@
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
if d.voucher_type == "Stock Reconciliation":
+ # get difference in qty shift as actual qty
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
@@ -264,12 +269,16 @@
self.__update_balances(d, key)
+ if not self.filters.get("show_warehouse_wise_stock"):
+ # (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
+ self.item_details = self.__aggregate_details_by_item(self.item_details)
+
return self.item_details
def __init_key_stores(self, row: Dict) -> Tuple:
"Initialise keys and FIFO Queue."
- key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
+ key = (row.name, row.warehouse)
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
fifo_queue = self.item_details[key]["fifo_queue"]
@@ -281,14 +290,16 @@
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
"Update FIFO Queue on inward stock."
- if self.transferred_item_details.get(transfer_key):
+ transfer_data = self.transferred_item_details.get(transfer_key)
+ if transfer_data:
# inward/outward from same voucher, item & warehouse
- slot = self.transferred_item_details[transfer_key].pop(0)
- fifo_queue.append(slot)
+ # eg: Repack with same item, Stock reco for batch item
+ # consume transfer data and add stock to fifo queue
+ self.__adjust_incoming_transfer_qty(transfer_data, fifo_queue, row)
else:
if not serial_nos:
- if fifo_queue and flt(fifo_queue[0][0]) < 0:
- # neutralize negative stock by adding positive stock
+ if fifo_queue and flt(fifo_queue[0][0]) <= 0:
+ # neutralize 0/negative stock by adding positive stock
fifo_queue[0][0] += flt(row.actual_qty)
fifo_queue[0][1] = row.posting_date
else:
@@ -319,7 +330,7 @@
elif not fifo_queue:
# negative stock, no balance but qty yet to consume
fifo_queue.append([-(qty_to_pop), row.posting_date])
- self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
+ self.transferred_item_details[transfer_key].append([qty_to_pop, row.posting_date])
qty_to_pop = 0
else:
# qty to pop < slot qty, ample balance
@@ -328,6 +339,33 @@
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
qty_to_pop = 0
+ def __adjust_incoming_transfer_qty(self, transfer_data: Dict, fifo_queue: List, row: Dict):
+ "Add previously removed stock back to FIFO Queue."
+ transfer_qty_to_pop = flt(row.actual_qty)
+
+ def add_to_fifo_queue(slot):
+ if fifo_queue and flt(fifo_queue[0][0]) <= 0:
+ # neutralize 0/negative stock by adding positive stock
+ fifo_queue[0][0] += flt(slot[0])
+ fifo_queue[0][1] = slot[1]
+ else:
+ fifo_queue.append(slot)
+
+ while transfer_qty_to_pop:
+ if transfer_data and 0 < transfer_data[0][0] <= transfer_qty_to_pop:
+ # bucket qty is not enough, consume whole
+ transfer_qty_to_pop -= transfer_data[0][0]
+ add_to_fifo_queue(transfer_data.pop(0))
+ elif not transfer_data:
+ # transfer bucket is empty, extra incoming qty
+ add_to_fifo_queue([transfer_qty_to_pop, row.posting_date])
+ transfer_qty_to_pop = 0
+ else:
+ # ample bucket qty to consume
+ transfer_data[0][0] -= transfer_qty_to_pop
+ add_to_fifo_queue([transfer_qty_to_pop, transfer_data[0][1]])
+ transfer_qty_to_pop = 0
+
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
@@ -338,6 +376,27 @@
self.item_details[key]["has_serial_no"] = row.has_serial_no
+ def __aggregate_details_by_item(self, wh_wise_data: Dict) -> Dict:
+ "Aggregate Item-Wh wise data into single Item entry."
+ item_aggregated_data = {}
+ for key,row in wh_wise_data.items():
+ item = key[0]
+ if not item_aggregated_data.get(item):
+ item_aggregated_data.setdefault(item, {
+ "details": frappe._dict(),
+ "fifo_queue": [],
+ "qty_after_transaction": 0.0,
+ "total_qty": 0.0
+ })
+ item_row = item_aggregated_data.get(item)
+ item_row["details"].update(row["details"])
+ item_row["fifo_queue"].extend(row["fifo_queue"])
+ item_row["qty_after_transaction"] += flt(row["qty_after_transaction"])
+ item_row["total_qty"] += flt(row["total_qty"])
+ item_row["has_serial_no"] = row["has_serial_no"]
+
+ return item_aggregated_data
+
def __get_stock_ledger_entries(self) -> List[Dict]:
sle = frappe.qb.DocType("Stock Ledger Entry")
item = self.__get_item_query() # used as derived table in sle query
diff --git a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
index 5ffe97f..3d759dd 100644
--- a/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
+++ b/erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
@@ -15,6 +15,7 @@
50 qty is (today-the 1st) days old
20 qty is (today-the 2nd) days old
+> Note: We generate FIFO slots warehouse wise as stock reconciliations from different warehouses can cause incorrect values.
### Calculation of FIFO Slots
#### Case 1: Outward from sufficient balance qty
@@ -70,4 +71,39 @@
2nd | -60 | [[-10, 1-12-2021]]
3rd | +5 | [[-5, 3-12-2021]]
4th | +10 | [[5, 4-12-2021]]
-4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
\ No newline at end of file
+4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
+
+### Concept of Transfer Qty Bucket
+In the case of **Repack**, Quantity that comes in, isn't really incoming. It is just new stock repurposed from old stock, due to incoming-outgoing of the same warehouse.
+
+Here, stock is consumed from the FIFO Queue. It is then re-added back to the queue.
+While adding stock back to the queue we need to know how much to add.
+For this we need to keep track of how much was previously consumed.
+Hence we use **Transfer Qty Bucket**.
+
+While re-adding stock, we try to add buckets that were consumed earlier (date intact), to maintain correctness.
+
+#### Case 1: Same Item-Warehouse in Repack
+Eg:
+-------------------------------------------------------------------------------------
+Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
+-------------------------------------------------------------------------------------
+1st | +500 | PR | [[500, 1-12-2021]] |
+2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
+2nd | +50 | Repack | [[450, 1-12-2021], [50, 1-12-2021]] | []
+
+- The balance at the end is restored back to 500
+- However, the initial 500 qty bucket is now split into 450 and 50, with the same date
+- The net effect is the same as that before the Repack
+
+#### Case 2: Same Item-Warehouse in Repack with Split Consumption rows
+Eg:
+-------------------------------------------------------------------------------------
+Date | Qty | Voucher | FIFO Queue | Transfer Qty Buckets
+-------------------------------------------------------------------------------------
+1st | +500 | PR | [[500, 1-12-2021]] |
+2nd | -50 | Repack | [[450, 1-12-2021]] | [[50, 1-12-2021]]
+2nd | -50 | Repack | [[400, 1-12-2021]] | [[50, 1-12-2021],
+- | | | |[50, 1-12-2021]]
+2nd | +100 | Repack | [[400, 1-12-2021], [50, 1-12-2021], | []
+- | | | [50, 1-12-2021]] |
diff --git a/erpnext/stock/report/stock_ageing/test_stock_ageing.py b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
index 949bb7c..3fc357e 100644
--- a/erpnext/stock/report/stock_ageing/test_stock_ageing.py
+++ b/erpnext/stock/report/stock_ageing/test_stock_ageing.py
@@ -3,7 +3,7 @@
import frappe
-from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
+from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data
from erpnext.tests.utils import ERPNextTestCase
@@ -11,15 +11,17 @@
def setUp(self) -> None:
self.filters = frappe._dict(
company="_Test Company",
- to_date="2021-12-10"
+ to_date="2021-12-10",
+ range1=30, range2=60, range3=90
)
def test_normal_inward_outward_queue(self):
- "Reference: Case 1 in stock_ageing_fifo_logic.md"
+ "Reference: Case 1 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -27,6 +29,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=50,
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -34,6 +37,7 @@
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -50,11 +54,12 @@
self.assertEqual(queue[0][0], 20.0)
def test_insufficient_balance(self):
- "Reference: Case 3 in stock_ageing_fifo_logic.md"
+ "Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
sle = [
frappe._dict(
name="Flask Item",
actual_qty=(-30), qty_after_transaction=(-30),
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -62,6 +67,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=(-10),
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Entry",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -69,6 +75,7 @@
frappe._dict(
name="Flask Item",
actual_qty=20, qty_after_transaction=10,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -76,6 +83,7 @@
frappe._dict(
name="Flask Item",
actual_qty=10, qty_after_transaction=20,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="004",
has_serial_no=False, serial_no=None
@@ -91,11 +99,16 @@
self.assertEqual(queue[0][0], 10.0)
self.assertEqual(queue[1][0], 10.0)
- def test_stock_reconciliation(self):
+ def test_basic_stock_reconciliation(self):
+ """
+ Ledger (same wh): [+30, reco reset >> 50, -10]
+ Bal: 40
+ """
sle = [
frappe._dict(
name="Flask Item",
actual_qty=30, qty_after_transaction=30,
+ warehouse="WH 1",
posting_date="2021-12-01", voucher_type="Stock Entry",
voucher_no="001",
has_serial_no=False, serial_no=None
@@ -103,6 +116,7 @@
frappe._dict(
name="Flask Item",
actual_qty=0, qty_after_transaction=50,
+ warehouse="WH 1",
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
voucher_no="002",
has_serial_no=False, serial_no=None
@@ -110,6 +124,7 @@
frappe._dict(
name="Flask Item",
actual_qty=(-10), qty_after_transaction=40,
+ warehouse="WH 1",
posting_date="2021-12-03", voucher_type="Stock Entry",
voucher_no="003",
has_serial_no=False, serial_no=None
@@ -122,5 +137,477 @@
queue = result["fifo_queue"]
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(result["total_qty"], 40.0)
self.assertEqual(queue[0][0], 20.0)
self.assertEqual(queue[1][0], 20.0)
+
+ def test_sequential_stock_reco_same_warehouse(self):
+ """
+ Test back to back stock recos (same warehouse).
+ Ledger: [reco opening >> +1000, reco reset >> 400, -10]
+ Bal: 390
+ """
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=1000,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Reconciliation",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=400,
+ warehouse="WH 1",
+ posting_date="2021-12-02", voucher_type="Stock Reconciliation",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=390,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+ slots = FIFOSlots(self.filters, sle).generate()
+
+ result = slots["Flask Item"]
+ queue = result["fifo_queue"]
+
+ self.assertEqual(result["qty_after_transaction"], result["total_qty"])
+ self.assertEqual(result["total_qty"], 390.0)
+ self.assertEqual(queue[0][0], 390.0)
+
+ def test_sequential_stock_reco_different_warehouse(self):
+ """
+ Ledger:
+ WH | Voucher | Qty
+ -------------------
+ WH1 | Reco | 1000
+ WH2 | Reco | 400
+ WH1 | SE | -10
+
+ Bal: WH1 bal + WH2 bal = 990 + 400 = 1390
+ """
+ sle = [
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=1000,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Reconciliation",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=0, qty_after_transaction=400,
+ warehouse="WH 2",
+ posting_date="2021-12-02", voucher_type="Stock Reconciliation",
+ voucher_no="003",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-10), qty_after_transaction=990,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="004",
+ has_serial_no=False, serial_no=None
+ )
+ ]
+
+ item_wise_slots, item_wh_wise_slots = generate_item_and_item_wh_wise_slots(
+ filters=self.filters,sle=sle
+ )
+
+ # test without 'show_warehouse_wise_stock'
+ item_result = item_wise_slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["qty_after_transaction"], item_result["total_qty"])
+ self.assertEqual(item_result["total_qty"], 1390.0)
+ self.assertEqual(queue[0][0], 990.0)
+ self.assertEqual(queue[1][0], 400.0)
+
+ # test with 'show_warehouse_wise_stock' checked
+ item_wh_balances = [item_wh_wise_slots.get(i).get("qty_after_transaction") for i in item_wh_wise_slots]
+ self.assertEqual(sum(item_wh_balances), item_result["qty_after_transaction"])
+
+ def test_repack_entry_same_item_split_rows(self):
+ """
+ Split consumption rows and have single repacked item row (same warehouse).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | 500 | 001
+ Item 1 | -50 | 002 (repack)
+ Item 1 | -50 | 002 (repack)
+ Item 1 | 100 | 002 (repack)
+
+ Case most likely for batch items. Test time bucket computation.
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=500, qty_after_transaction=500,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=450,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=400,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=100, qty_after_transaction=500,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ slots = FIFOSlots(self.filters, sle).generate()
+ item_result = slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["total_qty"], 500.0)
+ self.assertEqual(queue[0][0], 400.0)
+ self.assertEqual(queue[1][0], 50.0)
+ self.assertEqual(queue[2][0], 50.0)
+ # check if time buckets add up to balance qty
+ self.assertEqual(sum([i[0] for i in queue]), 500.0)
+
+ def test_repack_entry_same_item_overconsume(self):
+ """
+ Over consume item and have less repacked item qty (same warehouse).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | 500 | 001
+ Item 1 | -100 | 002 (repack)
+ Item 1 | 50 | 002 (repack)
+
+ Case most likely for batch items. Test time bucket computation.
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=500, qty_after_transaction=500,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-100), qty_after_transaction=400,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=50, qty_after_transaction=450,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ slots = FIFOSlots(self.filters, sle).generate()
+ item_result = slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["total_qty"], 450.0)
+ self.assertEqual(queue[0][0], 400.0)
+ self.assertEqual(queue[1][0], 50.0)
+ # check if time buckets add up to balance qty
+ self.assertEqual(sum([i[0] for i in queue]), 450.0)
+
+ def test_repack_entry_same_item_overconsume_with_split_rows(self):
+ """
+ Over consume item and have less repacked item qty (same warehouse).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | 20 | 001
+ Item 1 | -50 | 002 (repack)
+ Item 1 | -50 | 002 (repack)
+ Item 1 | 50 | 002 (repack)
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=20, qty_after_transaction=20,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=(-30),
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=(-80),
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=50, qty_after_transaction=(-30),
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ fifo_slots = FIFOSlots(self.filters, sle)
+ slots = fifo_slots.generate()
+ item_result = slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["total_qty"], -30.0)
+ self.assertEqual(queue[0][0], -30.0)
+
+ # check transfer bucket
+ transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
+ self.assertEqual(transfer_bucket[0][0], 50)
+
+ def test_repack_entry_same_item_overproduce(self):
+ """
+ Under consume item and have more repacked item qty (same warehouse).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | 500 | 001
+ Item 1 | -50 | 002 (repack)
+ Item 1 | 100 | 002 (repack)
+
+ Case most likely for batch items. Test time bucket computation.
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=500, qty_after_transaction=500,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=450,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=100, qty_after_transaction=550,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ slots = FIFOSlots(self.filters, sle).generate()
+ item_result = slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["total_qty"], 550.0)
+ self.assertEqual(queue[0][0], 450.0)
+ self.assertEqual(queue[1][0], 50.0)
+ self.assertEqual(queue[2][0], 50.0)
+ # check if time buckets add up to balance qty
+ self.assertEqual(sum([i[0] for i in queue]), 550.0)
+
+ def test_repack_entry_same_item_overproduce_with_split_rows(self):
+ """
+ Over consume item and have less repacked item qty (same warehouse).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | 20 | 001
+ Item 1 | -50 | 002 (repack)
+ Item 1 | 50 | 002 (repack)
+ Item 1 | 50 | 002 (repack)
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=20, qty_after_transaction=20,
+ warehouse="WH 1",
+ posting_date="2021-12-03", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=(-30),
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=50, qty_after_transaction=20,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict(
+ name="Flask Item",
+ actual_qty=50, qty_after_transaction=70,
+ warehouse="WH 1",
+ posting_date="2021-12-04", voucher_type="Stock Entry",
+ voucher_no="002",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ fifo_slots = FIFOSlots(self.filters, sle)
+ slots = fifo_slots.generate()
+ item_result = slots["Flask Item"]
+ queue = item_result["fifo_queue"]
+
+ self.assertEqual(item_result["total_qty"], 70.0)
+ self.assertEqual(queue[0][0], 20.0)
+ self.assertEqual(queue[1][0], 50.0)
+
+ # check transfer bucket
+ transfer_bucket = fifo_slots.transferred_item_details[('002', 'Flask Item', 'WH 1')]
+ self.assertFalse(transfer_bucket)
+
+ def test_negative_stock_same_voucher(self):
+ """
+ Test negative stock scenario in transfer bucket via repack entry (same wh).
+ Ledger:
+ Item | Qty | Voucher
+ ------------------------
+ Item 1 | -50 | 001
+ Item 1 | -50 | 001
+ Item 1 | 30 | 001
+ Item 1 | 80 | 001
+ """
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=(-50),
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=(-50), qty_after_transaction=(-100),
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=30, qty_after_transaction=(-70),
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+ fifo_slots = FIFOSlots(self.filters, sle)
+ slots = fifo_slots.generate()
+ item_result = slots["Flask Item"]
+
+ # check transfer bucket
+ transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
+ self.assertEqual(transfer_bucket[0][0], 20)
+ self.assertEqual(transfer_bucket[1][0], 50)
+ self.assertEqual(item_result["fifo_queue"][0][0], -70.0)
+
+ sle.append(frappe._dict(
+ name="Flask Item",
+ actual_qty=80, qty_after_transaction=10,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ))
+
+ fifo_slots = FIFOSlots(self.filters, sle)
+ slots = fifo_slots.generate()
+ item_result = slots["Flask Item"]
+
+ transfer_bucket = fifo_slots.transferred_item_details[('001', 'Flask Item', 'WH 1')]
+ self.assertFalse(transfer_bucket)
+ self.assertEqual(item_result["fifo_queue"][0][0], 10.0)
+
+ def test_precision(self):
+ "Test if final balance qty is rounded off correctly."
+ sle = [
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=0.3, qty_after_transaction=0.3,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ frappe._dict( # stock up item
+ name="Flask Item",
+ actual_qty=0.6, qty_after_transaction=0.9,
+ warehouse="WH 1",
+ posting_date="2021-12-01", voucher_type="Stock Entry",
+ voucher_no="001",
+ has_serial_no=False, serial_no=None
+ ),
+ ]
+
+ slots = FIFOSlots(self.filters, sle).generate()
+ report_data = format_report_data(self.filters, slots, self.filters["to_date"])
+ row = report_data[0] # first row in report
+ bal_qty = row[5]
+ range_qty_sum = sum([i for i in row[7:11]]) # get sum of range balance
+
+ # check if value of Available Qty column matches with range bucket post format
+ self.assertEqual(bal_qty, 0.9)
+ self.assertEqual(bal_qty, range_qty_sum)
+
+def generate_item_and_item_wh_wise_slots(filters, sle):
+ "Return results with and without 'show_warehouse_wise_stock'"
+ item_wise_slots = FIFOSlots(filters, sle).generate()
+
+ filters.show_warehouse_wise_stock = True
+ item_wh_wise_slots = FIFOSlots(filters, sle).generate()
+ filters.show_warehouse_wise_stock = False
+
+ return item_wise_slots, item_wh_wise_slots
\ No newline at end of file
diff --git a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
index cb35bf7..7826d34 100644
--- a/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
+++ b/erpnext/stock/report/stock_ledger_invariant_check/stock_ledger_invariant_check.py
@@ -60,6 +60,9 @@
fifo_qty += qty
fifo_value += qty * rate
+ if sle.actual_qty < 0:
+ sle.consumption_rate = sle.stock_value_difference / sle.actual_qty
+
balance_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
@@ -90,6 +93,9 @@
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference
+ if sle.batch_no:
+ sle.use_batchwise_valuation = frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True)
+
return sles
@@ -135,6 +141,11 @@
"options": "Batch",
},
{
+ "fieldname": "use_batchwise_valuation",
+ "fieldtype": "Check",
+ "label": "Batchwise Valuation",
+ },
+ {
"fieldname": "actual_qty",
"fieldtype": "Float",
"label": "Qty Change",
@@ -145,9 +156,9 @@
"label": "Incoming Rate",
},
{
- "fieldname": "outgoing_rate",
+ "fieldname": "consumption_rate",
"fieldtype": "Float",
- "label": "Outgoing Rate",
+ "label": "Consumption Rate",
},
{
"fieldname": "qty_after_transaction",
diff --git a/erpnext/stock/report/test_reports.py b/erpnext/stock/report/test_reports.py
index 525af40..76c2079 100644
--- a/erpnext/stock/report/test_reports.py
+++ b/erpnext/stock/report/test_reports.py
@@ -73,10 +73,11 @@
def test_execute_all_stock_reports(self):
"""Test that all script report in stock modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES:
- execute_script_report(
- report_name=report,
- module="Stock",
- filters=filter,
- default_filters=DEFAULT_FILTERS,
- optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
- )
+ with self.subTest(report=report):
+ execute_script_report(
+ report_name=report,
+ module="Stock",
+ filters=filter,
+ default_filters=DEFAULT_FILTERS,
+ optional_filters=OPTIONAL_FILTERS if filter.get("_optional") else None,
+ )
diff --git a/erpnext/stock/spec/README.md b/erpnext/stock/spec/README.md
new file mode 100644
index 0000000..f5a3501
--- /dev/null
+++ b/erpnext/stock/spec/README.md
@@ -0,0 +1,103 @@
+# Implementation notes for Stock Ledger
+
+
+## Important files
+
+- `stock/stock_ledger.py`
+- `controllers/stock_controller.py`
+- `stock/valuation.py`
+
+## What is in an Stock Ledger Entry (SLE)?
+
+Stock Ledger Entry is a single row in the Stock Ledger. It signifies some
+modification of stock for a particular Item in the specified warehouse.
+
+- `item_code`: item for which ledger entry is made
+- `warehouse`: warehouse where inventory is affected
+- `actual_qty`: change in qty
+- `qty_after_transaction`: quantity available after the transaction is processed
+- `incoming_rate`: rate at which inventory was received.
+- `is_cancelled`: if 1 then stock ledger entry is cancelled and should not be used
+for any business logic except for the code that handles cancellation.
+- `posting_date` & `posting_time`: Specify the temporal ordering of stock ledger
+ entries. Ties are broken by `creation` timestamp.
+- `voucher_type`: Many transaction can create SLE, e.g. Stock Entry, Purchase
+ Invoice
+- `voucher_no`: `name` of the transaction that created SLE
+- `voucher_detail_no`: `name` of the child table row from parent transaction
+ that created the SLE.
+- `dependant_sle_voucher_detail_no`: cross-warehouse transfers need this
+ reference in order to update dependent warehouse rates in case of change in
+ rate.
+- `recalculate_rate`: if this is checked in/out rates are recomputed on
+ transactions.
+- `valuation_rate`: current average valuation rate.
+- `stock_value`: current total stock value
+- `stock_value_difference`: stock value difference made between last and current
+ entry. This value is booked in accounting ledger.
+- `stock_queue`: if FIFO/LIFO is used this represents queue/stack maintained for
+ computing incoming rate for inventory getting consumed.
+- `batch_no`: batch no for which stock entry is made; each stock entry can only
+ affect one batch number.
+- `serial_no`: newline separated list of serial numbers that were added (if
+ actual_qty > 0) or else removed. Currently multiple serial nos can have single
+ SLE but this will likely change in future.
+
+
+## Implementation of Stock Ledger
+
+Stock Ledger Entry affects stock of combinations of (item_code, warehouse) and
+optionally batch no if specified. For simplicity, lets avoid batch no. for now.
+
+
+Stock Ledger Entry table stores stock ledger for all combinations of item_code
+and warehouse. So whenever any operations are to be performed on said
+item-warehouse combination stock ledger is filtered and sorted by posting
+datetime. A typical query that will give you individual ledger looks like this:
+
+```sql
+select *
+from `tabStock Ledger Entry` as sle
+where
+ is_cancelled = 0 --- cancelled entries don't affect ledger
+ and item_code = 'item_code' and warehouse = 'warehouse_name'
+order by timestamp(posting_date, posting_time), creation
+```
+
+New entry is just an update to the last entry which is found by looking at last
+row in the filter ledger.
+
+
+### Serial nos
+
+Serial numbers do not follow any valuation method configuration and they are
+consumed at rate they were produced unless they are grouped in which case they
+are consumed at weighted average rate.
+
+
+### Batch Nos
+
+Batches are currently NOT consumed as per batch wise valuation rate, instead
+global FIFO queue for the item is used for valuation rate.
+
+
+## Creation process of SLEs
+
+- SLE creation is usually triggered by Stock Transactions using a method
+ conventionally named `update_stock_ledger()` This might not be defined for
+ stock transaction and could be specified somewhere in inheritance hierarchy of
+ controllers.
+- This method produces SLE objects which are processed by `make_sl_entries` in
+ `stock_ledger.py` which commits the SLE to database.
+- `update_entries_after` class is used to process ONLY the inserted SLE's queue
+ and valuation.
+- The change in qty is propagated to future entries immediately. Valuation and
+ queue for future entries is processed in background using repost item
+ valuation.
+
+
+## Accounting impact
+
+- Accounting impact for stock transaction is handled by `get_gl_entries()`
+ method on controllers. Each transaction has different business logic for
+ booking the accounting impact.
diff --git a/erpnext/stock/spec/reposting.md b/erpnext/stock/spec/reposting.md
new file mode 100644
index 0000000..b0d59fe
--- /dev/null
+++ b/erpnext/stock/spec/reposting.md
@@ -0,0 +1,38 @@
+# Stock Reposting
+
+Stock "reposting" is process of re-processing Stock Ledger Entry and GL Entries
+in event of backdated stock transaction.
+
+*Backdated stock transaction*: Any stock transaction for which some
+item-warehouse combination has a future transactions.
+
+## Why is this required?
+Stock Ledger is stateful, it maintains queue, qty at any
+point in time. So if you do a backdated transaction all future values change,
+queues need to be re-evaluated etc. Watch Nabin and Rohit's conference
+presentation for explanation: https://www.youtube.com/watch?v=mw3WAnekGIM
+
+## How is this implemented?
+Whenever backdated transaction is detected, instead of
+fully processing it while submitting, the processing is queued using "Repost
+Item Valuation" doctype. Every hour a scheduled job runs and processes this
+queue (for up to maximum of 25 minutes)
+
+
+## Queue implementation
+- "Repost item valuation" (RIV) is automatically submitted from backdated transactions. (check stock_controller.py)
+- Draft and cancelled RIV are ignored.
+- Keep filter of "submitted" documents when doing anything with RIVs.
+- The default status is "Queued".
+- When background job runs, it picks the oldest pending reposts and changes the status to "In Progress" and when it finishes it
+changes to "Completed"
+- There are two more status: "Failed" when reposting failed and "Skipped" when reposting is deemed not necessary so it's skipped.
+- technical detail: Entry point for whole process is "repost_entries" function in repost_item_valuation.py
+
+
+## How to identify broken stock data:
+There are 4 major reports for checking broken stock data:
+- Incorrect balance qty after the transaction - to check if the running total of qty isn't correct.
+- Incorrect stock value report - to check incorrect value books in accounts for stock transactions
+- Incorrect serial no valuation -specific to serial nos
+- Stock ledger invariant check - combined report for checking qty, running total, queue, balance value etc
diff --git a/erpnext/stock/stock_balance.py b/erpnext/stock/stock_balance.py
index 6663458..62017e4 100644
--- a/erpnext/stock/stock_balance.py
+++ b/erpnext/stock/stock_balance.py
@@ -3,10 +3,9 @@
import frappe
-from frappe.utils import cstr, flt, nowdate, nowtime
+from frappe.utils import cstr, flt, now, nowdate, nowtime
from erpnext.controllers.stock_controller import create_repost_item_valuation_entry
-from erpnext.stock.utils import update_bin
def repost(only_actual=False, allow_negative_stock=False, allow_zero_rate=False, only_bin=False):
@@ -175,6 +174,7 @@
bin.set(field, flt(value))
mismatch = True
+ bin.modified = now()
if mismatch:
bin.set_projected_qty()
bin.db_update()
@@ -227,8 +227,6 @@
"sle_id": sle_doc.name
})
- update_bin(args)
-
create_repost_item_valuation_entry({
"item_code": d[0],
"warehouse": d[1],
diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py
index 41c4002..1b90086 100644
--- a/erpnext/stock/stock_ledger.py
+++ b/erpnext/stock/stock_ledger.py
@@ -3,11 +3,14 @@
import copy
import json
+from typing import Optional
import frappe
from frappe import _
from frappe.model.meta import get_field_precision
+from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate
+from pypika import CustomFunction
import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty
@@ -16,14 +19,13 @@
get_or_make_bin,
get_valuation_method,
)
-from erpnext.stock.valuation import FIFOValuation, LIFOValuation
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
class NegativeStockError(frappe.ValidationError): pass
class SerialNoExistsInFutureTransaction(frappe.ValidationError):
pass
-_exceptions = frappe.local('stockledger_exceptions')
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
from erpnext.controllers.stock_controller import future_sle_exists
@@ -268,11 +270,10 @@
self.verbose = verbose
self.allow_zero_rate = allow_zero_rate
self.via_landed_cost_voucher = via_landed_cost_voucher
- self.allow_negative_stock = allow_negative_stock \
- or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
+ self.item_code = args.get("item_code")
+ self.allow_negative_stock = allow_negative_stock or is_negative_stock_allowed(item_code=self.item_code)
self.args = frappe._dict(args)
- self.item_code = args.get("item_code")
if self.args.sle_id:
self.args['name'] = self.args.sle_id
@@ -447,6 +448,8 @@
self.wh_data.qty_after_transaction = sle.qty_after_transaction
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
+ elif sle.batch_no and frappe.db.get_value("Batch", sle.batch_no, "use_batchwise_valuation", cache=True):
+ self.update_batched_values(sle)
else:
if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no:
# assert
@@ -462,10 +465,11 @@
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else:
self.update_queue_values(sle)
- self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision
self.wh_data.stock_value = flt(self.wh_data.stock_value, self.precision)
+ if not self.wh_data.qty_after_transaction:
+ self.wh_data.stock_value = 0.0
stock_value_difference = self.wh_data.stock_value - self.wh_data.prev_stock_value
self.wh_data.prev_stock_value = self.wh_data.stock_value
@@ -481,6 +485,7 @@
if not self.args.get("sle_id"):
self.update_outgoing_rate_on_transaction(sle)
+
def validate_negative_stock(self, sle):
"""
validate negative stock for entries current datetime onwards
@@ -629,9 +634,7 @@
if not self.wh_data.valuation_rate and sle.voucher_detail_no:
allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_rate:
- self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse,
- sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
- currency=erpnext.get_company_currency(sle.company), company=sle.company)
+ self.wh_data.valuation_rate = self.get_fallback_rate(sle)
def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company
@@ -697,46 +700,70 @@
if not self.wh_data.valuation_rate and sle.voucher_detail_no:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_valuation_rate:
- self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse,
- sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
- currency=erpnext.get_company_currency(sle.company), company=sle.company)
+ self.wh_data.valuation_rate = self.get_fallback_rate(sle)
def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate)
+ self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty)
+
if self.valuation_method == "LIFO":
stock_queue = LIFOValuation(self.wh_data.stock_queue)
else:
stock_queue = FIFOValuation(self.wh_data.stock_queue)
+ _prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value()
+
if actual_qty > 0:
stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else:
def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_valuation_rate:
- return get_valuation_rate(sle.item_code, sle.warehouse,
- sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
- currency=erpnext.get_company_currency(sle.company), company=sle.company)
+ return self.get_fallback_rate(sle)
else:
return 0.0
stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator)
- stock_qty, stock_value = stock_queue.get_total_stock_and_value()
+ _qty, stock_value = stock_queue.get_total_stock_and_value()
+
+ stock_value_difference = stock_value - prev_stock_value
self.wh_data.stock_queue = stock_queue.state
- self.wh_data.stock_value = stock_value
- if stock_qty:
- self.wh_data.valuation_rate = stock_value / stock_qty
-
+ self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference)
if not self.wh_data.stock_queue:
self.wh_data.stock_queue.append([0, sle.incoming_rate or sle.outgoing_rate or self.wh_data.valuation_rate])
+ if self.wh_data.qty_after_transaction:
+ self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
+ def update_batched_values(self, sle):
+ incoming_rate = flt(sle.incoming_rate)
+ actual_qty = flt(sle.actual_qty)
+
+ self.wh_data.qty_after_transaction = round_off_if_near_zero(self.wh_data.qty_after_transaction + actual_qty)
+
+ if actual_qty > 0:
+ stock_value_difference = incoming_rate * actual_qty
+ else:
+ outgoing_rate = get_batch_incoming_rate(item_code=sle.item_code,
+ warehouse=sle.warehouse, batch_no=sle.batch_no, posting_date=sle.posting_date,
+ posting_time=sle.posting_time, creation=sle.creation)
+ if outgoing_rate is None:
+ # This can *only* happen if qty available for the batch is zero.
+ # in such case fall back various other rates.
+ # future entries will correct the overall accounting as each
+ # batch individually uses moving average rates.
+ outgoing_rate = self.get_fallback_rate(sle)
+ stock_value_difference = outgoing_rate * actual_qty
+
+ self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference)
+ if self.wh_data.qty_after_transaction:
+ self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no):
ref_item_dt = ""
@@ -751,6 +778,13 @@
else:
return 0
+ def get_fallback_rate(self, sle) -> float:
+ """When exact incoming rate isn't available use any of other "average" rates as fallback.
+ This should only get used for negative stock."""
+ return get_valuation_rate(sle.item_code, sle.warehouse,
+ sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
+ currency=erpnext.get_company_currency(sle.company), company=sle.company, batch_no=sle.batch_no)
+
def get_sle_before_datetime(self, args):
"""get previous stock ledger entry before current time-bucket"""
sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False)
@@ -897,22 +931,72 @@
['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'],
as_dict=1)
+def get_batch_incoming_rate(item_code, warehouse, batch_no, posting_date, posting_time, creation=None):
+
+ Timestamp = CustomFunction('timestamp', ['date', 'time'])
+
+ sle = frappe.qb.DocType("Stock Ledger Entry")
+
+ timestamp_condition = (Timestamp(sle.posting_date, sle.posting_time) < Timestamp(posting_date, posting_time))
+ if creation:
+ timestamp_condition |= (
+ (Timestamp(sle.posting_date, sle.posting_time) == Timestamp(posting_date, posting_time))
+ & (sle.creation < creation)
+ )
+
+ batch_details = (
+ frappe.qb
+ .from_(sle)
+ .select(
+ Sum(sle.stock_value_difference).as_("batch_value"),
+ Sum(sle.actual_qty).as_("batch_qty")
+ )
+ .where(
+ (sle.item_code == item_code)
+ & (sle.warehouse == warehouse)
+ & (sle.batch_no == batch_no)
+ & (sle.is_cancelled == 0)
+ )
+ .where(timestamp_condition)
+ ).run(as_dict=True)
+
+ if batch_details and batch_details[0].batch_qty:
+ return batch_details[0].batch_value / batch_details[0].batch_qty
+
+
def get_valuation_rate(item_code, warehouse, voucher_type, voucher_no,
- allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True):
+ allow_zero_rate=False, currency=None, company=None, raise_error_if_no_rate=True, batch_no=None):
if not company:
company = frappe.get_cached_value("Warehouse", warehouse, "company")
+ last_valuation_rate = None
+
+ # Get moving average rate of a specific batch number
+ if warehouse and batch_no and frappe.db.get_value("Batch", batch_no, "use_batchwise_valuation"):
+ last_valuation_rate = frappe.db.sql("""
+ select sum(stock_value_difference) / sum(actual_qty)
+ from `tabStock Ledger Entry`
+ where
+ item_code = %s
+ AND warehouse = %s
+ AND batch_no = %s
+ AND is_cancelled = 0
+ AND NOT (voucher_no = %s AND voucher_type = %s)
+ """,
+ (item_code, warehouse, batch_no, voucher_no, voucher_type))
+
# Get valuation rate from last sle for the same item and warehouse
- last_valuation_rate = frappe.db.sql("""select valuation_rate
- from `tabStock Ledger Entry` force index (item_warehouse)
- where
- item_code = %s
- AND warehouse = %s
- AND valuation_rate >= 0
- AND is_cancelled = 0
- AND NOT (voucher_no = %s AND voucher_type = %s)
- order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type))
+ if not last_valuation_rate or last_valuation_rate[0][0] is None:
+ last_valuation_rate = frappe.db.sql("""select valuation_rate
+ from `tabStock Ledger Entry` force index (item_warehouse)
+ where
+ item_code = %s
+ AND warehouse = %s
+ AND valuation_rate >= 0
+ AND is_cancelled = 0
+ AND NOT (voucher_no = %s AND voucher_type = %s)
+ order by posting_date desc, posting_time desc, name desc limit 1""", (item_code, warehouse, voucher_no, voucher_type))
if not last_valuation_rate:
# Get valuation rate from last sle for the item against any warehouse
@@ -1049,10 +1133,7 @@
)"""
def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
- allow_negative_stock = cint(allow_negative_stock) \
- or cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock"))
-
- if allow_negative_stock:
+ if allow_negative_stock or is_negative_stock_allowed(item_code=args.item_code):
return
if not (args.actual_qty < 0 or args.voucher_type == "Stock Reconciliation"):
return
@@ -1121,3 +1202,11 @@
and timestamp(posting_date, posting_time) >= timestamp(%(posting_date)s, %(posting_time)s)
limit 1
""", args, as_dict=1)
+
+
+def is_negative_stock_allowed(*, item_code: Optional[str] = None) -> bool:
+ if cint(frappe.db.get_single_value("Stock Settings", "allow_negative_stock", cache=True)):
+ return True
+ if item_code and cint(frappe.db.get_value("Item", item_code, "allow_negative_stock", cache=True)):
+ return True
+ return False
diff --git a/erpnext/stock/tests/test_valuation.py b/erpnext/stock/tests/test_valuation.py
index 648d440..bdb768f 100644
--- a/erpnext/stock/tests/test_valuation.py
+++ b/erpnext/stock/tests/test_valuation.py
@@ -7,7 +7,7 @@
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
-from erpnext.stock.valuation import FIFOValuation, LIFOValuation, _round_off_if_near_zero
+from erpnext.stock.valuation import FIFOValuation, LIFOValuation, round_off_if_near_zero
from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6)
@@ -113,11 +113,11 @@
self.assertTotalQty(0)
def test_rounding_off_near_zero(self):
- self.assertEqual(_round_off_if_near_zero(0), 0)
- self.assertEqual(_round_off_if_near_zero(1), 1)
- self.assertEqual(_round_off_if_near_zero(-1), -1)
- self.assertEqual(_round_off_if_near_zero(-1e-8), 0)
- self.assertEqual(_round_off_if_near_zero(1e-8), 0)
+ self.assertEqual(round_off_if_near_zero(0), 0)
+ self.assertEqual(round_off_if_near_zero(1), 1)
+ self.assertEqual(round_off_if_near_zero(-1), -1)
+ self.assertEqual(round_off_if_near_zero(-1e-8), 0)
+ self.assertEqual(round_off_if_near_zero(1e-8), 0)
def test_totals(self):
self.queue.add_stock(1, 10)
diff --git a/erpnext/stock/utils.py b/erpnext/stock/utils.py
index c75c737..f85a04f 100644
--- a/erpnext/stock/utils.py
+++ b/erpnext/stock/utils.py
@@ -206,26 +206,31 @@
return bin_obj
-def update_bin(args, allow_negative_stock=False, via_landed_cost_voucher=False):
- """WARNING: This function is deprecated. Inline this function instead of using it."""
- from erpnext.stock.doctype.bin.bin import update_stock
- is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
- if is_stock_item:
- bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
- update_stock(bin_name, args, allow_negative_stock, via_landed_cost_voucher)
- else:
- frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))
-
@frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method"""
- from erpnext.stock.stock_ledger import get_previous_sle, get_valuation_rate
+ from erpnext.stock.stock_ledger import (
+ get_batch_incoming_rate,
+ get_previous_sle,
+ get_valuation_rate,
+ )
if isinstance(args, str):
args = json.loads(args)
- in_rate = 0
+ voucher_no = args.get('voucher_no') or args.get('name')
+
+ in_rate = None
if (args.get("serial_no") or "").strip():
in_rate = get_avg_purchase_rate(args.get("serial_no"))
+ elif args.get("batch_no") and \
+ frappe.db.get_value("Batch", args.get("batch_no"), "use_batchwise_valuation", cache=True):
+ in_rate = get_batch_incoming_rate(
+ item_code=args.get('item_code'),
+ warehouse=args.get('warehouse'),
+ batch_no=args.get("batch_no"),
+ posting_date=args.get("posting_date"),
+ posting_time=args.get("posting_time"),
+ )
else:
valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args)
@@ -236,12 +241,11 @@
elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0
- if not in_rate:
- voucher_no = args.get('voucher_no') or args.get('name')
+ if in_rate is None:
in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'),
args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'),
currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'),
- raise_error_if_no_rate=raise_error_if_no_rate)
+ raise_error_if_no_rate=raise_error_if_no_rate, batch_no=args.get("batch_no"))
return flt(in_rate)
@@ -257,7 +261,7 @@
"""get valuation method from item or default"""
val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True)
if not val_method:
- val_method = frappe.db.get_value("Stock Settings", None, "valuation_method") or "FIFO"
+ val_method = frappe.db.get_value("Stock Settings", None, "valuation_method", cache=True) or "FIFO"
return val_method
def get_fifo_rate(previous_stock_queue, qty):
diff --git a/erpnext/stock/valuation.py b/erpnext/stock/valuation.py
index ee9477e..e2bd1ad 100644
--- a/erpnext/stock/valuation.py
+++ b/erpnext/stock/valuation.py
@@ -34,7 +34,7 @@
total_qty += flt(qty)
total_value += flt(qty) * flt(rate)
- return _round_off_if_near_zero(total_qty), _round_off_if_near_zero(total_value)
+ return round_off_if_near_zero(total_qty), round_off_if_near_zero(total_value)
def __repr__(self):
return str(self.state)
@@ -136,7 +136,7 @@
fifo_bin = self.queue[index]
if qty >= fifo_bin[QTY]:
# consume current bin
- qty = _round_off_if_near_zero(qty - fifo_bin[QTY])
+ qty = round_off_if_near_zero(qty - fifo_bin[QTY])
to_consume = self.queue.pop(index)
consumed_bins.append(list(to_consume))
@@ -148,7 +148,7 @@
break
else:
# qty found in current bin consume it and exit
- fifo_bin[QTY] = _round_off_if_near_zero(fifo_bin[QTY] - qty)
+ fifo_bin[QTY] = round_off_if_near_zero(fifo_bin[QTY] - qty)
consumed_bins.append([qty, fifo_bin[RATE]])
qty = 0
@@ -231,7 +231,7 @@
stock_bin = self.stack[index]
if qty >= stock_bin[QTY]:
# consume current bin
- qty = _round_off_if_near_zero(qty - stock_bin[QTY])
+ qty = round_off_if_near_zero(qty - stock_bin[QTY])
to_consume = self.stack.pop(index)
consumed_bins.append(list(to_consume))
@@ -243,14 +243,14 @@
break
else:
# qty found in current bin consume it and exit
- stock_bin[QTY] = _round_off_if_near_zero(stock_bin[QTY] - qty)
+ stock_bin[QTY] = round_off_if_near_zero(stock_bin[QTY] - qty)
consumed_bins.append([qty, stock_bin[RATE]])
qty = 0
return consumed_bins
-def _round_off_if_near_zero(number: float, precision: int = 7) -> float:
+def round_off_if_near_zero(number: float, precision: int = 7) -> float:
"""Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7.
"""
diff --git a/erpnext/templates/includes/navbar/navbar_items.html b/erpnext/templates/includes/navbar/navbar_items.html
index 3275521..d7adae5 100644
--- a/erpnext/templates/includes/navbar/navbar_items.html
+++ b/erpnext/templates/includes/navbar/navbar_items.html
@@ -13,7 +13,7 @@
<li class="wishlist wishlist-icon hidden">
<a class="nav-link" href="/wishlist">
<svg class="icon icon-lg">
- <use href="#icon-heart-active"></use>
+ <use href="#icon-heart"></use>
</svg>
<span class="badge badge-primary shopping-badge" id="wish-count"></span>
</a>
diff --git a/erpnext/tests/test_zform_loads.py b/erpnext/tests/test_zform_loads.py
new file mode 100644
index 0000000..b6fb636
--- /dev/null
+++ b/erpnext/tests/test_zform_loads.py
@@ -0,0 +1,30 @@
+""" dumb test to check all function calls on known form loads """
+
+import unittest
+
+import frappe
+from frappe.desk.form.load import getdoc
+
+
+class TestFormLoads(unittest.TestCase):
+
+ def test_load(self):
+ erpnext_modules = frappe.get_all("Module Def", filters={"app_name": "erpnext"}, pluck="name")
+ doctypes = frappe.get_all("DocType", {"istable": 0, "issingle": 0, "is_virtual": 0, "module": ("in", erpnext_modules)}, pluck="name")
+
+ for doctype in doctypes:
+ last_doc = frappe.db.get_value(doctype, {}, "name", order_by="modified desc")
+ if not last_doc:
+ continue
+ with self.subTest(msg=f"Loading {doctype} - {last_doc}", doctype=doctype, last_doc=last_doc):
+ try:
+ # reset previous response
+ frappe.response = frappe._dict({"docs":[]})
+ frappe.response.docinfo = None
+
+ getdoc(doctype, last_doc)
+ except Exception as e:
+ self.fail(f"Failed to load {doctype} - {last_doc}: {e}")
+
+ self.assertTrue(frappe.response.docs, msg=f"expected document in reponse, found: {frappe.response.docs}")
+ self.assertTrue(frappe.response.docinfo, msg=f"expected docinfo in reponse, found: {frappe.response.docinfo}")
diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv
index 4a6c834..b882b9d 100644
--- a/erpnext/translations/de.csv
+++ b/erpnext/translations/de.csv
@@ -913,6 +913,7 @@
Email Address,E-Mail-Adresse,
"Email Address must be unique, already exists for {0}","E-Mail-Adresse muss eindeutig sein, diese wird bereits für {0} verwendet",
Email Digest: ,E-Mail-Bericht:,
+Email Digest Recipient,E-Mail-Berichtsempfänger,
Email Reminders will be sent to all parties with email contacts,E-Mail-Erinnerungen werden an alle Parteien mit E-Mail-Kontakten gesendet,
Email Sent,E-Mail wurde versandt,
Email Template,E-Mail-Vorlage,
@@ -1596,6 +1597,7 @@
Middle Income,Mittleres Einkommen,
Middle Name,Zweiter Vorname,
Middle Name (Optional),Weiterer Vorname (optional),
+Milestonde,Meilenstein,
Min Amt can not be greater than Max Amt,Min. Amt kann nicht größer als Max. Amt sein,
Min Qty can not be greater than Max Qty,Mindestmenge kann nicht größer als Maximalmenge sein,
Minimum Lead Age (Days),Mindest Lead-Alter (in Tagen),
@@ -2944,7 +2946,7 @@
Temporary Opening,Temporäre Eröffnungskonten,
Terms and Conditions,Allgemeine Geschäftsbedingungen,
Terms and Conditions Template,Vorlage für Allgemeine Geschäftsbedingungen,
-Territory,Region,
+Territory,Gebiet,
Test,Test,
Thank you,Danke,
Thank you for your business!,Vielen Dank für Ihr Unternehmen!,
@@ -3729,7 +3731,7 @@
Edit Details,Details bearbeiten,
Edit Profile,Profil bearbeiten,
Either GST Transporter ID or Vehicle No is required if Mode of Transport is Road,Bei Straßentransport ist entweder die GST-Transporter-ID oder die Fahrzeug-Nr. Erforderlich,
-Email,Email,
+Email,E-Mail,
Email Campaigns,E-Mail-Kampagnen,
Employee ID is linked with another instructor,Die Mitarbeiter-ID ist mit einem anderen Ausbilder verknüpft,
Employee Tax and Benefits,Mitarbeitersteuern und -leistungen,
@@ -6485,7 +6487,7 @@
Send Emails At,Die E-Mails senden um,
Reminder,Erinnerung,
Daily Work Summary Group User,Tägliche Arbeit Zusammenfassung Gruppenbenutzer,
-email,Email,
+email,E-Mail,
Parent Department,Elternabteilung,
Leave Block List,Urlaubssperrenliste,
Days for which Holidays are blocked for this department.,"Tage, an denen eine Urlaubssperre für diese Abteilung gilt.",
diff --git a/erpnext/utilities/doctype/rename_tool/rename_tool.js b/erpnext/utilities/doctype/rename_tool/rename_tool.js
index 7823055..5553e44 100644
--- a/erpnext/utilities/doctype/rename_tool/rename_tool.js
+++ b/erpnext/utilities/doctype/rename_tool/rename_tool.js
@@ -13,6 +13,12 @@
},
refresh: function(frm) {
frm.disable_save();
+
+ frm.get_field("file_to_rename").df.options = {
+ restrictions: {
+ allowed_file_types: [".csv"],
+ },
+ };
if (!frm.doc.file_to_rename) {
frm.get_field("rename_log").$wrapper.html("");
}
diff --git a/erpnext/www/lms/macros/hero.html b/erpnext/www/lms/macros/hero.html
index e72bfc8..95ba8f7 100644
--- a/erpnext/www/lms/macros/hero.html
+++ b/erpnext/www/lms/macros/hero.html
@@ -11,7 +11,7 @@
{% if frappe.session.user == 'Guest' %}
<a id="signup" class="btn btn-primary btn-lg" href="/login#signup">{{_('Sign Up')}}</a>
{% elif not has_access %}
- <button id="enroll" class="btn btn-primary btn-lg" onclick="enroll()" disabled>{{_('Enroll')}}</button>
+ <button id="enroll" class="btn btn-primary btn-lg" onclick="enroll()">{{_('Enroll')}}</button>
{% endif %}
</p>
</div>
@@ -20,34 +20,35 @@
<script type="text/javascript">
frappe.ready(() => {
btn = document.getElementById('enroll');
- if (btn) btn.disabled = false;
})
function enroll() {
let params = frappe.utils.get_query_params()
let btn = document.getElementById('enroll');
- btn.disbaled = true;
- btn.innerText = __('Enrolling...')
let opts = {
method: 'erpnext.education.utils.enroll_in_program',
args: {
program_name: params.program
- }
+ },
+ freeze: true,
+ freeze_message: __('Enrolling...')
}
frappe.call(opts).then(res => {
let success_dialog = new frappe.ui.Dialog({
title: __('Success'),
+ primary_action_label: __('View Program Content'),
+ primary_action: function() {
+ window.location.reload();
+ },
secondary_action: function() {
- window.location.reload()
+ window.location.reload();
}
})
- success_dialog.set_message(__('You have successfully enrolled for the program '));
- success_dialog.$message.show()
success_dialog.show();
- btn.disbaled = false;
+ success_dialog.set_message(__('You have successfully enrolled for the program '));
})
}
</script>
diff --git a/erpnext/www/shop-by-category/index.py b/erpnext/www/shop-by-category/index.py
index 3946212..09f97ba 100644
--- a/erpnext/www/shop-by-category/index.py
+++ b/erpnext/www/shop-by-category/index.py
@@ -62,8 +62,7 @@
"parent_item_group": "All Item Groups",
"show_in_website": 1
},
- fields=["name", "parent_item_group", "is_group", "image", "route"],
- as_dict=True
+ fields=["name", "parent_item_group", "is_group", "image", "route"]
)
else:
doctype = frappe.unscrub(category)
@@ -71,7 +70,7 @@
if frappe.get_meta(doctype, cached=True).get_field("image"):
fields += ["image"]
- categorical_data[category] = frappe.db.get_all(doctype, fields=fields, as_dict=True)
+ categorical_data[category] = frappe.db.get_all(doctype, fields=fields)
return categorical_data