Merge branch 'develop' into remove-nonprofit
This commit is contained in:
commit
d647de3782
14
.github/helper/install.sh
vendored
14
.github/helper/install.sh
vendored
@ -40,10 +40,14 @@ if [ "$DB" == "postgres" ];then
|
||||
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 @@ bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
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
|
||||
|
34
.mergify.yml
34
.mergify.yml
@ -14,9 +14,39 @@ pull_request_rules:
|
||||
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 @@ pull_request_rules:
|
||||
branches:
|
||||
- version-12-pre-release
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- "{{ author }}"
|
||||
|
@ -7,6 +7,7 @@ import json
|
||||
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 @@ def check_matching(bank_account, company, transaction, document_types):
|
||||
}
|
||||
|
||||
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 @@ def get_queries(bank_account, company, transaction, document_types):
|
||||
|
||||
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 @@ def get_je_matching_query(amount_condition, transaction):
|
||||
# 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"""
|
||||
|
@ -49,7 +49,8 @@ class BankTransaction(StatusUpdater):
|
||||
|
||||
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 @@ def get_paid_amount(payment_entry, currency, bank_account):
|
||||
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))
|
||||
|
||||
|
@ -109,7 +109,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"):
|
||||
frappe.get_doc({
|
||||
"doctype": "Bank",
|
||||
"bank_name":bank_name,
|
||||
}).insert()
|
||||
}).insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
@ -119,7 +119,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"):
|
||||
"account_name":"Checking Account",
|
||||
"bank": bank_name,
|
||||
"account": account_name
|
||||
}).insert()
|
||||
}).insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
@ -184,7 +184,7 @@ def add_vouchers():
|
||||
"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 @@ def add_vouchers():
|
||||
"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 @@ def add_vouchers():
|
||||
"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 @@ def add_vouchers():
|
||||
"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 @@ def add_vouchers():
|
||||
"customer_group":"All Customer Groups",
|
||||
"customer_type": "Company",
|
||||
"customer_name": "Fayva"
|
||||
}).insert()
|
||||
}).insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
|
@ -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
|
||||
}
|
@ -166,7 +166,7 @@ class OpeningInvoiceCreationTool(Document):
|
||||
frappe.scrub(row.party_type): row.party,
|
||||
"is_pos": 0,
|
||||
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
|
||||
"update_stock": 0,
|
||||
"update_stock": 0, # important: https://github.com/frappe/erpnext/pull/23559
|
||||
"invoice_number": row.invoice_number,
|
||||
"disable_rounded_total": 1
|
||||
})
|
||||
|
@ -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.accounting_dimension.test_accounting_dimension imp
|
||||
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 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
|
||||
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"
|
||||
|
@ -1066,7 +1066,7 @@ def get_outstanding_reference_documents(args):
|
||||
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"),
|
||||
|
@ -46,7 +46,7 @@ def valdiate_taxes_and_charges_template(doc):
|
||||
|
||||
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)
|
||||
|
||||
|
@ -307,7 +307,7 @@ def validate_party_gle_currency(party_type, party, company, party_account_curren
|
||||
.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 @@ def validate_party_accounts(doc):
|
||||
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):
|
||||
|
@ -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 @@ def execute(filters=None):
|
||||
|
||||
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_columns():
|
||||
]
|
||||
|
||||
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 @@ def get_entries(filters):
|
||||
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 @@ def get_entries(filters):
|
||||
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 @@ def get_entries(filters):
|
||||
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 @@ def get_amounts_not_reflected_in_system(filters):
|
||||
|
||||
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:
|
||||
|
@ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
|
||||
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 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
|
||||
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 @@ def update_parent_account_names(accounts):
|
||||
|
||||
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 @@ def get_subsidiary_companies(company):
|
||||
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 = []
|
||||
|
@ -8,20 +8,22 @@ frappe.query_reports["Gross Profit"] = {
|
||||
"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",
|
||||
|
@ -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",
|
||||
|
@ -70,43 +70,42 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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 @@ class GrossProfitGenerator(object):
|
||||
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
|
||||
|
||||
|
@ -61,7 +61,7 @@ class TestTaxDetail(unittest.TestCase):
|
||||
# Create GL Entries:
|
||||
db_doc.submit()
|
||||
else:
|
||||
db_doc.insert()
|
||||
db_doc.insert(ignore_if_duplicate=True)
|
||||
except frappe.exceptions.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
|
@ -43,7 +43,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map):
|
||||
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 = {
|
||||
|
@ -39,10 +39,11 @@ class TestReports(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
|
@ -847,7 +847,7 @@ def create_payment_gateway_account(gateway, payment_channel="Email"):
|
||||
"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?
|
||||
|
@ -417,11 +417,12 @@ class Asset(AccountsController):
|
||||
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 @@ class Asset(AccountsController):
|
||||
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")
|
||||
|
@ -873,8 +873,9 @@ class TestDepreciationBasics(AssetSetup):
|
||||
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 @@ class TestDepreciationBasics(AssetSetup):
|
||||
|
||||
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 @@ def create_asset(**args):
|
||||
|
||||
if not args.do_not_save:
|
||||
try:
|
||||
asset.save()
|
||||
asset.insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
@ -1305,7 +1321,7 @@ def create_fixed_asset_item(item_code=None, auto_create_assets=1, is_grouped_ass
|
||||
"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
|
||||
|
@ -23,7 +23,7 @@ class TestAssetCategory(unittest.TestCase):
|
||||
})
|
||||
|
||||
try:
|
||||
asset_category.insert()
|
||||
asset_category.insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
|
||||
|
@ -14,151 +14,150 @@ test_records = frappe.get_test_records('Supplier')
|
||||
|
||||
|
||||
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
|
||||
|
@ -1566,13 +1566,12 @@ def validate_taxes_and_charges(tax):
|
||||
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 @@ def update_bin_on_delete(row, doctype):
|
||||
|
||||
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 = []
|
||||
|
@ -249,6 +249,7 @@ class BuyingController(StockController, Subcontracting):
|
||||
"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 @@ class BuyingController(StockController, Subcontracting):
|
||||
"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:
|
||||
|
@ -104,11 +104,11 @@ class EmployeeBoardingController(Document):
|
||||
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)
|
||||
|
||||
|
@ -420,6 +420,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None
|
||||
"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
|
||||
|
@ -394,6 +394,7 @@ class SellingController(StockController):
|
||||
"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,
|
||||
|
@ -175,7 +175,7 @@ class TestShoppingCart(unittest.TestCase):
|
||||
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
|
||||
|
||||
|
@ -82,7 +82,7 @@ class TallyMigration(Document):
|
||||
"is_private": True
|
||||
})
|
||||
try:
|
||||
f.insert()
|
||||
f.insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
setattr(self, key, f.file_url)
|
||||
|
@ -8,10 +8,6 @@ from frappe.utils import cint, flt
|
||||
|
||||
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 @@ def get_client():
|
||||
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 @@ def create_transaction(doc, method):
|
||||
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 create_transaction(doc, method):
|
||||
|
||||
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 delete_transaction(doc, method):
|
||||
|
||||
|
||||
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 @@ def get_tax_data(doc):
|
||||
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 @@ def get_line_item_dict(item, docstatus):
|
||||
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 @@ def set_sales_tax(doc, method):
|
||||
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_for_nexus(doc, tax_dict):
|
||||
|
||||
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")
|
||||
|
@ -142,7 +142,7 @@ class Employee(NestedSet):
|
||||
"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
|
||||
|
@ -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 @@ class TestEmployeeOnboarding(unittest.TestCase):
|
||||
# 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 @@ class TestEmployeeOnboarding(unittest.TestCase):
|
||||
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 get_job_offer(applicant_name):
|
||||
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()
|
||||
|
@ -128,4 +128,4 @@ def show_email_summary(email_success, email_failure):
|
||||
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)
|
||||
frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True)
|
||||
|
@ -82,7 +82,7 @@ def get_vehicle(employee_id):
|
||||
"vehicle_value": flt(500000)
|
||||
})
|
||||
try:
|
||||
vehicle.insert()
|
||||
vehicle.insert(ignore_if_duplicate=True)
|
||||
except frappe.DuplicateEntryError:
|
||||
pass
|
||||
return license_plate
|
||||
|
@ -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",
|
||||
@ -106,11 +110,6 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Disbursement Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "customer_details_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Customer Details"
|
||||
},
|
||||
{
|
||||
"fetch_from": "against_loan.applicant_type",
|
||||
"fieldname": "applicant_type",
|
||||
@ -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
|
||||
}
|
@ -42,9 +42,6 @@ class LoanDisbursement(AccountsController):
|
||||
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 @@ class LoanDisbursement(AccountsController):
|
||||
|
||||
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 @@ class LoanDisbursement(AccountsController):
|
||||
|
||||
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",
|
||||
|
@ -74,39 +74,6 @@ class LoanInterestAccrual(AccountsController):
|
||||
})
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
@ -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",
|
||||
|
@ -310,7 +310,6 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
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 @@ class LoanRepayment(AccountsController):
|
||||
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 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": loan_details.penalty_income_account,
|
||||
"against": loan_details.loan_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,8 +358,7 @@ class LoanRepayment(AccountsController):
|
||||
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",
|
||||
@ -368,16 +366,16 @@ class LoanRepayment(AccountsController):
|
||||
"remarks": remarks,
|
||||
"cost_center": self.cost_center,
|
||||
"posting_date": getdate(self.posting_date),
|
||||
"party_type": loan_details.applicant_type if self.repay_from_salary else '',
|
||||
"party": loan_details.applicant if self.repay_from_salary else ''
|
||||
"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,
|
||||
|
@ -62,7 +62,7 @@ class JobCard(Document):
|
||||
|
||||
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)
|
||||
|
@ -55,10 +55,11 @@ class TestManufacturingReports(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
|
@ -329,7 +329,6 @@ execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings'
|
||||
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
|
||||
@ -352,6 +351,9 @@ erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
|
||||
erpnext.patches.v13_0.shopping_cart_to_ecommerce
|
||||
erpnext.patches.v13_0.update_disbursement_account
|
||||
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.v14_0.delete_non_profit_doctypes
|
||||
erpnext.patches.v13_0.update_accounts_in_loan_docs
|
||||
erpnext.patches.v14_0.update_batch_valuation_flag
|
||||
erpnext.patches.v14_0.delete_non_profit_doctypes
|
@ -9,6 +9,8 @@ def execute():
|
||||
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"))
|
||||
})
|
||||
|
37
erpnext/patches/v13_0/update_accounts_in_loan_docs.py
Normal file
37
erpnext/patches/v13_0/update_accounts_in_loan_docs.py
Normal file
@ -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()
|
11
erpnext/patches/v14_0/update_batch_valuation_flag.py
Normal file
11
erpnext/patches/v14_0/update_batch_valuation_flag.py
Normal file
@ -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()
|
@ -6,9 +6,6 @@ from erpnext.setup.utils import get_exchange_rate
|
||||
|
||||
|
||||
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 @@ def execute():
|
||||
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)
|
||||
|
@ -29,9 +29,11 @@ def execute():
|
||||
""")
|
||||
|
||||
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(
|
||||
|
@ -14,6 +14,8 @@ def execute():
|
||||
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),
|
||||
|
@ -1268,7 +1268,7 @@ class SalarySlip(TransactionBase):
|
||||
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):
|
||||
|
@ -1019,13 +1019,13 @@ def setup_test():
|
||||
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"
|
||||
|
@ -21,7 +21,7 @@ class TestHomepageSection(unittest.TestCase):
|
||||
{'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
|
||||
|
||||
|
@ -151,6 +151,35 @@ class TestTimesheet(unittest.TestCase):
|
||||
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()
|
||||
|
@ -7,7 +7,7 @@ import json
|
||||
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 @@ class Timesheet(Document):
|
||||
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 @@ class Timesheet(Document):
|
||||
.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:
|
||||
|
@ -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",
|
||||
@ -83,43 +77,6 @@
|
||||
"fieldtype": "Check",
|
||||
"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": []
|
||||
}
|
@ -181,6 +181,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
fieldname: "journal_entry",
|
||||
onchange: () => this.update_options(),
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: "Loan Repayment",
|
||||
fieldname: "loan_repayment",
|
||||
onchange: () => this.update_options(),
|
||||
},
|
||||
{
|
||||
fieldname: "column_break_5",
|
||||
fieldtype: "Column Break",
|
||||
@ -191,13 +197,18 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
fieldname: "sales_invoice",
|
||||
onchange: () => this.update_options(),
|
||||
},
|
||||
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: "Purchase Invoice",
|
||||
fieldname: "purchase_invoice",
|
||||
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 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
},
|
||||
{
|
||||
fieldtype: "Check",
|
||||
label: "Show Only Exact Amount",
|
||||
fieldname: "exact_match",
|
||||
label: "Loan Disbursement",
|
||||
fieldname: "loan_disbursement",
|
||||
onchange: () => this.update_options(),
|
||||
},
|
||||
{
|
||||
|
@ -525,6 +525,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
|
||||
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 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
'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
|
||||
@ -2284,20 +2286,12 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
|
||||
coupon_code() {
|
||||
var me = this;
|
||||
if (this.frm.doc.coupon_code) {
|
||||
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()
|
||||
]);
|
||||
}
|
||||
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()
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -338,14 +338,14 @@ body.product-page {
|
||||
|
||||
.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 @@ body.product-page {
|
||||
|
||||
.not-wished {
|
||||
cursor: pointer;
|
||||
stroke: #F47A7A !important;
|
||||
--icon-stroke: #F47A7A !important;
|
||||
|
||||
&:hover {
|
||||
fill: #F47A7A;
|
||||
@ -1030,7 +1030,7 @@ body.product-page {
|
||||
}
|
||||
|
||||
.wished {
|
||||
stroke: none;
|
||||
--icon-stroke: none;
|
||||
fill: #F47A7A !important;
|
||||
}
|
||||
|
||||
|
@ -53,10 +53,7 @@ def create_hsn_codes(data, code_field):
|
||||
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',
|
||||
|
@ -17,7 +17,7 @@ frappe.query_reports["GSTR-1"] = {
|
||||
"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',
|
||||
@ -26,6 +26,11 @@ frappe.query_reports["GSTR-1"] = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldname": "company_gstin",
|
||||
"label": __("Company GSTIN"),
|
||||
"fieldtype": "Select"
|
||||
},
|
||||
{
|
||||
"fieldname": "from_date",
|
||||
"label": __("From Date"),
|
||||
@ -60,10 +65,21 @@ frappe.query_reports["GSTR-1"] = {
|
||||
}
|
||||
],
|
||||
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: {
|
||||
|
@ -253,7 +253,8 @@ class Gstr1Report(object):
|
||||
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]
|
||||
|
||||
@ -1192,3 +1193,23 @@ def is_inter_state(invoice_detail):
|
||||
return True
|
||||
else:
|
||||
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
|
@ -102,7 +102,7 @@ def make_custom_fields():
|
||||
]
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -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
|
||||
}
|
@ -169,6 +169,21 @@ erpnext.PointOfSale.Payment = class {
|
||||
}
|
||||
});
|
||||
|
||||
frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => {
|
||||
if (!frm.doc.ignore_pricing_rule) {
|
||||
if (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)
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.setup_listener_for_payments();
|
||||
|
||||
this.$payment_modes.on('click', '.shortcut', function() {
|
||||
|
@ -155,7 +155,7 @@ def insert_record(records):
|
||||
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:
|
||||
|
@ -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
|
||||
}
|
@ -110,11 +110,18 @@ class Batch(Document):
|
||||
|
||||
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:
|
||||
|
@ -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 @@ class TestBatch(ERPNextTestCase):
|
||||
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 create_price_list_for_batch(item_code, batch, rate):
|
||||
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
|
||||
|
@ -594,7 +594,7 @@ $.extend(erpnext.item, {
|
||||
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;
|
||||
|
@ -398,6 +398,7 @@ class Item(Document):
|
||||
|
||||
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 @@ class Item(Document):
|
||||
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 @@ class Item(Document):
|
||||
|
||||
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):
|
||||
|
@ -15,6 +15,7 @@ from erpnext.controllers.item_variant import (
|
||||
get_variant,
|
||||
)
|
||||
from erpnext.stock.doctype.item.item import (
|
||||
DataValidationError,
|
||||
InvalidBarcode,
|
||||
StockExistsForTemplate,
|
||||
get_item_attribute,
|
||||
@ -388,6 +389,26 @@ class TestItem(ERPNextTestCase):
|
||||
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')
|
||||
|
@ -56,14 +56,13 @@ class MaterialRequest(BuyingController):
|
||||
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 @@ class MaterialRequest(BuyingController):
|
||||
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:
|
||||
|
@ -1540,6 +1540,7 @@ def make_purchase_receipt(**args):
|
||||
"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'),
|
||||
|
@ -425,6 +425,7 @@ frappe.ui.form.on('Stock Entry', {
|
||||
'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 @@ frappe.ui.form.on('Stock Entry', {
|
||||
'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 @@ frappe.ui.form.on('Stock Entry Detail', {
|
||||
'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,
|
||||
|
@ -510,7 +510,7 @@ class StockEntry(StockController):
|
||||
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:
|
||||
@ -541,6 +541,7 @@ class StockEntry(StockController):
|
||||
"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,
|
||||
|
@ -44,6 +44,7 @@ def get_sle(**args):
|
||||
|
||||
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 @@ class TestStockEntry(ERPNextTestCase):
|
||||
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 @@ class TestStockEntry(ERPNextTestCase):
|
||||
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 @@ class TestStockEntry(ERPNextTestCase):
|
||||
|
||||
# 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 @@ class TestStockEntry(ERPNextTestCase):
|
||||
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])
|
||||
|
@ -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 @@ class TestStockLedgerEntry(ERPNextTestCase):
|
||||
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 @@ def create_items():
|
||||
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()
|
||||
|
@ -200,7 +200,6 @@ class TestStockReconciliation(ERPNextTestCase):
|
||||
|
||||
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 @@ class TestStockReconciliation(ERPNextTestCase):
|
||||
|
||||
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 @@ class TestStockReconciliation(ERPNextTestCase):
|
||||
|
||||
|
||||
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)
|
||||
|
@ -12,6 +12,7 @@ from frappe.utils import cint, date_diff, flt
|
||||
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 @@ def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> Li
|
||||
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 @@ def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: D
|
||||
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
|
||||
|
||||
@ -286,14 +290,16 @@ class FIFOSlots:
|
||||
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:
|
||||
@ -324,7 +330,7 @@ class FIFOSlots:
|
||||
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
|
||||
@ -333,6 +339,33 @@ class FIFOSlots:
|
||||
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
|
||||
|
||||
|
@ -71,4 +71,39 @@ Date | Qty | Queue
|
||||
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]]
|
||||
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]] |
|
||||
|
@ -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,7 +11,8 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
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):
|
||||
@ -236,6 +237,371 @@ class TestStockAgeing(ERPNextTestCase):
|
||||
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()
|
||||
|
@ -60,6 +60,9 @@ def add_invariant_check_fields(sles):
|
||||
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 @@ def add_invariant_check_fields(sles):
|
||||
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
|
||||
|
||||
|
||||
@ -134,6 +140,11 @@ def get_columns():
|
||||
"label": "Batch",
|
||||
"options": "Batch",
|
||||
},
|
||||
{
|
||||
"fieldname": "use_batchwise_valuation",
|
||||
"fieldtype": "Check",
|
||||
"label": "Batchwise Valuation",
|
||||
},
|
||||
{
|
||||
"fieldname": "actual_qty",
|
||||
"fieldtype": "Float",
|
||||
@ -145,9 +156,9 @@ def get_columns():
|
||||
"label": "Incoming Rate",
|
||||
},
|
||||
{
|
||||
"fieldname": "outgoing_rate",
|
||||
"fieldname": "consumption_rate",
|
||||
"fieldtype": "Float",
|
||||
"label": "Outgoing Rate",
|
||||
"label": "Consumption Rate",
|
||||
},
|
||||
{
|
||||
"fieldname": "qty_after_transaction",
|
||||
|
@ -73,10 +73,11 @@ class TestReports(unittest.TestCase):
|
||||
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,
|
||||
)
|
||||
|
103
erpnext/stock/spec/README.md
Normal file
103
erpnext/stock/spec/README.md
Normal file
@ -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.
|
38
erpnext/stock/spec/reposting.md
Normal file
38
erpnext/stock/spec/reposting.md
Normal file
@ -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
|
@ -8,7 +8,9 @@ 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
|
||||
@ -17,14 +19,13 @@ from erpnext.stock.utils import (
|
||||
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
|
||||
@ -447,6 +448,8 @@ class update_entries_after(object):
|
||||
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 @@ class update_entries_after(object):
|
||||
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 @@ class update_entries_after(object):
|
||||
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 @@ class update_entries_after(object):
|
||||
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 @@ class update_entries_after(object):
|
||||
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 @@ class update_entries_after(object):
|
||||
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 @@ def get_sle_by_voucher_detail_no(voucher_detail_no, excluded_sle=None):
|
||||
['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
|
||||
|
@ -7,7 +7,7 @@ from hypothesis import strategies as st
|
||||
|
||||
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 @@ class TestFIFOValuation(unittest.TestCase):
|
||||
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)
|
||||
|
@ -209,13 +209,28 @@ def _create_bin(item_code, warehouse):
|
||||
@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)
|
||||
@ -226,12 +241,11 @@ def get_incoming_rate(args, raise_error_if_no_rate=True):
|
||||
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)
|
||||
|
||||
@ -247,7 +261,7 @@ def get_valuation_method(item_code):
|
||||
"""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):
|
||||
|
@ -34,7 +34,7 @@ class BinWiseValuation(ABC):
|
||||
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 @@ class FIFOValuation(BinWiseValuation):
|
||||
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 @@ class FIFOValuation(BinWiseValuation):
|
||||
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 @@ class LIFOValuation(BinWiseValuation):
|
||||
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 @@ class LIFOValuation(BinWiseValuation):
|
||||
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.
|
||||
"""
|
||||
|
@ -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>
|
||||
|
30
erpnext/tests/test_zform_loads.py
Normal file
30
erpnext/tests/test_zform_loads.py
Normal file
@ -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}")
|
@ -1597,6 +1597,7 @@ Method,Methode,
|
||||
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),
|
||||
@ -3730,7 +3731,7 @@ Earliest Age,Frühestes Alter,
|
||||
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,
|
||||
@ -6486,7 +6487,7 @@ Select Users,Wählen Sie Benutzer aus,
|
||||
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.",
|
||||
|
Can't render this file because it is too large.
|
@ -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>
|
||||
|
@ -62,8 +62,7 @@ def get_category_records(categories):
|
||||
"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 @@ def get_category_records(categories):
|
||||
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
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user