Merge branch 'remove-india' of github.com:resilient-tech/erpnext into remove-india

This commit is contained in:
Smit Vora 2022-03-01 12:59:28 +05:30
commit 80c0d0db21
106 changed files with 1822 additions and 1446 deletions

View File

@ -17,6 +17,36 @@ pull_request_rules:
@{{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 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 - name: backport to version-13-hotfix
conditions: conditions:
- label="backport version-13-hotfix" - label="backport version-13-hotfix"

View File

@ -7,6 +7,7 @@ import json
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.custom import ConstantColumn
from frappe.utils import flt from frappe.utils import flt
from erpnext import get_company_currency from erpnext import get_company_currency
@ -275,6 +276,10 @@ def check_matching(bank_account, company, transaction, document_types):
} }
matching_vouchers = [] matching_vouchers = []
matching_vouchers.extend(get_loan_vouchers(bank_account, transaction,
document_types, filters))
for query in subquery: for query in subquery:
matching_vouchers.extend( matching_vouchers.extend(
frappe.db.sql(query, filters,) frappe.db.sql(query, filters,)
@ -311,6 +316,114 @@ def get_queries(bank_account, company, transaction, document_types):
return queries 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): def get_pe_matching_query(amount_condition, account_from_to, transaction):
# get matching payment entries query # get matching payment entries query
if transaction.deposit > 0: if transaction.deposit > 0:
@ -348,7 +461,6 @@ def get_je_matching_query(amount_condition, transaction):
# We have mapping at the bank level # We have mapping at the bank level
# So one bank could have both types of bank accounts like asset and liability # 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 # 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" cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
return f""" return f"""

View File

@ -49,7 +49,8 @@ class BankTransaction(StatusUpdater):
def clear_linked_payment_entries(self, for_cancel=False): def clear_linked_payment_entries(self, for_cancel=False):
for payment_entry in self.payment_entries: 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) self.clear_simple_entry(payment_entry, for_cancel=for_cancel)
elif payment_entry.payment_document == "Sales Invoice": 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) payment_entry.payment_entry, paid_amount_field)
elif payment_entry.payment_document == "Journal Entry": 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": elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed") 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: else:
frappe.throw("Please reconcile {0}: {1} manually".format(payment_entry.payment_document, payment_entry.payment_entry)) frappe.throw("Please reconcile {0}: {1} manually".format(payment_entry.payment_document, payment_entry.payment_entry))

View File

@ -109,7 +109,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"):
frappe.get_doc({ frappe.get_doc({
"doctype": "Bank", "doctype": "Bank",
"bank_name":bank_name, "bank_name":bank_name,
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -119,7 +119,7 @@ def create_bank_account(bank_name="Citi Bank", account_name="_Test Bank - _TC"):
"account_name":"Checking Account", "account_name":"Checking Account",
"bank": bank_name, "bank": bank_name,
"account": account_name "account": account_name
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -184,7 +184,7 @@ def add_vouchers():
"supplier_group":"All Supplier Groups", "supplier_group":"All Supplier Groups",
"supplier_type": "Company", "supplier_type": "Company",
"supplier_name": "Conrad Electronic" "supplier_name": "Conrad Electronic"
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -203,7 +203,7 @@ def add_vouchers():
"supplier_group":"All Supplier Groups", "supplier_group":"All Supplier Groups",
"supplier_type": "Company", "supplier_type": "Company",
"supplier_name": "Mr G" "supplier_name": "Mr G"
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -227,7 +227,7 @@ def add_vouchers():
"supplier_group":"All Supplier Groups", "supplier_group":"All Supplier Groups",
"supplier_type": "Company", "supplier_type": "Company",
"supplier_name": "Poore Simon's" "supplier_name": "Poore Simon's"
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -237,7 +237,7 @@ def add_vouchers():
"customer_group":"All Customer Groups", "customer_group":"All Customer Groups",
"customer_type": "Company", "customer_type": "Company",
"customer_name": "Poore Simon's" "customer_name": "Poore Simon's"
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -266,7 +266,7 @@ def add_vouchers():
"customer_group":"All Customer Groups", "customer_group":"All Customer Groups",
"customer_type": "Company", "customer_type": "Company",
"customer_name": "Fayva" "customer_name": "Fayva"
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass

View File

@ -1,94 +1,34 @@
{ {
"allow_copy": 0, "actions": [],
"allow_guest_to_view": 0,
"allow_import": 0,
"allow_rename": 0,
"autoname": "field:mapping",
"beta": 0,
"creation": "2018-02-08 10:18:48.513608", "creation": "2018-02-08 10:18:48.513608",
"custom": 0,
"docstatus": 0,
"doctype": "DocType", "doctype": "DocType",
"document_type": "",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [
"mapping"
],
"fields": [ "fields": [
{ {
"allow_bulk_edit": 0,
"allow_on_submit": 0,
"bold": 0,
"collapsible": 0,
"columns": 0,
"fieldname": "mapping", "fieldname": "mapping",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 0,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1, "in_list_view": 1,
"in_standard_filter": 0,
"label": "Mapping", "label": "Mapping",
"length": 0,
"no_copy": 0,
"options": "Cash Flow Mapping", "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, "reqd": 1,
"search_index": 0, "unique": 1
"set_only_once": 0,
"unique": 0
} }
], ],
"has_web_view": 0, "istable": 1,
"hide_heading": 0, "links": [],
"hide_toolbar": 0, "modified": "2022-02-21 03:34:57.902332",
"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", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Cash Flow Mapping Template Details", "name": "Cash Flow Mapping Template Details",
"name_case": "",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "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, "quick_entry": 1,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1, "states": [],
"track_seen": 0 "track_changes": 1
} }

View File

@ -166,7 +166,7 @@ class OpeningInvoiceCreationTool(Document):
frappe.scrub(row.party_type): row.party, frappe.scrub(row.party_type): row.party,
"is_pos": 0, "is_pos": 0,
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice", "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, "invoice_number": row.invoice_number,
"disable_rounded_total": 1 "disable_rounded_total": 1
}) })

View File

@ -1,11 +1,7 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe 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 ( from erpnext.accounts.doctype.accounting_dimension.test_accounting_dimension import (
create_dimension, 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 ( from erpnext.accounts.doctype.opening_invoice_creation_tool.opening_invoice_creation_tool import (
get_temporary_opening_account, get_temporary_opening_account,
) )
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Customer", "Supplier", "Accounting Dimension"] test_dependencies = ["Customer", "Supplier", "Accounting Dimension"]
class TestOpeningInvoiceCreationTool(unittest.TestCase): class TestOpeningInvoiceCreationTool(ERPNextTestCase):
def setUp(self): @classmethod
def setUpClass(self):
if not frappe.db.exists("Company", "_Test Opening Invoice Company"): if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
make_company() make_company()
create_dimension() 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): 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") doc = frappe.get_single("Opening Invoice Creation Tool")
@ -31,8 +30,6 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
return doc.make_invoices() return doc.make_invoices()
def test_opening_sales_invoice_creation(self): 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) self.assertEqual(len(invoices), 2)
@ -48,10 +45,6 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
# Check if update stock is not enabled # Check if update stock is not enabled
self.assertEqual(si.update_stock, 0) self.assertEqual(si.update_stock, 0)
finally:
property_setter.delete()
clear_doctype_cache("Sales Invoice")
def check_expected_values(self, invoices, expected_value, invoice_type="Sales"): def check_expected_values(self, invoices, expected_value, invoice_type="Sales"):
doctype = "Sales Invoice" if invoice_type == "Sales" else "Purchase Invoice" doctype = "Sales Invoice" if invoice_type == "Sales" else "Purchase Invoice"

View File

@ -1077,7 +1077,7 @@ def get_outstanding_reference_documents(args):
if d.voucher_type in ("Purchase Invoice"): if d.voucher_type in ("Purchase Invoice"):
d["bill_no"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "bill_no") 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 = [] orders_to_be_billed = []
if (args.get("party_type") != "Student"): if (args.get("party_type") != "Student"):
orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"), orders_to_be_billed = get_orders_to_be_billed(args.get("posting_date"),args.get("party_type"),

View File

@ -46,7 +46,7 @@ def valdiate_taxes_and_charges_template(doc):
for tax in doc.get("taxes"): for tax in doc.get("taxes"):
validate_taxes_and_charges(tax) 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_cost_center(tax, doc)
validate_inclusive_tax(tax, doc) validate_inclusive_tax(tax, doc)

View File

@ -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) .format(frappe.bold(party_type), frappe.bold(party), frappe.bold(existing_gle_currency), frappe.bold(company)), InvalidAccountCurrency)
def validate_party_accounts(doc): def validate_party_accounts(doc):
from erpnext.controllers.accounts_controller import validate_account_head
companies = [] companies = []
for account in doc.get("accounts"): 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: 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")) 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() @frappe.whitelist()
def get_due_date(posting_date, party_type, party, company=None, bill_date=None): def get_due_date(posting_date, party_type, party, company=None, bill_date=None):

View File

@ -4,7 +4,12 @@
import frappe import frappe
from frappe import _ 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): def execute(filters=None):
@ -18,7 +23,6 @@ def execute(filters=None):
data = get_entries(filters) data = get_entries(filters)
from erpnext.accounts.utils import get_balance_on
balance_as_per_system = get_balance_on(filters["account"], filters["report_date"]) balance_as_per_system = get_balance_on(filters["account"], filters["report_date"])
total_debit, total_credit = 0,0 total_debit, total_credit = 0,0
@ -118,7 +122,21 @@ def get_columns():
] ]
def get_entries(filters): 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, select "Journal Entry" as payment_document, jv.posting_date,
jv.name as payment_entry, jvd.debit_in_account_currency as debit, jv.name as payment_entry, jvd.debit_in_account_currency as debit,
jvd.credit_in_account_currency as credit, jvd.against_account, 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.clearance_date, '4000-01-01') > %(report_date)s
and ifnull(jv.is_opening, 'No') = 'No'""", filters, as_dict=1) 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 select
"Payment Entry" as payment_document, name as payment_entry, "Payment Entry" as payment_document, name as payment_entry,
reference_no, reference_date as ref_date, 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 and ifnull(clearance_date, '4000-01-01') > %(report_date)s
""", filters, as_dict=1) """, filters, as_dict=1)
pos_entries = [] def get_pos_entries(filters):
if filters.include_pos_transactions: return frappe.db.sql("""
pos_entries = frappe.db.sql("""
select select
"Sales Invoice Payment" as payment_document, sip.name as payment_entry, sip.amount as debit, "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, 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 si.posting_date ASC, si.name DESC
""", filters, as_dict=1) """, filters, as_dict=1)
return sorted(list(payment_entries)+list(journal_entries+list(pos_entries)), def get_loan_entries(filters):
key=lambda k: k['posting_date'] or getdate(nowdate())) 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): def get_amounts_not_reflected_in_system(filters):
je_amount = frappe.db.sql(""" 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 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): def get_balance_row(label, amount, account_currency):
if amount > 0: if amount > 0:

View File

@ -354,9 +354,6 @@ def accumulate_values_into_parents(accounts, accounts_by_name, companies):
if d.parent_account: if d.parent_account:
account = d.parent_account_name account = d.parent_account_name
# if not accounts_by_name.get(account):
# continue
for company in companies: for company in companies:
accounts_by_name[account][company] = \ accounts_by_name[account][company] = \
accounts_by_name[account].get(company, 0.0) + d.get(company, 0.0) 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) accounts_by_name[account].get("opening_balance", 0.0) + d.get("opening_balance", 0.0)
def get_account_heads(root_type, companies, filters): def get_account_heads(root_type, companies, filters):
accounts = get_accounts(root_type, filters) accounts = get_accounts(root_type, companies)
if not accounts: if not accounts:
return None, None, None return None, None, None
@ -396,7 +393,7 @@ def update_parent_account_names(accounts):
for account in accounts: for account in accounts:
if account.parent_account: 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 return accounts
@ -419,12 +416,19 @@ def get_subsidiary_companies(company):
return frappe.db.sql_list("""select name from `tabCompany` return frappe.db.sql_list("""select name from `tabCompany`
where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt)) where lft >= {0} and rgt <= {1} order by lft, rgt""".format(lft, rgt))
def get_accounts(root_type, filters): def get_accounts(root_type, companies):
return frappe.db.sql(""" select name, is_group, company, accounts = []
parent_account, lft, rgt, root_type, report_type, account_name, account_number added_accounts = []
from
`tabAccount` where company = %s and root_type = %s for company in companies:
""" , (filters.get('company'), root_type), as_dict=1) 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): def prepare_data(accounts, start_date, end_date, balance_must_be, companies, company_currency, filters):
data = [] data = []

View File

@ -8,20 +8,22 @@ frappe.query_reports["Gross Profit"] = {
"label": __("Company"), "label": __("Company"),
"fieldtype": "Link", "fieldtype": "Link",
"options": "Company", "options": "Company",
"reqd": 1, "default": frappe.defaults.get_user_default("Company"),
"default": frappe.defaults.get_user_default("Company") "reqd": 1
}, },
{ {
"fieldname":"from_date", "fieldname":"from_date",
"label": __("From Date"), "label": __("From Date"),
"fieldtype": "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", "fieldname":"to_date",
"label": __("To Date"), "label": __("To Date"),
"fieldtype": "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", "fieldname":"sales_invoice",

View File

@ -1,5 +1,5 @@
{ {
"add_total_row": 0, "add_total_row": 1,
"columns": [], "columns": [],
"creation": "2013-02-25 17:03:34", "creation": "2013-02-25 17:03:34",
"disable_prepared_report": 0, "disable_prepared_report": 0,
@ -9,7 +9,7 @@
"filters": [], "filters": [],
"idx": 3, "idx": 3,
"is_standard": "Yes", "is_standard": "Yes",
"modified": "2021-11-13 19:14:23.730198", "modified": "2022-02-11 10:18:36.956558",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Gross Profit", "name": "Gross Profit",

View File

@ -70,43 +70,42 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
data.append(row) data.append(row)
def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_columns, data): 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 = [] row = []
for col in group_wise_columns.get(scrub(filters.group_by)): for col in group_wise_columns.get(scrub(filters.group_by)):
row.append(src.get(col)) row.append(src.get(col))
row.append(filters.currency) row.append(filters.currency)
if idx == len(gross_profit_data.grouped_data)-1:
row[0] = "Total"
data.append(row) data.append(row)
def get_columns(group_wise_columns, filters): def get_columns(group_wise_columns, filters):
columns = [] columns = []
column_map = frappe._dict({ column_map = frappe._dict({
"parent": _("Sales Invoice") + ":Link/Sales Invoice:120", "parent": {"label": _('Sales Invoice'), "fieldname": "parent_invoice", "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
"invoice_or_item": _("Sales Invoice") + ":Link/Sales Invoice:120", "invoice_or_item": {"label": _('Sales Invoice'), "fieldtype": "Link", "options": "Sales Invoice", "width": 120},
"posting_date": _("Posting Date") + ":Date:100", "posting_date": {"label": _('Posting Date'), "fieldname": "posting_date", "fieldtype": "Date", "width": 100},
"posting_time": _("Posting Time") + ":Data:100", "posting_time": {"label": _('Posting Time'), "fieldname": "posting_time", "fieldtype": "Data", "width": 100},
"item_code": _("Item Code") + ":Link/Item:100", "item_code": {"label": _('Item Code'), "fieldname": "item_code", "fieldtype": "Link", "options": "Item", "width": 100},
"item_name": _("Item Name") + ":Data:100", "item_name": {"label": _('Item Name'), "fieldname": "item_name", "fieldtype": "Data", "width": 100},
"item_group": _("Item Group") + ":Link/Item Group:100", "item_group": {"label": _('Item Group'), "fieldname": "item_group", "fieldtype": "Link", "options": "Item Group", "width": 100},
"brand": _("Brand") + ":Link/Brand:100", "brand": {"label": _('Brand'), "fieldtype": "Link", "options": "Brand", "width": 100},
"description": _("Description") +":Data:100", "description": {"label": _('Description'), "fieldname": "description", "fieldtype": "Data", "width": 100},
"warehouse": _("Warehouse") + ":Link/Warehouse:100", "warehouse": {"label": _('Warehouse'), "fieldname": "warehouse", "fieldtype": "Link", "options": "warehouse", "width": 100},
"qty": _("Qty") + ":Float:80", "qty": {"label": _('Qty'), "fieldname": "qty", "fieldtype": "Float", "width": 80},
"base_rate": _("Avg. Selling Rate") + ":Currency/currency:100", "base_rate": {"label": _('Avg. Selling Rate'), "fieldname": "avg._selling_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
"buying_rate": _("Valuation Rate") + ":Currency/currency:100", "buying_rate": {"label": _('Valuation Rate'), "fieldname": "valuation_rate", "fieldtype": "Currency", "options": "currency", "width": 100},
"base_amount": _("Selling Amount") + ":Currency/currency:100", "base_amount": {"label": _('Selling Amount'), "fieldname": "selling_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"buying_amount": _("Buying Amount") + ":Currency/currency:100", "buying_amount": {"label": _('Buying Amount'), "fieldname": "buying_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"gross_profit": _("Gross Profit") + ":Currency/currency:100", "gross_profit": {"label": _('Gross Profit'), "fieldname": "gross_profit", "fieldtype": "Currency", "options": "currency", "width": 100},
"gross_profit_percent": _("Gross Profit %") + ":Percent:100", "gross_profit_percent": {"label": _('Gross Profit Percent'), "fieldname": "gross_profit_%",
"project": _("Project") + ":Link/Project:100", "fieldtype": "Percent", "width": 100},
"sales_person": _("Sales person"), "project": {"label": _('Project'), "fieldname": "project", "fieldtype": "Link", "options": "Project", "width": 100},
"allocated_amount": _("Allocated Amount") + ":Currency/currency:100", "sales_person": {"label": _('Sales Person'), "fieldname": "sales_person", "fieldtype": "Data","width": 100},
"customer": _("Customer") + ":Link/Customer:100", "allocated_amount": {"label": _('Allocated Amount'), "fieldname": "allocated_amount", "fieldtype": "Currency", "options": "currency", "width": 100},
"customer_group": _("Customer Group") + ":Link/Customer Group:100", "customer": {"label": _('Customer'), "fieldname": "customer", "fieldtype": "Link", "options": "Customer", "width": 100},
"territory": _("Territory") + ":Link/Territory: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)): for col in group_wise_columns.get(scrub(filters.group_by)):
@ -173,7 +172,7 @@ class GrossProfitGenerator(object):
buying_amount = 0 buying_amount = 0
for row in reversed(self.si_list): for row in reversed(self.si_list):
if self.skip_row(row, self.product_bundles): if self.skip_row(row):
continue continue
row.base_amount = flt(row.base_net_amount, self.currency_precision) 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() self.get_average_rate_based_on_group_by()
def get_average_rate_based_on_group_by(self): 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): for key in list(self.grouped):
if self.filters.get("group_by") != "Invoice": if self.filters.get("group_by") != "Invoice":
for i, row in enumerate(self.grouped[key]): 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.base_amount += flt(row.base_amount, self.currency_precision)
new_row = self.set_average_rate(new_row) new_row = self.set_average_rate(new_row)
self.grouped_data.append(new_row) self.grouped_data.append(new_row)
self.add_to_totals(new_row)
else: else:
for i, row in enumerate(self.grouped[key]): for i, row in enumerate(self.grouped[key]):
if row.indent == 1.0: if row.indent == 1.0:
@ -258,17 +246,6 @@ class GrossProfitGenerator(object):
if (flt(row.qty) or row.base_amount): if (flt(row.qty) or row.base_amount):
row = self.set_average_rate(row) row = self.set_average_rate(row)
self.grouped_data.append(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): 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" 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) \ 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 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): def get_returned_invoice_items(self):
returned_invoices = frappe.db.sql(""" returned_invoices = frappe.db.sql("""
select select
@ -306,12 +278,12 @@ class GrossProfitGenerator(object):
self.returned_invoices.setdefault(inv.return_against, frappe._dict())\ self.returned_invoices.setdefault(inv.return_against, frappe._dict())\
.setdefault(inv.item_code, []).append(inv) .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 self.filters.get("group_by") != "Invoice":
if not row.get(scrub(self.filters.get("group_by", ""))): if not row.get(scrub(self.filters.get("group_by", ""))):
return True return True
elif row.get("is_return") == 1:
return True return False
def get_buying_amount_from_product_bundle(self, row, product_bundle): def get_buying_amount_from_product_bundle(self, row, product_bundle):
buying_amount = 0.0 buying_amount = 0.0
@ -369,20 +341,37 @@ class GrossProfitGenerator(object):
return self.average_buying_rate[item_code] return self.average_buying_rate[item_code]
def get_last_purchase_rate(self, item_code, row): def get_last_purchase_rate(self, item_code, row):
condition = '' purchase_invoice = frappe.qb.DocType("Purchase Invoice")
if row.project: purchase_invoice_item = frappe.qb.DocType("Purchase Invoice Item")
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)
last_purchase_rate = frappe.db.sql(""" query = (frappe.qb.from_(purchase_invoice_item)
select (a.base_rate / a.conversion_factor) .inner_join(
from `tabPurchase Invoice Item` a purchase_invoice
where a.item_code = %s and a.docstatus=1 ).on(
{0} purchase_invoice.name == purchase_invoice_item.parent
order by a.modified desc limit 1""".format(condition), item_code) ).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 return flt(last_purchase_rate[0][0]) if last_purchase_rate else 0

View File

@ -61,7 +61,7 @@ class TestTaxDetail(unittest.TestCase):
# Create GL Entries: # Create GL Entries:
db_doc.submit() db_doc.submit()
else: else:
db_doc.insert() db_doc.insert(ignore_if_duplicate=True)
except frappe.exceptions.DuplicateEntryError: except frappe.exceptions.DuplicateEntryError:
pass pass

View File

@ -43,7 +43,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map):
if entry.account in tds_accounts: if entry.account in tds_accounts:
tds_deducted += (entry.credit - entry.debit) tds_deducted += (entry.credit - entry.debit)
total_amount_credited += (entry.credit - entry.debit) total_amount_credited += entry.credit
if tds_deducted: if tds_deducted:
row = { row = {

View File

@ -39,6 +39,7 @@ class TestReports(unittest.TestCase):
def test_execute_all_accounts_reports(self): def test_execute_all_accounts_reports(self):
"""Test that all script report in stock modules are executable with supported filters""" """Test that all script report in stock modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES: for report, filter in REPORT_FILTER_TEST_CASES:
with self.subTest(report=report):
execute_script_report( execute_script_report(
report_name=report, report_name=report,
module="Accounts", module="Accounts",

View File

@ -847,7 +847,7 @@ def create_payment_gateway_account(gateway, payment_channel="Email"):
"payment_account": bank_account.name, "payment_account": bank_account.name,
"currency": bank_account.account_currency, "currency": bank_account.account_currency,
"payment_channel": payment_channel "payment_channel": payment_channel
}).insert(ignore_permissions=True) }).insert(ignore_permissions=True, ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
# already exists, due to a reinstall? # already exists, due to a reinstall?

View File

@ -417,11 +417,12 @@ class Asset(AccountsController):
def validate_asset_finance_books(self, row): def validate_asset_finance_books(self, row):
if flt(row.expected_value_after_useful_life) >= flt(self.gross_purchase_amount): 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") 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 row.depreciation_start_date:
if not self.available_for_use_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) row.depreciation_start_date = get_last_day(self.available_for_use_date)
if not self.is_existing_asset: if not self.is_existing_asset:
@ -439,8 +440,9 @@ class Asset(AccountsController):
else: else:
self.number_of_depreciations_booked = 0 self.number_of_depreciations_booked = 0
if cint(self.number_of_depreciations_booked) > cint(row.total_number_of_depreciations): if flt(row.total_number_of_depreciations) <= cint(self.number_of_depreciations_booked):
frappe.throw(_("Number of Depreciations Booked cannot be greater than Total Number of Depreciations")) 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): 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") frappe.throw(_("Depreciation Row {0}: Next Depreciation Date cannot be before Purchase Date")

View File

@ -834,8 +834,9 @@ class TestDepreciationBasics(AssetSetup):
self.assertRaises(frappe.ValidationError, asset.save) self.assertRaises(frappe.ValidationError, asset.save)
def test_number_of_depreciations(self): 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( asset = create_asset(
item_code = "Macbook Pro", item_code = "Macbook Pro",
calculate_depreciation = 1, calculate_depreciation = 1,
@ -850,6 +851,21 @@ class TestDepreciationBasics(AssetSetup):
self.assertRaises(frappe.ValidationError, asset.save) 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): def test_depreciation_start_date_is_before_purchase_date(self):
asset = create_asset( asset = create_asset(
item_code = "Macbook Pro", item_code = "Macbook Pro",
@ -1225,7 +1241,7 @@ def create_asset(**args):
if not args.do_not_save: if not args.do_not_save:
try: try:
asset.save() asset.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
@ -1266,7 +1282,7 @@ def create_fixed_asset_item(item_code=None, auto_create_assets=1, is_grouped_ass
"is_grouped_asset": is_grouped_asset, "is_grouped_asset": is_grouped_asset,
"asset_naming_series": naming_series "asset_naming_series": naming_series
}) })
item.insert() item.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
return item return item

View File

@ -23,7 +23,7 @@ class TestAssetCategory(unittest.TestCase):
}) })
try: try:
asset_category.insert() asset_category.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass

View File

@ -149,7 +149,9 @@ class TestSupplier(unittest.TestCase):
def create_supplier(**args): def create_supplier(**args):
args = frappe._dict(args) args = frappe._dict(args)
try: if frappe.db.exists("Supplier", args.supplier_name):
return frappe.get_doc("Supplier", args.supplier_name)
doc = frappe.get_doc({ doc = frappe.get_doc({
"doctype": "Supplier", "doctype": "Supplier",
"supplier_name": args.supplier_name, "supplier_name": args.supplier_name,
@ -159,6 +161,3 @@ def create_supplier(**args):
}).insert() }).insert()
return doc return doc
except frappe.DuplicateEntryError:
return frappe.get_doc("Supplier", args.supplier_name)

View File

@ -1566,13 +1566,12 @@ def validate_taxes_and_charges(tax):
tax.rate = None tax.rate = None
def validate_account_head(tax, doc): def validate_account_head(idx, account, company):
company = frappe.get_cached_value('Account', account_company = frappe.get_cached_value('Account', account, 'company')
tax.account_head, 'company')
if company != doc.company: if account_company != company:
frappe.throw(_('Row {0}: Account {1} does not belong to Company {2}') 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): def validate_cost_center(tax, doc):

View File

@ -249,6 +249,7 @@ class BuyingController(StockController, Subcontracting):
"posting_time": self.get('posting_time'), "posting_time": self.get('posting_time'),
"qty": -1 * flt(d.get('stock_qty')), "qty": -1 * flt(d.get('stock_qty')),
"serial_no": d.get('serial_no'), "serial_no": d.get('serial_no'),
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
@ -278,7 +279,8 @@ class BuyingController(StockController, Subcontracting):
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": -1 * d.consumed_qty, "qty": -1 * d.consumed_qty,
"serial_no": d.serial_no "serial_no": d.serial_no,
"batch_no": d.batch_no,
}) })
if rate > 0: if rate > 0:

View File

@ -104,11 +104,11 @@ class EmployeeBoardingController(Document):
def get_task_dates(self, activity, holiday_list): def get_task_dates(self, activity, holiday_list):
start_date = end_date = None 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 = add_days(self.boarding_begins_on, activity.begin_on)
start_date = self.update_if_holiday(start_date, holiday_list) 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 = add_days(self.boarding_begins_on, activity.begin_on + activity.duration)
end_date = self.update_if_holiday(end_date, holiday_list) end_date = self.update_if_holiday(end_date, holiday_list)

View File

@ -420,6 +420,7 @@ def get_rate_for_return(voucher_type, voucher_no, item_code, return_against=None
"posting_time": sle.get('posting_time'), "posting_time": sle.get('posting_time'),
"qty": sle.actual_qty, "qty": sle.actual_qty,
"serial_no": sle.get('serial_no'), "serial_no": sle.get('serial_no'),
"batch_no": sle.get("batch_no"),
"company": sle.company, "company": sle.company,
"voucher_type": sle.voucher_type, "voucher_type": sle.voucher_type,
"voucher_no": sle.voucher_no "voucher_no": sle.voucher_no

View File

@ -394,6 +394,7 @@ class SellingController(StockController):
"posting_time": self.get('posting_time') or nowtime(), "posting_time": self.get('posting_time') or nowtime(),
"qty": qty if cint(self.get("is_return")) else (-1 * qty), "qty": qty if cint(self.get("is_return")) else (-1 * qty),
"serial_no": d.get('serial_no'), "serial_no": d.get('serial_no'),
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,

View File

@ -175,7 +175,7 @@ class TestShoppingCart(unittest.TestCase):
def create_tax_rule(self): def create_tax_rule(self):
tax_rule = frappe.get_test_records("Tax Rule")[0] tax_rule = frappe.get_test_records("Tax Rule")[0]
try: try:
frappe.get_doc(tax_rule).insert() frappe.get_doc(tax_rule).insert(ignore_if_duplicate=True)
except (frappe.DuplicateEntryError, ConflictingTaxRule): except (frappe.DuplicateEntryError, ConflictingTaxRule):
pass pass

View File

@ -82,7 +82,7 @@ class TallyMigration(Document):
"is_private": True "is_private": True
}) })
try: try:
f.insert() f.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
setattr(self, key, f.file_url) setattr(self, key, f.file_url)

View File

@ -8,10 +8,6 @@ from frappe.utils import cint, flt
from erpnext import get_default_company, get_region 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", 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", "FR", "GB", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
"SE", "SI", "SK", "US"] "SE", "SI", "SK", "US"]
@ -35,12 +31,14 @@ def get_client():
if api_key and api_url: if api_key and api_url:
client = taxjar.Client(api_key=api_key, api_url=api_url) client = taxjar.Client(api_key=api_key, api_url=api_url)
client.set_api_config('headers', { client.set_api_config('headers', {
'x-api-version': '2020-08-07' 'x-api-version': '2022-01-24'
}) })
return client return client
def create_transaction(doc, method): def create_transaction(doc, method):
TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
"""Create an order transaction in TaxJar""" """Create an order transaction in TaxJar"""
if not TAXJAR_CREATE_TRANSACTIONS: if not TAXJAR_CREATE_TRANSACTIONS:
@ -51,6 +49,7 @@ def create_transaction(doc, method):
if not client: if not client:
return 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]) sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD])
if not sales_tax: if not sales_tax:
@ -79,6 +78,7 @@ def create_transaction(doc, method):
def delete_transaction(doc, method): def delete_transaction(doc, method):
"""Delete an existing TaxJar order transaction""" """Delete an existing TaxJar order transaction"""
TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
if not TAXJAR_CREATE_TRANSACTIONS: if not TAXJAR_CREATE_TRANSACTIONS:
return return
@ -92,6 +92,8 @@ def delete_transaction(doc, method):
def get_tax_data(doc): 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_address = get_company_address_details(doc)
from_shipping_state = from_address.get("state") from_shipping_state = from_address.get("state")
from_country_code = frappe.db.get_value("Country", from_address.country, "code") 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') to_shipping_state = get_state_code(to_address, 'Shipping')
tax_dict = { tax_dict = {
'from_country': from_country_code, "from_country": from_country_code,
'from_zip': from_address.pincode, "from_zip": from_address.pincode,
'from_state': from_shipping_state, "from_state": from_shipping_state,
'from_city': from_address.city, "from_city": from_address.city,
'from_street': from_address.address_line1, "from_street": from_address.address_line1,
'to_country': to_country_code, "to_country": to_country_code,
'to_zip': to_address.pincode, "to_zip": to_address.pincode,
'to_city': to_address.city, "to_city": to_address.city,
'to_street': to_address.address_line1, "to_street": to_address.address_line1,
'to_state': to_shipping_state, "to_state": to_shipping_state,
'shipping': shipping, "shipping": shipping,
'amount': doc.net_total, "amount": doc.net_total,
'plugin': 'erpnext', "plugin": "erpnext",
'line_items': line_items "line_items": line_items
} }
return tax_dict return tax_dict
@ -156,6 +158,9 @@ def get_line_item_dict(item, docstatus):
return tax_dict return tax_dict
def set_sales_tax(doc, method): 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: if not TAXJAR_CALCULATE_TAX:
return return
@ -206,6 +211,7 @@ def set_sales_tax(doc, method):
doc.run_method("calculate_taxes_and_totals") doc.run_method("calculate_taxes_and_totals")
def check_for_nexus(doc, tax_dict): 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"]}): if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}):
for item in doc.get("items"): for item in doc.get("items"):
item.tax_collectable = flt(0) item.tax_collectable = flt(0)
@ -218,6 +224,8 @@ def check_for_nexus(doc, tax_dict):
def check_sales_tax_exemption(doc): def check_sales_tax_exemption(doc):
# if the party is exempt from sales tax, then set all tax account heads to zero # 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 \ 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") \ or frappe.db.has_column("Customer", "exempt_from_sales_tax") \
and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax") and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax")

View File

@ -142,7 +142,7 @@ class Employee(NestedSet):
"file_url": self.image, "file_url": self.image,
"attached_to_doctype": "User", "attached_to_doctype": "User",
"attached_to_name": self.user_id "attached_to_name": self.user_id
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
# already exists # already exists
pass pass

View File

@ -4,7 +4,7 @@
import unittest import unittest
import frappe import frappe
from frappe.utils import getdate from frappe.utils import add_days, getdate
from erpnext.hr.doctype.employee_onboarding.employee_onboarding import ( from erpnext.hr.doctype.employee_onboarding.employee_onboarding import (
IncompleteTaskError, IncompleteTaskError,
@ -35,6 +35,15 @@ class TestEmployeeOnboarding(unittest.TestCase):
# boarding status # boarding status
self.assertEqual(onboarding.boarding_status, 'Pending') 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 # complete the task
project = frappe.get_doc('Project', onboarding.project) project = frappe.get_doc('Project', onboarding.project)
for task in frappe.get_all('Task', dict(project=project.name)): 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') self.assertEqual(employee.employee_name, 'Test Researcher')
def tearDown(self): def tearDown(self):
for entry in frappe.get_all('Employee Onboarding'): frappe.db.rollback()
doc = frappe.get_doc('Employee Onboarding', entry.name)
doc.cancel()
doc.delete()
def get_job_applicant(): def get_job_applicant():
@ -87,23 +93,31 @@ def get_job_offer(applicant_name):
def create_employee_onboarding(): def create_employee_onboarding():
applicant = get_job_applicant() applicant = get_job_applicant()
job_offer = get_job_offer(applicant.name) 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 = frappe.new_doc('Employee Onboarding')
onboarding.job_applicant = applicant.name onboarding.job_applicant = applicant.name
onboarding.job_offer = job_offer.name onboarding.job_offer = job_offer.name
onboarding.date_of_joining = onboarding.boarding_begins_on = getdate() onboarding.date_of_joining = onboarding.boarding_begins_on = getdate()
onboarding.company = '_Test Company' onboarding.company = '_Test Company'
onboarding.holiday_list = holiday_list onboarding.holiday_list = holiday_list.name
onboarding.designation = 'Researcher' onboarding.designation = 'Researcher'
onboarding.append('activities', { onboarding.append('activities', {
'activity_name': 'Assign ID Card', 'activity_name': 'Assign ID Card',
'role': 'HR User', 'role': 'HR User',
'required_for_employee_creation': 1 'required_for_employee_creation': 1,
'begin_on': 0,
'duration': 1
}) })
onboarding.append('activities', { onboarding.append('activities', {
'activity_name': 'Assign a laptop', 'activity_name': 'Assign a laptop',
'role': 'HR User' 'role': 'HR User',
'begin_on': 1,
'duration': 1
}) })
onboarding.status = 'Pending' onboarding.status = 'Pending'
onboarding.insert() onboarding.insert()

View File

@ -82,7 +82,7 @@ def get_vehicle(employee_id):
"vehicle_value": flt(500000) "vehicle_value": flt(500000)
}) })
try: try:
vehicle.insert() vehicle.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass
return license_plate return license_plate

View File

@ -14,11 +14,15 @@
"applicant", "applicant",
"section_break_7", "section_break_7",
"disbursement_date", "disbursement_date",
"clearance_date",
"column_break_8", "column_break_8",
"disbursed_amount", "disbursed_amount",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
"customer_details_section", "accounting_details",
"disbursement_account",
"column_break_16",
"loan_account",
"bank_account", "bank_account",
"disbursement_references_section", "disbursement_references_section",
"reference_date", "reference_date",
@ -106,11 +110,6 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "Disbursement Details" "label": "Disbursement Details"
}, },
{
"fieldname": "customer_details_section",
"fieldtype": "Section Break",
"label": "Customer Details"
},
{ {
"fetch_from": "against_loan.applicant_type", "fetch_from": "against_loan.applicant_type",
"fieldname": "applicant_type", "fieldname": "applicant_type",
@ -149,15 +148,48 @@
"fieldname": "reference_number", "fieldname": "reference_number",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Reference Number" "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, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-04-19 18:09:32.175355", "modified": "2022-02-17 18:23:44.157598",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Loan Management", "module": "Loan Management",
"name": "Loan Disbursement", "name": "Loan Disbursement",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -194,5 +226,6 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -42,9 +42,6 @@ class LoanDisbursement(AccountsController):
if not self.posting_date: if not self.posting_date:
self.posting_date = self.disbursement_date or nowdate() 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): def validate_disbursal_amount(self):
possible_disbursal_amount = get_disbursal_amount(self.against_loan) 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): def make_gl_entries(self, cancel=0, adv_adj=0):
gle_map = [] gle_map = []
loan_details = frappe.get_doc("Loan", self.against_loan)
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.loan_account, "account": self.loan_account,
"against": loan_details.disbursement_account, "against": self.disbursement_account,
"debit": self.disbursed_amount, "debit": self.disbursed_amount,
"debit_in_account_currency": self.disbursed_amount, "debit_in_account_currency": self.disbursed_amount,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",
@ -137,8 +133,8 @@ class LoanDisbursement(AccountsController):
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.disbursement_account, "account": self.disbursement_account,
"against": loan_details.loan_account, "against": self.loan_account,
"credit": self.disbursed_amount, "credit": self.disbursed_amount,
"credit_in_account_currency": self.disbursed_amount, "credit_in_account_currency": self.disbursed_amount,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",

View File

@ -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: if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj) make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)

View File

@ -1,7 +1,7 @@
{ {
"actions": [], "actions": [],
"autoname": "LM-REP-.####", "autoname": "LM-REP-.####",
"creation": "2019-09-03 14:44:39.977266", "creation": "2022-01-25 10:30:02.767941",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
@ -13,6 +13,7 @@
"column_break_3", "column_break_3",
"company", "company",
"posting_date", "posting_date",
"clearance_date",
"rate_of_interest", "rate_of_interest",
"payroll_payable_account", "payroll_payable_account",
"is_term_loan", "is_term_loan",
@ -37,7 +38,12 @@
"total_penalty_paid", "total_penalty_paid",
"total_interest_paid", "total_interest_paid",
"repayment_details", "repayment_details",
"amended_from" "amended_from",
"accounting_details_section",
"payment_account",
"penalty_income_account",
"column_break_36",
"loan_account"
], ],
"fields": [ "fields": [
{ {
@ -260,12 +266,52 @@
"fieldname": "repay_from_salary", "fieldname": "repay_from_salary",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Repay From Salary" "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, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-01-06 01:51:06.707782", "modified": "2022-02-18 19:10:07.742298",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Loan Management", "module": "Loan Management",
"name": "Loan Repayment", "name": "Loan Repayment",

View File

@ -310,7 +310,6 @@ class LoanRepayment(AccountsController):
def make_gl_entries(self, cancel=0, adv_adj=0): def make_gl_entries(self, cancel=0, adv_adj=0):
gle_map = [] gle_map = []
loan_details = frappe.get_doc("Loan", self.against_loan)
if self.shortfall_amount and self.amount_paid > self.shortfall_amount: if self.shortfall_amount and self.amount_paid > self.shortfall_amount:
remarks = _("Shortfall Repayment of {0}.\nRepayment against Loan: {1}").format(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: if self.repay_from_salary:
payment_account = self.payroll_payable_account payment_account = self.payroll_payable_account
else: else:
payment_account = loan_details.payment_account payment_account = self.payment_account
if self.total_penalty_paid: if self.total_penalty_paid:
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.loan_account, "account": self.loan_account,
"against": loan_details.payment_account, "against": payment_account,
"debit": self.total_penalty_paid, "debit": self.total_penalty_paid,
"debit_in_account_currency": self.total_penalty_paid, "debit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",
@ -344,8 +343,8 @@ class LoanRepayment(AccountsController):
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.penalty_income_account, "account": self.penalty_income_account,
"against": loan_details.loan_account, "against": self.loan_account,
"credit": self.total_penalty_paid, "credit": self.total_penalty_paid,
"credit_in_account_currency": self.total_penalty_paid, "credit_in_account_currency": self.total_penalty_paid,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",
@ -359,8 +358,7 @@ class LoanRepayment(AccountsController):
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": payment_account, "account": payment_account,
"against": loan_details.loan_account + ", " + loan_details.interest_income_account "against": self.loan_account + ", " + self.penalty_income_account,
+ ", " + loan_details.penalty_income_account,
"debit": self.amount_paid, "debit": self.amount_paid,
"debit_in_account_currency": self.amount_paid, "debit_in_account_currency": self.amount_paid,
"against_voucher_type": "Loan", "against_voucher_type": "Loan",
@ -368,16 +366,16 @@ class LoanRepayment(AccountsController):
"remarks": remarks, "remarks": remarks,
"cost_center": self.cost_center, "cost_center": self.cost_center,
"posting_date": getdate(self.posting_date), "posting_date": getdate(self.posting_date),
"party_type": loan_details.applicant_type if self.repay_from_salary else '', "party_type": self.applicant_type if self.repay_from_salary else '',
"party": loan_details.applicant if self.repay_from_salary else '' "party": self.applicant if self.repay_from_salary else ''
}) })
) )
gle_map.append( gle_map.append(
self.get_gl_dict({ self.get_gl_dict({
"account": loan_details.loan_account, "account": self.loan_account,
"party_type": loan_details.applicant_type, "party_type": self.applicant_type,
"party": loan_details.applicant, "party": self.applicant,
"against": payment_account, "against": payment_account,
"credit": self.amount_paid, "credit": self.amount_paid,
"credit_in_account_currency": self.amount_paid, "credit_in_account_currency": self.amount_paid,

View File

@ -62,7 +62,7 @@ class JobCard(Document):
if self.get('time_logs'): if self.get('time_logs'):
for d in 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)) frappe.throw(_("Row {0}: From time must be less than to time").format(d.idx))
data = self.get_overlap_for(d) data = self.get_overlap_for(d)

View File

@ -55,6 +55,7 @@ class TestManufacturingReports(unittest.TestCase):
def test_execute_all_manufacturing_reports(self): def test_execute_all_manufacturing_reports(self):
"""Test that all script report in manufacturing modules are executable with supported filters""" """Test that all script report in manufacturing modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES: for report, filter in REPORT_FILTER_TEST_CASES:
with self.subTest(report=report):
execute_script_report( execute_script_report(
report_name=report, report_name=report,
module="Manufacturing", module="Manufacturing",

View File

@ -10,9 +10,6 @@ erpnext.patches.v4_2.update_requested_and_ordered_qty #2021-03-31
erpnext.patches.v5_7.update_item_description_based_on_item_master erpnext.patches.v5_7.update_item_description_based_on_item_master
erpnext.patches.v4_2.repost_reserved_qty #2021-03-31 erpnext.patches.v4_2.repost_reserved_qty #2021-03-31
execute:frappe.reload_doc("Payroll", "doctype", "salary_slip") execute:frappe.reload_doc("Payroll", "doctype", "salary_slip")
erpnext.patches.v8_1.setup_gst_india #2017-06-27
erpnext.patches.v8_1.removed_roles_from_gst_report_non_indian_account #16-08-2018
erpnext.patches.v8_7.sync_india_custom_fields
erpnext.patches.v10_0.fichier_des_ecritures_comptables_for_france erpnext.patches.v10_0.fichier_des_ecritures_comptables_for_france
erpnext.patches.v10_0.rename_price_to_rate_in_pricing_rule erpnext.patches.v10_0.rename_price_to_rate_in_pricing_rule
erpnext.patches.v10_0.set_currency_in_pricing_rule erpnext.patches.v10_0.set_currency_in_pricing_rule
@ -46,7 +43,6 @@ erpnext.patches.v11_0.check_buying_selling_in_currency_exchange
erpnext.patches.v11_0.move_item_defaults_to_child_table_for_multicompany #02-07-2018 #19-06-2019 erpnext.patches.v11_0.move_item_defaults_to_child_table_for_multicompany #02-07-2018 #19-06-2019
erpnext.patches.v11_0.rename_overproduction_percent_field erpnext.patches.v11_0.rename_overproduction_percent_field
erpnext.patches.v11_0.update_backflush_subcontract_rm_based_on_bom erpnext.patches.v11_0.update_backflush_subcontract_rm_based_on_bom
erpnext.patches.v11_0.inter_state_field_for_gst
erpnext.patches.v11_0.rename_members_with_naming_series #04-06-2018 erpnext.patches.v11_0.rename_members_with_naming_series #04-06-2018
erpnext.patches.v11_0.set_update_field_and_value_in_workflow_state erpnext.patches.v11_0.set_update_field_and_value_in_workflow_state
erpnext.patches.v11_0.update_total_qty_field erpnext.patches.v11_0.update_total_qty_field
@ -68,20 +64,15 @@ execute:frappe.delete_doc_if_exists("Page", "sales-analytics")
execute:frappe.delete_doc_if_exists("Page", "purchase-analytics") execute:frappe.delete_doc_if_exists("Page", "purchase-analytics")
execute:frappe.delete_doc_if_exists("Page", "stock-analytics") execute:frappe.delete_doc_if_exists("Page", "stock-analytics")
execute:frappe.delete_doc_if_exists("Page", "production-analytics") execute:frappe.delete_doc_if_exists("Page", "production-analytics")
erpnext.patches.v11_0.ewaybill_fields_gst_india #2018-11-13 #2019-01-09 #2019-04-01 #2019-04-26 #2019-05-03
erpnext.patches.v11_0.drop_column_max_days_allowed erpnext.patches.v11_0.drop_column_max_days_allowed
erpnext.patches.v10_0.item_barcode_childtable_migrate # 16-02-2019 erpnext.patches.v10_0.item_barcode_childtable_migrate # 16-02-2019
erpnext.patches.v11_0.update_delivery_trip_status erpnext.patches.v11_0.update_delivery_trip_status
erpnext.patches.v11_0.set_missing_gst_hsn_code
erpnext.patches.v11_0.rename_bom_wo_fields erpnext.patches.v11_0.rename_bom_wo_fields
erpnext.patches.v12_0.set_default_homepage_type erpnext.patches.v12_0.set_default_homepage_type
erpnext.patches.v11_0.rename_additional_salary_component_additional_salary erpnext.patches.v11_0.rename_additional_salary_component_additional_salary
erpnext.patches.v11_0.renamed_from_to_fields_in_project erpnext.patches.v11_0.renamed_from_to_fields_in_project
erpnext.patches.v11_0.add_permissions_in_gst_settings #2020-04-04
erpnext.patches.v11_1.setup_guardian_role erpnext.patches.v11_1.setup_guardian_role
execute:frappe.delete_doc('DocType', 'Notification Control') execute:frappe.delete_doc('DocType', 'Notification Control')
erpnext.patches.v12_0.set_gst_category
erpnext.patches.v12_0.update_gst_category
erpnext.patches.v11_0.remove_barcodes_field_from_copy_fields_to_variants erpnext.patches.v11_0.remove_barcodes_field_from_copy_fields_to_variants
erpnext.patches.v12_0.set_task_status erpnext.patches.v12_0.set_task_status
erpnext.patches.v11_0.make_italian_localization_fields # 26-03-2019 erpnext.patches.v11_0.make_italian_localization_fields # 26-03-2019
@ -118,7 +109,6 @@ execute:frappe.delete_doc("DocType", "Project Task")
erpnext.patches.v11_1.update_default_supplier_in_item_defaults erpnext.patches.v11_1.update_default_supplier_in_item_defaults
erpnext.patches.v12_0.update_due_date_in_gle erpnext.patches.v12_0.update_due_date_in_gle
erpnext.patches.v12_0.add_default_buying_selling_terms_in_company erpnext.patches.v12_0.add_default_buying_selling_terms_in_company
erpnext.patches.v12_0.update_ewaybill_field_position
erpnext.patches.v12_0.create_accounting_dimensions_in_missing_doctypes #2020-05-11 erpnext.patches.v12_0.create_accounting_dimensions_in_missing_doctypes #2020-05-11
erpnext.patches.v11_1.set_status_for_material_request_type_manufacture erpnext.patches.v11_1.set_status_for_material_request_type_manufacture
erpnext.patches.v12_0.move_plaid_settings_to_doctype erpnext.patches.v12_0.move_plaid_settings_to_doctype
@ -141,14 +131,12 @@ erpnext.patches.v12_0.replace_accounting_with_accounts_in_home_settings
erpnext.patches.v12_0.set_automatically_process_deferred_accounting_in_accounts_settings erpnext.patches.v12_0.set_automatically_process_deferred_accounting_in_accounts_settings
erpnext.patches.v12_0.set_payment_entry_status erpnext.patches.v12_0.set_payment_entry_status
erpnext.patches.v12_0.update_owner_fields_in_acc_dimension_custom_fields erpnext.patches.v12_0.update_owner_fields_in_acc_dimension_custom_fields
erpnext.patches.v12_0.add_export_type_field_in_party_master
erpnext.patches.v12_0.remove_denied_leaves_from_leave_ledger erpnext.patches.v12_0.remove_denied_leaves_from_leave_ledger
erpnext.patches.v12_0.update_price_or_product_discount erpnext.patches.v12_0.update_price_or_product_discount
erpnext.patches.v12_0.set_production_capacity_in_workstation erpnext.patches.v12_0.set_production_capacity_in_workstation
erpnext.patches.v12_0.set_employee_preferred_emails erpnext.patches.v12_0.set_employee_preferred_emails
erpnext.patches.v12_0.set_against_blanket_order_in_sales_and_purchase_order erpnext.patches.v12_0.set_against_blanket_order_in_sales_and_purchase_order
erpnext.patches.v12_0.set_cost_center_in_child_table_of_expense_claim erpnext.patches.v12_0.set_cost_center_in_child_table_of_expense_claim
erpnext.patches.v12_0.add_eway_bill_in_delivery_note
erpnext.patches.v12_0.set_lead_title_field erpnext.patches.v12_0.set_lead_title_field
erpnext.patches.v12_0.set_permission_einvoicing erpnext.patches.v12_0.set_permission_einvoicing
erpnext.patches.v12_0.set_job_offer_applicant_email erpnext.patches.v12_0.set_job_offer_applicant_email
@ -196,7 +184,6 @@ erpnext.patches.v12_0.set_italian_import_supplier_invoice_permissions
erpnext.patches.v13_0.update_subscription erpnext.patches.v13_0.update_subscription
erpnext.patches.v12_0.unhide_cost_center_field erpnext.patches.v12_0.unhide_cost_center_field
erpnext.patches.v13_0.update_sla_enhancements erpnext.patches.v13_0.update_sla_enhancements
erpnext.patches.v12_0.update_address_template_for_india
erpnext.patches.v13_0.update_deferred_settings erpnext.patches.v13_0.update_deferred_settings
erpnext.patches.v12_0.set_multi_uom_in_rfq erpnext.patches.v12_0.set_multi_uom_in_rfq
erpnext.patches.v13_0.delete_old_sales_reports erpnext.patches.v13_0.delete_old_sales_reports
@ -225,7 +212,6 @@ erpnext.patches.v13_0.set_youtube_video_id
erpnext.patches.v13_0.set_app_name erpnext.patches.v13_0.set_app_name
erpnext.patches.v13_0.print_uom_after_quantity_patch erpnext.patches.v13_0.print_uom_after_quantity_patch
erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account erpnext.patches.v13_0.set_payment_channel_in_payment_gateway_account
erpnext.patches.v12_0.setup_einvoice_fields #2020-12-02
erpnext.patches.v13_0.updates_for_multi_currency_payroll erpnext.patches.v13_0.updates_for_multi_currency_payroll
erpnext.patches.v13_0.update_reason_for_resignation_in_employee erpnext.patches.v13_0.update_reason_for_resignation_in_employee
execute:frappe.delete_doc("Report", "Quoted Item Comparison") execute:frappe.delete_doc("Report", "Quoted Item Comparison")
@ -245,22 +231,18 @@ erpnext.patches.v12_0.add_state_code_for_ladakh
erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl erpnext.patches.v13_0.item_reposting_for_incorrect_sl_and_gl
erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes erpnext.patches.v13_0.delete_old_bank_reconciliation_doctypes
erpnext.patches.v13_0.update_vehicle_no_reqd_condition erpnext.patches.v13_0.update_vehicle_no_reqd_condition
erpnext.patches.v12_0.add_einvoice_status_field #2021-03-17
erpnext.patches.v12_0.add_einvoice_summary_report_permissions
erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation erpnext.patches.v13_0.setup_fields_for_80g_certificate_and_donation
erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings erpnext.patches.v13_0.rename_membership_settings_to_non_profit_settings
erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae erpnext.patches.v13_0.setup_gratuity_rule_for_india_and_uae
erpnext.patches.v13_0.setup_uae_vat_fields erpnext.patches.v13_0.setup_uae_vat_fields
execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext') execute:frappe.db.set_value('System Settings', None, 'app_name', 'ERPNext')
erpnext.patches.v12_0.create_taxable_value_field erpnext.patches.v12_0.create_taxable_value_field
erpnext.patches.v12_0.add_gst_category_in_delivery_note
erpnext.patches.v12_0.purchase_receipt_status erpnext.patches.v12_0.purchase_receipt_status
erpnext.patches.v13_0.fix_non_unique_represents_company erpnext.patches.v13_0.fix_non_unique_represents_company
erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing
erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022 erpnext.patches.v13_0.make_non_standard_user_type #13-04-2021 #17-01-2022
erpnext.patches.v13_0.update_shipment_status erpnext.patches.v13_0.update_shipment_status
erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting
erpnext.patches.v12_0.add_ewaybill_validity_field
erpnext.patches.v13_0.germany_make_custom_fields erpnext.patches.v13_0.germany_make_custom_fields
erpnext.patches.v13_0.germany_fill_debtor_creditor_number erpnext.patches.v13_0.germany_fill_debtor_creditor_number
erpnext.patches.v13_0.set_pos_closing_as_failed erpnext.patches.v13_0.set_pos_closing_as_failed
@ -276,9 +258,7 @@ erpnext.patches.v13_0.update_job_card_details
erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry erpnext.patches.v13_0.add_missing_fg_item_for_stock_entry
erpnext.patches.v13_0.update_subscription_status_in_memberships erpnext.patches.v13_0.update_subscription_status_in_memberships
erpnext.patches.v13_0.update_amt_in_work_order_required_items erpnext.patches.v13_0.update_amt_in_work_order_required_items
erpnext.patches.v12_0.show_einvoice_irn_cancelled_field
erpnext.patches.v13_0.delete_orphaned_tables erpnext.patches.v13_0.delete_orphaned_tables
erpnext.patches.v13_0.update_export_type_for_gst #2021-08-16
erpnext.patches.v13_0.update_tds_check_field #3 erpnext.patches.v13_0.update_tds_check_field #3
erpnext.patches.v13_0.add_custom_field_for_south_africa #2 erpnext.patches.v13_0.add_custom_field_for_south_africa #2
erpnext.patches.v13_0.update_recipient_email_digest erpnext.patches.v13_0.update_recipient_email_digest
@ -291,7 +271,6 @@ execute:frappe.reload_doc("erpnext_integrations", "doctype", "TaxJar Settings")
execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category") execute:frappe.reload_doc("erpnext_integrations", "doctype", "Product Tax Category")
erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021 erpnext.patches.v13_0.custom_fields_for_taxjar_integration #08-11-2021
erpnext.patches.v13_0.set_operation_time_based_on_operating_cost erpnext.patches.v13_0.set_operation_time_based_on_operating_cost
erpnext.patches.v13_0.create_gst_payment_entry_fields #27-11-2021
erpnext.patches.v13_0.fix_invoice_statuses erpnext.patches.v13_0.fix_invoice_statuses
erpnext.patches.v13_0.create_website_items #30-09-2021 erpnext.patches.v13_0.create_website_items #30-09-2021
erpnext.patches.v13_0.populate_e_commerce_settings erpnext.patches.v13_0.populate_e_commerce_settings
@ -299,7 +278,6 @@ erpnext.patches.v13_0.make_homepage_products_website_items
erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item erpnext.patches.v13_0.replace_supplier_item_group_with_party_specific_item
erpnext.patches.v13_0.update_dates_in_tax_withholding_category erpnext.patches.v13_0.update_dates_in_tax_withholding_category
erpnext.patches.v14_0.update_opportunity_currency_fields erpnext.patches.v14_0.update_opportunity_currency_fields
erpnext.patches.v13_0.gst_fields_for_pos_invoice
erpnext.patches.v13_0.create_accounting_dimensions_in_pos_doctypes erpnext.patches.v13_0.create_accounting_dimensions_in_pos_doctypes
erpnext.patches.v13_0.trim_sales_invoice_custom_field_length erpnext.patches.v13_0.trim_sales_invoice_custom_field_length
erpnext.patches.v13_0.create_custom_field_for_finance_book erpnext.patches.v13_0.create_custom_field_for_finance_book
@ -316,7 +294,6 @@ erpnext.patches.v13_0.healthcare_deprecation_warning
erpnext.patches.v13_0.item_naming_series_not_mandatory erpnext.patches.v13_0.item_naming_series_not_mandatory
erpnext.patches.v14_0.delete_healthcare_doctypes erpnext.patches.v14_0.delete_healthcare_doctypes
erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.update_category_in_ltds_certificate
erpnext.patches.v13_0.create_pan_field_for_india #2
erpnext.patches.v13_0.fetch_thumbnail_in_website_items erpnext.patches.v13_0.fetch_thumbnail_in_website_items
erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit erpnext.patches.v13_0.update_maintenance_schedule_field_in_visit
erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022 erpnext.patches.v13_0.create_ksa_vat_custom_fields # 07-01-2022
@ -324,7 +301,6 @@ erpnext.patches.v14_0.migrate_crm_settings
erpnext.patches.v13_0.rename_ksa_qr_field erpnext.patches.v13_0.rename_ksa_qr_field
erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty erpnext.patches.v13_0.wipe_serial_no_field_for_0_qty
erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021 erpnext.patches.v13_0.disable_ksa_print_format_for_others # 16-12-2021
erpnext.patches.v13_0.update_tax_category_for_rcm
execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings') execute:frappe.delete_doc_if_exists('Workspace', 'ERPNext Integrations Settings')
erpnext.patches.v14_0.set_payroll_cost_centers erpnext.patches.v14_0.set_payroll_cost_centers
erpnext.patches.v13_0.agriculture_deprecation_warning erpnext.patches.v13_0.agriculture_deprecation_warning
@ -342,9 +318,7 @@ erpnext.patches.v14_0.delete_hospitality_doctypes # 20-01-2022
erpnext.patches.v14_0.delete_agriculture_doctypes erpnext.patches.v14_0.delete_agriculture_doctypes
erpnext.patches.v14_0.rearrange_company_fields erpnext.patches.v14_0.rearrange_company_fields
erpnext.patches.v14_0.update_leave_notification_template erpnext.patches.v14_0.update_leave_notification_template
erpnext.patches.v14_0.restore_einvoice_fields
erpnext.patches.v13_0.update_sane_transfer_against erpnext.patches.v13_0.update_sane_transfer_against
erpnext.patches.v12_0.add_company_link_to_einvoice_settings
erpnext.patches.v14_0.migrate_cost_center_allocations erpnext.patches.v14_0.migrate_cost_center_allocations
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template 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.shopping_cart_to_ecommerce
@ -353,3 +327,5 @@ erpnext.patches.v13_0.update_reserved_qty_closed_wo
erpnext.patches.v13_0.update_exchange_rate_settings erpnext.patches.v13_0.update_exchange_rate_settings
erpnext.patches.v14_0.delete_amazon_mws_doctype erpnext.patches.v14_0.delete_amazon_mws_doctype
erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr erpnext.patches.v13_0.set_work_order_qty_in_so_from_mr
erpnext.patches.v13_0.update_accounts_in_loan_docs
erpnext.patches.v14_0.update_batch_valuation_flag

View File

@ -1,13 +0,0 @@
import frappe
from erpnext.regional.india.setup import add_permissions
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc("regional", "doctype", "lower_deduction_certificate")
frappe.reload_doc("regional", "doctype", "gstr_3b_report")
add_permissions()

View File

@ -1,11 +0,0 @@
import frappe
from erpnext.regional.india.setup import make_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
make_custom_fields()

View File

@ -1,72 +0,0 @@
import frappe
from erpnext.regional.india.setup import make_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc("Payroll", "doctype", "Employee Tax Exemption Declaration")
frappe.reload_doc("Payroll", "doctype", "Employee Tax Exemption Proof Submission")
frappe.reload_doc("hr", "doctype", "Employee Grade")
frappe.reload_doc("hr", "doctype", "Leave Policy")
frappe.reload_doc("accounts", "doctype", "Bank Account")
frappe.reload_doc("accounts", "doctype", "Tax Withholding Category")
frappe.reload_doc("accounts", "doctype", "Allowed To Transact With")
frappe.reload_doc("accounts", "doctype", "Finance Book")
frappe.reload_doc("accounts", "doctype", "Loyalty Program")
frappe.reload_doc("stock", "doctype", "Item Barcode")
make_custom_fields()
frappe.reload_doc("accounts", "doctype", "sales_taxes_and_charges")
frappe.reload_doc("accounts", "doctype", "purchase_taxes_and_charges")
frappe.reload_doc("accounts", "doctype", "sales_taxes_and_charges_template")
frappe.reload_doc("accounts", "doctype", "purchase_taxes_and_charges_template")
# set is_inter_state in Taxes And Charges Templates
if frappe.db.has_column("Sales Taxes and Charges Template", "is_inter_state") and\
frappe.db.has_column("Purchase Taxes and Charges Template", "is_inter_state"):
igst_accounts = set(frappe.db.sql_list('''SELECT igst_account from `tabGST Account` WHERE parent = "GST Settings"'''))
cgst_accounts = set(frappe.db.sql_list('''SELECT cgst_account FROM `tabGST Account` WHERE parenttype = "GST Settings"'''))
when_then_sales = get_formatted_data("Sales Taxes and Charges", igst_accounts, cgst_accounts)
when_then_purchase = get_formatted_data("Purchase Taxes and Charges", igst_accounts, cgst_accounts)
if when_then_sales:
frappe.db.sql('''update `tabSales Taxes and Charges Template`
set is_inter_state = Case {when_then} Else 0 End
'''.format(when_then=" ".join(when_then_sales)))
if when_then_purchase:
frappe.db.sql('''update `tabPurchase Taxes and Charges Template`
set is_inter_state = Case {when_then} Else 0 End
'''.format(when_then=" ".join(when_then_purchase)))
def get_formatted_data(doctype, igst_accounts, cgst_accounts):
# fetch all the rows data from child table
all_details = frappe.db.sql('''
select parent, account_head from `tab{doctype}`
where parenttype="{doctype} Template"'''.format(doctype=doctype), as_dict=True)
# group the data in the form "parent: [list of accounts]""
group_detail = {}
for i in all_details:
if not i['parent'] in group_detail: group_detail[i['parent']] = []
for j in all_details:
if i['parent']==j['parent']:
group_detail[i['parent']].append(j['account_head'])
# form when_then condition based on - if list of accounts for a document
# matches any account in igst_accounts list and not matches any in cgst_accounts list
when_then = []
for i in group_detail:
temp = set(group_detail[i])
if not temp.isdisjoint(igst_accounts) and temp.isdisjoint(cgst_accounts):
when_then.append('''When name='{name}' Then 1'''.format(name=i))
return when_then

View File

@ -1,45 +0,0 @@
import frappe
from erpnext.controllers.taxes_and_totals import get_itemised_tax_breakup_html
def execute():
company = frappe.db.sql_list("select name from tabCompany where country = 'India'")
if not company:
return
doctypes = ["Quotation", "Sales Order", "Delivery Note", "Sales Invoice",
"Supplier Quotation", "Purchase Order", "Purchase Receipt", "Purchase Invoice"]
for dt in doctypes:
date_field = "posting_date"
if dt in ["Quotation", "Sales Order", "Supplier Quotation", "Purchase Order"]:
date_field = "transaction_date"
transactions = frappe.db.sql("""
select dt.name, dt_item.name as child_name
from `tab{dt}` dt, `tab{dt} Item` dt_item
where dt.name = dt_item.parent
and dt.`{date_field}` > '2018-06-01'
and dt.docstatus = 1
and ifnull(dt_item.gst_hsn_code, '') = ''
and ifnull(dt_item.item_code, '') != ''
and dt.company in ({company})
""".format(dt=dt, date_field=date_field, company=", ".join(['%s']*len(company))), tuple(company), as_dict=1)
if not transactions:
continue
transaction_rows_name = [d.child_name for d in transactions]
frappe.db.sql("""
update `tab{dt} Item` dt_item
set dt_item.gst_hsn_code = (select gst_hsn_code from tabItem where name=dt_item.item_code)
where dt_item.name in ({rows_name})
""".format(dt=dt, rows_name=", ".join(['%s']*len(transaction_rows_name))), tuple(transaction_rows_name))
parent = set([d.name for d in transactions])
for t in list(parent):
trans_doc = frappe.get_doc(dt, t)
hsnwise_tax = get_itemised_tax_breakup_html(trans_doc)
frappe.db.set_value(dt, t, "other_charges_calculation", hsnwise_tax, update_modified=False)

View File

@ -1,18 +0,0 @@
from __future__ import unicode_literals
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company or not frappe.db.count('E Invoice User'):
return
frappe.reload_doc("regional", "doctype", "e_invoice_user")
for creds in frappe.db.get_all('E Invoice User', fields=['name', 'gstin']):
company_name = frappe.db.sql("""
select dl.link_name from `tabAddress` a, `tabDynamic Link` dl
where a.gstin = %s and dl.parent = a.name and dl.link_doctype = 'Company'
""", (creds.get('gstin')))
if company_name and len(company_name) > 0:
frappe.db.set_value('E Invoice User', creds.get('name'), 'company', company_name[0][0])

View File

@ -1,72 +0,0 @@
from __future__ import unicode_literals
import json
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
# move hidden einvoice fields to a different section
custom_fields = {
'Sales Invoice': [
dict(fieldname='einvoice_section', label='E-Invoice Fields', fieldtype='Section Break', insert_after='gst_vehicle_type',
print_hide=1, hidden=1),
dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='einvoice_section',
no_copy=1, print_hide=1),
dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
dict(fieldname='irn_cancel_date', label='Cancel Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_date',
no_copy=1, print_hide=1),
dict(fieldname='signed_einvoice', label='Signed E-Invoice', fieldtype='Code', options='JSON', hidden=1, insert_after='irn_cancel_date',
no_copy=1, print_hide=1, read_only=1),
dict(fieldname='signed_qr_code', label='Signed QRCode', fieldtype='Code', options='JSON', hidden=1, insert_after='signed_einvoice',
no_copy=1, print_hide=1, read_only=1),
dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, insert_after='signed_qr_code',
no_copy=1, print_hide=1, read_only=1),
dict(fieldname='einvoice_status', label='E-Invoice Status', fieldtype='Select', insert_after='qrcode_image',
options='\nPending\nGenerated\nCancelled\nFailed', default=None, hidden=1, no_copy=1, print_hide=1, read_only=1),
dict(fieldname='failure_description', label='E-Invoice Failure Description', fieldtype='Code', options='JSON',
hidden=1, insert_after='einvoice_status', no_copy=1, print_hide=1, read_only=1)
]
}
create_custom_fields(custom_fields, update=True)
if frappe.db.exists('E Invoice Settings') and frappe.db.get_single_value('E Invoice Settings', 'enable'):
frappe.db.sql('''
UPDATE `tabSales Invoice` SET einvoice_status = 'Pending'
WHERE
posting_date >= '2021-04-01'
AND ifnull(irn, '') = ''
AND ifnull(`billing_address_gstin`, '') != ifnull(`company_gstin`, '')
AND ifnull(gst_category, '') in ('Registered Regular', 'SEZ', 'Overseas', 'Deemed Export')
''')
# set appropriate statuses
frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Generated'
WHERE ifnull(irn, '') != '' AND ifnull(irn_cancelled, 0) = 0''')
frappe.db.sql('''UPDATE `tabSales Invoice` SET einvoice_status = 'Cancelled'
WHERE ifnull(irn_cancelled, 0) = 1''')
# set correct acknowledgement in e-invoices
einvoices = frappe.get_all('Sales Invoice', {'irn': ['is', 'set']}, ['name', 'signed_einvoice'])
if einvoices:
for inv in einvoices:
signed_einvoice = inv.get('signed_einvoice')
if signed_einvoice:
signed_einvoice = json.loads(signed_einvoice)
frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_no', signed_einvoice.get('AckNo'), update_modified=False)
frappe.db.set_value('Sales Invoice', inv.get('name'), 'ack_date', signed_einvoice.get('AckDt'), update_modified=False)

View File

@ -1,20 +0,0 @@
from __future__ import unicode_literals
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
if frappe.db.exists('Report', 'E-Invoice Summary') and \
not frappe.db.get_value('Custom Role', dict(report='E-Invoice Summary')):
frappe.get_doc(dict(
doctype='Custom Role',
report='E-Invoice Summary',
roles= [
dict(role='Accounts User'),
dict(role='Accounts Manager')
]
)).insert()

View File

@ -1,20 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_field
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
create_custom_field('Delivery Note', {
'fieldname': 'ewaybill',
'label': 'E-Way Bill No.',
'fieldtype': 'Data',
'depends_on': 'eval:(doc.docstatus === 1)',
'allow_on_submit': 1,
'insert_after': 'customer_name_in_arabic',
'translatable': 0,
'owner': 'Administrator'
})

View File

@ -1,18 +0,0 @@
from __future__ import unicode_literals
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
custom_fields = {
'Sales Invoice': [
dict(fieldname='eway_bill_validity', label='E-Way Bill Validity', fieldtype='Data', no_copy=1, print_hide=1,
depends_on='ewaybill', read_only=1, allow_on_submit=1, insert_after='ewaybill')
]
}
create_custom_fields(custom_fields, update=True)

View File

@ -1,41 +0,0 @@
import frappe
from erpnext.regional.india.setup import make_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
make_custom_fields()
frappe.reload_doctype('Tax Category')
frappe.reload_doctype('Sales Taxes and Charges Template')
frappe.reload_doctype('Purchase Taxes and Charges Template')
# Create tax category with inter state field checked
tax_category = frappe.db.get_value('Tax Category', {'name': 'OUT OF STATE'}, 'name')
if not tax_category:
inter_state_category = frappe.get_doc({
'doctype': 'Tax Category',
'title': 'OUT OF STATE',
'is_inter_state': 1
}).insert()
tax_category = inter_state_category.name
for doctype in ('Sales Taxes and Charges Template', 'Purchase Taxes and Charges Template'):
if not frappe.get_meta(doctype).has_field('is_inter_state'): continue
template = frappe.db.get_value(doctype, {'is_inter_state': 1, 'disabled': 0}, ['name'])
if template:
frappe.db.set_value(doctype, template, 'tax_category', tax_category)
frappe.db.sql("""
DELETE FROM `tabCustom Field`
WHERE fieldname = 'is_inter_state'
AND dt IN ('Sales Taxes and Charges Template', 'Purchase Taxes and Charges Template')
""")

View File

@ -1,19 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
custom_fields = {
'Delivery Note': [
dict(fieldname='gst_category', label='GST Category',
fieldtype='Select', insert_after='gst_vehicle_type', print_hide=1,
options='\nRegistered Regular\nRegistered Composition\nUnregistered\nSEZ\nOverseas\nConsumer\nDeemed Export\nUIN Holders',
fetch_from='customer.gst_category', fetch_if_empty=1),
]
}
create_custom_fields(custom_fields, update=True)

View File

@ -1,52 +0,0 @@
import frappe
from erpnext.regional.india.setup import make_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc('accounts', 'doctype', 'Tax Category')
make_custom_fields()
for doctype in ['Sales Invoice', 'Purchase Invoice']:
has_column = frappe.db.has_column(doctype,'invoice_type')
if has_column:
update_map = {
'Regular': 'Registered Regular',
'Export': 'Overseas',
'SEZ': 'SEZ',
'Deemed Export': 'Deemed Export',
}
for old, new in update_map.items():
frappe.db.sql("UPDATE `tab{doctype}` SET gst_category = %s where invoice_type = %s".format(doctype=doctype), (new, old)) #nosec
frappe.delete_doc('Custom Field', 'Sales Invoice-invoice_type')
frappe.delete_doc('Custom Field', 'Purchase Invoice-invoice_type')
itc_update_map = {
"ineligible": "Ineligible",
"input service": "Input Service Distributor",
"capital goods": "Import Of Capital Goods",
"input": "All Other ITC"
}
has_gst_fields = frappe.db.has_column('Purchase Invoice','eligibility_for_itc')
if has_gst_fields:
for old, new in itc_update_map.items():
frappe.db.sql("UPDATE `tabPurchase Invoice` SET eligibility_for_itc = %s where eligibility_for_itc = %s ", (new, old))
for doctype in ["Customer", "Supplier"]:
frappe.db.sql(""" UPDATE `tab{doctype}` t1, `tabAddress` t2, `tabDynamic Link` t3 SET t1.gst_category = "Registered Regular"
where t3.link_name = t1.name and t3.parent = t2.name and t2.gstin IS NOT NULL and t2.gstin != '' """.format(doctype=doctype)) #nosec
frappe.db.sql(""" UPDATE `tab{doctype}` t1, `tabAddress` t2, `tabDynamic Link` t3 SET t1.gst_category = "Overseas"
where t3.link_name = t1.name and t3.parent = t2.name and t2.country != 'India' """.format(doctype=doctype)) #nosec

View File

@ -1,59 +0,0 @@
from __future__ import unicode_literals
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.regional.india.setup import add_permissions, add_print_formats
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc("custom", "doctype", "custom_field")
frappe.reload_doc("regional", "doctype", "e_invoice_settings")
custom_fields = {
'Sales Invoice': [
dict(fieldname='irn', label='IRN', fieldtype='Data', read_only=1, insert_after='customer', no_copy=1, print_hide=1,
depends_on='eval:in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category) && doc.irn_cancelled === 0'),
dict(fieldname='ack_no', label='Ack. No.', fieldtype='Data', read_only=1, hidden=1, insert_after='irn', no_copy=1, print_hide=1),
dict(fieldname='ack_date', label='Ack. Date', fieldtype='Data', read_only=1, hidden=1, insert_after='ack_no', no_copy=1, print_hide=1),
dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
dict(fieldname='signed_einvoice', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
dict(fieldname='signed_qr_code', fieldtype='Code', options='JSON', hidden=1, no_copy=1, print_hide=1, read_only=1),
dict(fieldname='qrcode_image', label='QRCode', fieldtype='Attach Image', hidden=1, no_copy=1, print_hide=1, read_only=1)
]
}
create_custom_fields(custom_fields, update=True)
add_permissions()
add_print_formats()
einvoice_cond = 'in_list(["Registered Regular", "SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
t = {
'mode_of_transport': [{'default': None}],
'distance': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.transporter'}],
'gst_vehicle_type': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
'lr_date': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
'lr_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && in_list(["Air", "Ship", "Rail"], doc.mode_of_transport)'}],
'vehicle_no': [{'mandatory_depends_on': f'eval:{einvoice_cond} && doc.mode_of_transport == "Road"'}],
'ewaybill': [
{'read_only_depends_on': 'eval:doc.irn && doc.ewaybill'},
{'depends_on': 'eval:((doc.docstatus === 1 || doc.ewaybill) && doc.eway_bill_cancelled === 0)'}
]
}
for field, conditions in t.items():
for c in conditions:
[(prop, value)] = c.items()
frappe.db.set_value('Custom Field', { 'fieldname': field }, prop, value)

View File

@ -1,14 +0,0 @@
from __future__ import unicode_literals
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
irn_cancelled_field = frappe.db.exists('Custom Field', {'dt': 'Sales Invoice', 'fieldname': 'irn_cancelled'})
if irn_cancelled_field:
frappe.db.set_value('Custom Field', irn_cancelled_field, 'depends_on', 'eval: doc.irn')
frappe.db.set_value('Custom Field', irn_cancelled_field, 'read_only', 0)

View File

@ -1,14 +0,0 @@
# Copyright (c) 2020, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from erpnext.regional.address_template.setup import set_up_address_templates
def execute():
if frappe.db.get_value('Company', {'country': 'India'}, 'name'):
address_template = frappe.db.get_value('Address Template', 'India', 'template')
if not address_template or "gstin" not in address_template:
set_up_address_templates(default_country='India')

View File

@ -1,27 +0,0 @@
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
field = frappe.db.get_value("Custom Field", {"dt": "Sales Invoice", "fieldname": "ewaybill"})
if field:
ewaybill_field = frappe.get_doc("Custom Field", field)
ewaybill_field.flags.ignore_validate = True
ewaybill_field.update({
'fieldname': 'ewaybill',
'label': 'e-Way Bill No.',
'fieldtype': 'Data',
'depends_on': 'eval:(doc.docstatus === 1)',
'allow_on_submit': 1,
'insert_after': 'tax_id',
'translatable': 0
})
ewaybill_field.save()

View File

@ -1,19 +0,0 @@
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.db.sql(""" UPDATE `tabSales Invoice` set gst_category = 'Unregistered'
where gst_category = 'Registered Regular'
and ifnull(customer_gstin, '')=''
and ifnull(billing_address_gstin,'')=''
""")
frappe.db.sql(""" UPDATE `tabPurchase Invoice` set gst_category = 'Unregistered'
where gst_category = 'Registered Regular'
and ifnull(supplier_gstin, '')=''
""")

View File

@ -1,37 +0,0 @@
# Copyright (c) 2021, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
frappe.reload_doc('accounts', 'doctype', 'advance_taxes_and_charges')
frappe.reload_doc('accounts', 'doctype', 'payment_entry')
if frappe.db.exists('Company', {'country': 'India'}):
custom_fields = {
'Payment Entry': [
dict(fieldname='gst_section', label='GST Details', fieldtype='Section Break', insert_after='deductions',
print_hide=1, collapsible=1),
dict(fieldname='company_address', label='Company Address', fieldtype='Link', insert_after='gst_section',
print_hide=1, options='Address'),
dict(fieldname='company_gstin', label='Company GSTIN',
fieldtype='Data', insert_after='company_address',
fetch_from='company_address.gstin', print_hide=1, read_only=1),
dict(fieldname='place_of_supply', label='Place of Supply',
fieldtype='Data', insert_after='company_gstin',
print_hide=1, read_only=1),
dict(fieldname='customer_address', label='Customer Address', fieldtype='Link', insert_after='place_of_supply',
print_hide=1, options='Address', depends_on = 'eval:doc.party_type == "Customer"'),
dict(fieldname='customer_gstin', label='Customer GSTIN',
fieldtype='Data', insert_after='customer_address',
fetch_from='customer_address.gstin', print_hide=1, read_only=1)
]
}
create_custom_fields(custom_fields, update=True)
else:
fields = ['gst_section', 'company_address', 'company_gstin', 'place_of_supply', 'customer_address', 'customer_gstin']
for field in fields:
frappe.delete_doc_if_exists("Custom Field", f"Payment Entry-{field}")

View File

@ -1,29 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
frappe.reload_doc('buying', 'doctype', 'supplier', force=True)
frappe.reload_doc('selling', 'doctype', 'customer', force=True)
frappe.reload_doc('core', 'doctype', 'doctype', force=True)
custom_fields = {
'Supplier': [
{
'fieldname': 'pan',
'label': 'PAN',
'fieldtype': 'Data',
'insert_after': 'supplier_type'
}
],
'Customer': [
{
'fieldname': 'pan',
'label': 'PAN',
'fieldtype': 'Data',
'insert_after': 'customer_type'
}
]
}
create_custom_fields(custom_fields, update=True)

View File

@ -1,42 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'}, fields=['name'])
if not company:
return
hsn_sac_field = dict(fieldname='gst_hsn_code', label='HSN/SAC',
fieldtype='Data', fetch_from='item_code.gst_hsn_code', insert_after='description',
allow_on_submit=1, print_hide=1, fetch_if_empty=1)
nil_rated_exempt = dict(fieldname='is_nil_exempt', label='Is Nil Rated or Exempted',
fieldtype='Check', fetch_from='item_code.is_nil_exempt', insert_after='gst_hsn_code',
print_hide=1)
is_non_gst = dict(fieldname='is_non_gst', label='Is Non GST',
fieldtype='Check', fetch_from='item_code.is_non_gst', insert_after='is_nil_exempt',
print_hide=1)
taxable_value = dict(fieldname='taxable_value', label='Taxable Value',
fieldtype='Currency', insert_after='base_net_amount', hidden=1, options="Company:company:default_currency",
print_hide=1)
sales_invoice_gst_fields = [
dict(fieldname='billing_address_gstin', label='Billing Address GSTIN',
fieldtype='Data', insert_after='customer_address', read_only=1,
fetch_from='customer_address.gstin', print_hide=1),
dict(fieldname='customer_gstin', label='Customer GSTIN',
fieldtype='Data', insert_after='shipping_address_name',
fetch_from='shipping_address_name.gstin', print_hide=1),
dict(fieldname='place_of_supply', label='Place of Supply',
fieldtype='Data', insert_after='customer_gstin',
print_hide=1, read_only=1),
dict(fieldname='company_gstin', label='Company GSTIN',
fieldtype='Data', insert_after='company_address',
fetch_from='company_address.gstin', print_hide=1, read_only=1),
]
custom_fields = {
'POS Invoice': sales_invoice_gst_fields,
'POS Invoice Item': [hsn_sac_field, nil_rated_exempt, is_non_gst, taxable_value],
}
create_custom_fields(custom_fields, update=True)

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

View File

@ -1,33 +0,0 @@
import frappe
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
# Update custom fields
fieldname = frappe.db.get_value('Custom Field', {'dt': 'Customer', 'fieldname': 'export_type'})
if fieldname:
frappe.db.set_value('Custom Field', fieldname,
{
'default': '',
'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas", "Deemed Export"], doc.gst_category)'
})
fieldname = frappe.db.get_value('Custom Field', {'dt': 'Supplier', 'fieldname': 'export_type'})
if fieldname:
frappe.db.set_value('Custom Field', fieldname,
{
'default': '',
'mandatory_depends_on': 'eval:in_list(["SEZ", "Overseas"], doc.gst_category)'
})
# Update Customer/Supplier Masters
frappe.db.sql("""
UPDATE `tabCustomer` set export_type = '' WHERE gst_category NOT IN ('SEZ', 'Overseas', 'Deemed Export')
""")
frappe.db.sql("""
UPDATE `tabSupplier` set export_type = '' WHERE gst_category NOT IN ('SEZ', 'Overseas')
""")

View File

@ -1,31 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.regional.india import states
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
create_custom_fields({
'Tax Category': [
dict(fieldname='is_inter_state', label='Is Inter State',
fieldtype='Check', insert_after='disabled', print_hide=1),
dict(fieldname='is_reverse_charge', label='Is Reverse Charge', fieldtype='Check',
insert_after='is_inter_state', print_hide=1),
dict(fieldname='tax_category_column_break', fieldtype='Column Break',
insert_after='is_reverse_charge'),
dict(fieldname='gst_state', label='Source State', fieldtype='Select',
options='\n'.join(states), insert_after='company')
]
}, update=True)
tax_category = frappe.qb.DocType("Tax Category")
frappe.qb.update(tax_category).set(
tax_category.is_reverse_charge, 1
).where(
tax_category.name.isin(['Reverse Charge Out-State', 'Reverse Charge In-State'])
).run()

View File

@ -1,24 +0,0 @@
import frappe
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
from erpnext.regional.india.setup import add_permissions, add_print_formats
def execute():
# restores back the 2 custom fields that was deleted while removing e-invoicing from v14
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
custom_fields = {
'Sales Invoice': [
dict(fieldname='irn_cancelled', label='IRN Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
depends_on='eval:(doc.irn_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
dict(fieldname='eway_bill_cancelled', label='E-Way Bill Cancelled', fieldtype='Check', no_copy=1, print_hide=1,
depends_on='eval:(doc.eway_bill_cancelled === 1)', read_only=1, allow_on_submit=1, insert_after='customer'),
]
}
create_custom_fields(custom_fields, update=True)
add_permissions()
add_print_formats()

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

View File

@ -1,19 +0,0 @@
# Copyright (c) 2017, Frappe and Contributors
# License: GNU General Public License v3. See license.txt
import frappe
def execute():
frappe.reload_doc('core', 'doctype', 'has_role')
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
frappe.db.sql("""
delete from
`tabHas Role`
where
parenttype = 'Report' and parent in('GST Sales Register',
'GST Purchase Register', 'GST Itemised Sales Register',
'GST Itemised Purchase Register', 'Eway Bill')""")

View File

@ -1,53 +0,0 @@
import frappe
from frappe.email import sendmail_to_system_managers
def execute():
frappe.reload_doc('stock', 'doctype', 'item')
frappe.reload_doc("stock", "doctype", "customs_tariff_number")
frappe.reload_doc("accounts", "doctype", "payment_terms_template")
frappe.reload_doc("accounts", "doctype", "payment_schedule")
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc('regional', 'doctype', 'gst_settings')
frappe.reload_doc('regional', 'doctype', 'gst_hsn_code')
for report_name in ('GST Sales Register', 'GST Purchase Register',
'GST Itemised Sales Register', 'GST Itemised Purchase Register'):
frappe.reload_doc('regional', 'report', frappe.scrub(report_name))
from erpnext.regional.india.setup import setup
delete_custom_field_tax_id_if_exists()
setup(patch=True)
send_gst_update_email()
def delete_custom_field_tax_id_if_exists():
for field in frappe.db.sql_list("""select name from `tabCustom Field` where fieldname='tax_id'
and dt in ('Sales Order', 'Sales Invoice', 'Delivery Note')"""):
frappe.delete_doc("Custom Field", field, ignore_permissions=True)
frappe.db.commit()
def send_gst_update_email():
message = """Hello,
<p>ERPNext is now GST Ready!</p>
<p>To start making GST Invoices from 1st of July, you just need to create new Tax Accounts,
Templates and update your Customer's and Supplier's GST Numbers.</p>
<p>Please refer {gst_document_link} to know more about how to setup and implement GST in ERPNext.</p>
<p>Please contact us at support@erpnext.com, if you have any questions.</p>
<p>Thanks,</p>
ERPNext Team.
""".format(gst_document_link="<a href='http://frappe.github.io/erpnext/user/manual/en/regional/india/'> ERPNext GST Document </a>")
try:
sendmail_to_system_managers("[Important] ERPNext GST updates", message)
except Exception as e:
pass

View File

@ -1,35 +0,0 @@
import frappe
from erpnext.regional.india.setup import make_custom_fields
def execute():
company = frappe.get_all('Company', filters = {'country': 'India'})
if not company:
return
frappe.reload_doc('Payroll', 'doctype', 'payroll_period')
frappe.reload_doc('Payroll', 'doctype', 'employee_tax_exemption_declaration')
frappe.reload_doc('Payroll', 'doctype', 'employee_tax_exemption_proof_submission')
frappe.reload_doc('Payroll', 'doctype', 'employee_tax_exemption_declaration_category')
frappe.reload_doc('Payroll', 'doctype', 'employee_tax_exemption_proof_submission_detail')
frappe.reload_doc('accounts', 'doctype', 'tax_category')
for doctype in ["Sales Invoice", "Delivery Note", "Purchase Invoice"]:
frappe.db.sql("""delete from `tabCustom Field` where dt = %s
and fieldname in ('port_code', 'shipping_bill_number', 'shipping_bill_date')""", doctype)
make_custom_fields()
frappe.db.sql("""
update `tabCustom Field`
set reqd = 0, `default` = ''
where fieldname = 'reason_for_issuing_document'
""")
frappe.db.sql("""
update tabAddress
set gst_state_number=concat("0", gst_state_number)
where ifnull(gst_state_number, '') != '' and gst_state_number<10
""")

View File

@ -1268,7 +1268,7 @@ class SalarySlip(TransactionBase):
for i, earning in enumerate(self.earnings): for i, earning in enumerate(self.earnings):
if earning.salary_component == salary_component: if earning.salary_component == salary_component:
self.earnings[i].amount = wages_amount 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) self.net_pay = flt(self.gross_pay) - flt(self.total_deduction)
def compute_year_to_date(self): def compute_year_to_date(self):

View File

@ -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_status_notification_template', None)
frappe.db.set_value('HR Settings', None, 'leave_approval_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()) 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: if not holiday_list:
holiday_list = frappe.get_doc({ holiday_list = frappe.get_doc({
"doctype": "Holiday List", "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], "from_date": fiscal_year[1],
"to_date": fiscal_year[2], "to_date": fiscal_year[2],
"weekly_off": "Sunday" "weekly_off": "Sunday"

View File

@ -21,7 +21,7 @@ class TestHomepageSection(unittest.TestCase):
{'title': 'Card 2', 'subtitle': 'Subtitle 2', 'content': 'This is test card 2', 'image': 'test.jpg'}, {'title': 'Card 2', 'subtitle': 'Subtitle 2', 'content': 'This is test card 2', 'image': 'test.jpg'},
], ],
'no_of_columns': 3 'no_of_columns': 3
}).insert() }).insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError: except frappe.DuplicateEntryError:
pass pass

View File

@ -181,6 +181,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
fieldname: "journal_entry", fieldname: "journal_entry",
onchange: () => this.update_options(), onchange: () => this.update_options(),
}, },
{
fieldtype: "Check",
label: "Loan Repayment",
fieldname: "loan_repayment",
onchange: () => this.update_options(),
},
{ {
fieldname: "column_break_5", fieldname: "column_break_5",
fieldtype: "Column Break", fieldtype: "Column Break",
@ -191,13 +197,18 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
fieldname: "sales_invoice", fieldname: "sales_invoice",
onchange: () => this.update_options(), onchange: () => this.update_options(),
}, },
{ {
fieldtype: "Check", fieldtype: "Check",
label: "Purchase Invoice", label: "Purchase Invoice",
fieldname: "purchase_invoice", fieldname: "purchase_invoice",
onchange: () => this.update_options(), onchange: () => this.update_options(),
}, },
{
fieldtype: "Check",
label: "Show Only Exact Amount",
fieldname: "exact_match",
onchange: () => this.update_options(),
},
{ {
fieldname: "column_break_5", fieldname: "column_break_5",
fieldtype: "Column Break", fieldtype: "Column Break",
@ -210,8 +221,8 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
}, },
{ {
fieldtype: "Check", fieldtype: "Check",
label: "Show Only Exact Amount", label: "Loan Disbursement",
fieldname: "exact_match", fieldname: "loan_disbursement",
onchange: () => this.update_options(), onchange: () => this.update_options(),
}, },
{ {

View File

@ -525,6 +525,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
item.weight_per_unit = 0; item.weight_per_unit = 0;
item.weight_uom = ''; item.weight_uom = '';
item.conversion_factor = 0;
if(['Sales Invoice'].includes(this.frm.doc.doctype)) { if(['Sales Invoice'].includes(this.frm.doc.doctype)) {
update_stock = cint(me.frm.doc.update_stock); update_stock = cint(me.frm.doc.update_stock);
@ -719,6 +720,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
'posting_time': posting_time, 'posting_time': posting_time,
'qty': item.qty * item.conversion_factor, 'qty': item.qty * item.conversion_factor,
'serial_no': item.serial_no, 'serial_no': item.serial_no,
'batch_no': item.batch_no,
'voucher_type': voucher_type, 'voucher_type': voucher_type,
'company': company, 'company': company,
'allow_zero_valuation_rate': item.allow_zero_valuation_rate 'allow_zero_valuation_rate': item.allow_zero_valuation_rate

View File

@ -338,14 +338,14 @@ body.product-page {
.btn-add-to-wishlist { .btn-add-to-wishlist {
svg use { svg use {
stroke: #F47A7A; --icon-stroke: #F47A7A;
} }
} }
.btn-view-in-wishlist { .btn-view-in-wishlist {
svg use { svg use {
fill: #F47A7A; fill: #F47A7A;
stroke: none; --icon-stroke: none;
} }
} }
@ -1022,7 +1022,7 @@ body.product-page {
.not-wished { .not-wished {
cursor: pointer; cursor: pointer;
stroke: #F47A7A !important; --icon-stroke: #F47A7A !important;
&:hover { &:hover {
fill: #F47A7A; fill: #F47A7A;
@ -1030,7 +1030,7 @@ body.product-page {
} }
.wished { .wished {
stroke: none; --icon-stroke: none;
fill: #F47A7A !important; fill: #F47A7A !important;
} }

View File

@ -83,8 +83,8 @@
"planned_qty", "planned_qty",
"column_break_69", "column_break_69",
"work_order_qty", "work_order_qty",
"delivered_qty",
"produced_qty", "produced_qty",
"delivered_qty",
"returned_qty", "returned_qty",
"shopping_cart_section", "shopping_cart_section",
"additional_notes", "additional_notes",
@ -701,10 +701,8 @@
"width": "50px" "width": "50px"
}, },
{ {
"description": "For Production",
"fieldname": "produced_qty", "fieldname": "produced_qty",
"fieldtype": "Float", "fieldtype": "Float",
"hidden": 1,
"label": "Produced Quantity", "label": "Produced Quantity",
"oldfieldname": "produced_qty", "oldfieldname": "produced_qty",
"oldfieldtype": "Currency", "oldfieldtype": "Currency",
@ -802,7 +800,7 @@
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-10-05 12:27:25.014789", "modified": "2022-02-21 13:55:08.883104",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Sales Order Item", "name": "Sales Order Item",
@ -811,5 +809,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -180,14 +180,6 @@ erpnext.PointOfSale.Payment = class {
() => frm.save(), () => frm.save(),
() => this.update_totals_section(frm.doc) () => this.update_totals_section(frm.doc)
]); ]);
} else {
frappe.run_serially([
() => frm.doc.ignore_pricing_rule=1,
() => frm.trigger('ignore_pricing_rule'),
() => frm.doc.ignore_pricing_rule=0,
() => frm.save(),
() => this.update_totals_section(frm.doc)
]);
} }
} }
}); });

View File

@ -155,7 +155,7 @@ def insert_record(records):
doc = frappe.new_doc(r.get("doctype")) doc = frappe.new_doc(r.get("doctype"))
doc.update(r) doc.update(r)
try: try:
doc.insert(ignore_permissions=True) doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
except frappe.DuplicateEntryError as e: except frappe.DuplicateEntryError as e:
# pass DuplicateEntryError and continue # pass DuplicateEntryError and continue
if e.args and e.args[0]==doc.doctype and e.args[1]==doc.name: if e.args and e.args[0]==doc.doctype and e.args[1]==doc.name:

View File

@ -9,6 +9,8 @@
"field_order": [ "field_order": [
"sb_disabled", "sb_disabled",
"disabled", "disabled",
"column_break_24",
"use_batchwise_valuation",
"sb_batch", "sb_batch",
"batch_id", "batch_id",
"item", "item",
@ -186,6 +188,18 @@
"fieldtype": "Float", "fieldtype": "Float",
"label": "Produced Qty", "label": "Produced Qty",
"read_only": 1 "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", "icon": "fa fa-archive",
@ -193,10 +207,11 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"max_attachments": 5, "max_attachments": 5,
"modified": "2021-07-08 16:22:01.343105", "modified": "2022-02-21 08:08:23.999236",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Batch", "name": "Batch",
"naming_rule": "By fieldname",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@ -217,6 +232,7 @@
"quick_entry": 1, "quick_entry": 1,
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"title_field": "batch_id", "title_field": "batch_id",
"track_changes": 1 "track_changes": 1
} }

View File

@ -110,11 +110,18 @@ class Batch(Document):
def validate(self): def validate(self):
self.item_has_batch_enabled() self.item_has_batch_enabled()
self.set_batchwise_valuation()
def item_has_batch_enabled(self): def item_has_batch_enabled(self):
if frappe.db.get_value("Item", self.item, "has_batch_no") == 0: if frappe.db.get_value("Item", self.item, "has_batch_no") == 0:
frappe.throw(_("The selected item cannot have Batch")) 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): 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']) 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: if not self.expiry_date and has_expiry_date and shelf_life_in_days:

View File

@ -1,13 +1,21 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import json
import frappe import frappe
from frappe.exceptions import ValidationError from frappe.exceptions import ValidationError
from frappe.utils import cint, flt 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.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.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.get_item_details import get_item_details
from erpnext.stock.stock_ledger import get_valuation_rate
from erpnext.tests.utils import ERPNextTestCase from erpnext.tests.utils import ERPNextTestCase
@ -300,6 +308,105 @@ class TestBatch(ERPNextTestCase):
details = get_item_details(args) details = get_item_details(args)
self.assertEqual(details.get('price_list_rate'), 400) 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): def create_batch(item_code, rate, create_item_price_for_batch):
pi = make_purchase_invoice(company="_Test Company", pi = make_purchase_invoice(company="_Test Company",
warehouse= "Stores - _TC", cost_center = "Main - _TC", update_stock=1, 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): def make_new_batch(**args):
args = frappe._dict(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({ batch = frappe.get_doc({
"doctype": "Batch", "doctype": "Batch",
"batch_id": args.batch_id, "batch_id": args.batch_id,
"item": args.item_code, "item": args.item_code,
}).insert() }).insert()
except frappe.DuplicateEntryError:
batch = frappe.get_doc("Batch", args.batch_id)
return batch return batch

View File

@ -594,7 +594,7 @@ $.extend(erpnext.item, {
const increment = r.message.increment; const increment = r.message.increment;
let values = []; let values = [];
for(var i = from; i <= to; i += increment) { for(var i = from; i <= to; i = flt(i + increment, 6)) {
values.push(i); values.push(i);
} }
attr_val_fields[d.attribute] = values; attr_val_fields[d.attribute] = values;

View File

@ -398,6 +398,7 @@ class Item(Document):
if merge: if merge:
self.validate_properties_before_merge(new_name) 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) self.validate_duplicate_website_item_before_merge(old_name, new_name)
def after_rename(self, old_name, new_name, merge): 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]) msg += ": \n" + ", ".join([self.meta.get_label(fld) for fld in field_list])
frappe.throw(msg, title=_("Cannot Merge"), exc=DataValidationError) 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): 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. 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] 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) 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) frappe.throw(_(msg), title=_("Cannot Merge"), exc=DataValidationError)
def set_last_purchase_rate(self, new_name): def set_last_purchase_rate(self, new_name):

View File

@ -15,6 +15,7 @@ from erpnext.controllers.item_variant import (
get_variant, get_variant,
) )
from erpnext.stock.doctype.item.item import ( from erpnext.stock.doctype.item.item import (
DataValidationError,
InvalidBarcode, InvalidBarcode,
StockExistsForTemplate, StockExistsForTemplate,
get_item_attribute, get_item_attribute,
@ -388,6 +389,26 @@ class TestItem(ERPNextTestCase):
self.assertTrue(frappe.db.get_value("Bin", self.assertTrue(frappe.db.get_value("Bin",
{"item_code": "Test Item for Merging 2", "warehouse": "_Test Warehouse 1 - _TC"})) {"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): def test_uom_conversion_factor(self):
if frappe.db.exists('Item', 'Test Item UOM'): if frappe.db.exists('Item', 'Test Item UOM'):
frappe.delete_doc('Item', 'Test Item UOM') frappe.delete_doc('Item', 'Test Item UOM')

View File

@ -56,14 +56,13 @@ class MaterialRequest(BuyingController):
if actual_so_qty and (flt(so_items[so_no][item]) + already_indented > actual_so_qty): 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)) 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): def validate(self):
super(MaterialRequest, self).validate() super(MaterialRequest, self).validate()
self.validate_schedule_date() self.validate_schedule_date()
self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order') self.check_for_on_hold_or_closed_status('Sales Order', 'sales_order')
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_material_request_type()
if not self.status: if not self.status:
self.status = "Draft" 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_warehouse", "items", "warehouse")
self.reset_default_field_value("set_from_warehouse", "items", "from_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): def set_title(self):
'''Set title as comma separated list of items''' '''Set title as comma separated list of items'''
if not self.title: if not self.title:

View File

@ -1540,6 +1540,7 @@ def make_purchase_receipt(**args):
"conversion_factor": args.conversion_factor or 1.0, "conversion_factor": args.conversion_factor or 1.0,
"stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0), "stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0),
"serial_no": args.serial_no, "serial_no": args.serial_no,
"batch_no": args.batch_no,
"stock_uom": args.stock_uom or "_Test UOM", "stock_uom": args.stock_uom or "_Test UOM",
"uom": uom, "uom": uom,
"cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'), "cost_center": args.cost_center or frappe.get_cached_value('Company', pr.company, 'cost_center'),

View File

@ -425,6 +425,7 @@ frappe.ui.form.on('Stock Entry', {
'posting_time' : frm.doc.posting_time, 'posting_time' : frm.doc.posting_time,
'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse), 'warehouse' : cstr(item.s_warehouse) || cstr(item.t_warehouse),
'serial_no' : item.serial_no, 'serial_no' : item.serial_no,
'batch_no' : item.batch_no,
'company' : frm.doc.company, 'company' : frm.doc.company,
'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty), 'qty' : item.s_warehouse ? -1*flt(item.transfer_qty) : flt(item.transfer_qty),
'voucher_type' : frm.doc.doctype, 'voucher_type' : frm.doc.doctype,
@ -457,6 +458,7 @@ frappe.ui.form.on('Stock Entry', {
'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse), 'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse),
'transfer_qty': child.transfer_qty, 'transfer_qty': child.transfer_qty,
'serial_no': child.serial_no, 'serial_no': child.serial_no,
'batch_no': child.batch_no,
'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty, 'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty,
'posting_date': frm.doc.posting_date, 'posting_date': frm.doc.posting_date,
'posting_time': frm.doc.posting_time, '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), 'warehouse' : cstr(d.s_warehouse) || cstr(d.t_warehouse),
'transfer_qty' : d.transfer_qty, 'transfer_qty' : d.transfer_qty,
'serial_no' : d.serial_no, 'serial_no' : d.serial_no,
'batch_no' : d.batch_no,
'bom_no' : d.bom_no, 'bom_no' : d.bom_no,
'expense_account' : d.expense_account, 'expense_account' : d.expense_account,
'cost_center' : d.cost_center, 'cost_center' : d.cost_center,

View File

@ -510,7 +510,7 @@ class StockEntry(StockController):
d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse, d.basic_rate = get_valuation_rate(d.item_code, d.t_warehouse,
self.doctype, self.name, d.allow_zero_valuation_rate, self.doctype, self.name, d.allow_zero_valuation_rate,
currency=erpnext.get_company_currency(self.company), company=self.company, 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")) d.basic_rate = flt(d.basic_rate, d.precision("basic_rate"))
if d.is_process_loss: if d.is_process_loss:
@ -541,6 +541,7 @@ class StockEntry(StockController):
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty), "qty": item.s_warehouse and -1*flt(item.transfer_qty) or flt(item.transfer_qty),
"serial_no": item.serial_no, "serial_no": item.serial_no,
"batch_no": item.batch_no,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
"company": self.company, "company": self.company,

View File

@ -44,6 +44,7 @@ def get_sle(**args):
class TestStockEntry(ERPNextTestCase): class TestStockEntry(ERPNextTestCase):
def tearDown(self): def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator") frappe.set_user("Administrator")
frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0") frappe.db.set_value("Manufacturing Settings", None, "material_consumption", "0")
@ -565,6 +566,7 @@ class TestStockEntry(ERPNextTestCase):
st1.set_stock_entry_type() st1.set_stock_entry_type()
st1.insert() st1.insert()
st1.submit() st1.submit()
st1.cancel()
frappe.set_user("Administrator") frappe.set_user("Administrator")
remove_user_permission("Warehouse", "_Test Warehouse 1 - _TC", "test@example.com") 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", bom_no = frappe.db.get_value("BOM", {"item": "_Test Variant Item",
"is_default": 1, "docstatus": 1}) "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 = frappe.new_doc("Work Order")
work_order.update({ work_order.update({
"company": "_Test Company", "company": "_Test Company",
@ -1023,13 +1027,10 @@ class TestStockEntry(ERPNextTestCase):
# Check if FG cost is calculated based on RM total cost # Check if FG cost is calculated based on RM total cost
# RM total cost = 200, FG rate = 200/4(FG qty) = 50 # 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.value_difference, 0.0)
self.assertEqual(se.total_incoming_value, se.total_outgoing_value) self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
# teardown
se.delete()
@change_settings("Stock Settings", {"allow_negative_stock": 0}) @change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_future_negative_sle(self): def test_future_negative_sle(self):
# Initialize item, batch, warehouse, opening qty # Initialize item, batch, warehouse, opening qty
@ -1107,6 +1108,52 @@ class TestStockEntry(ERPNextTestCase):
posting_date='2021-09-02', # backdated consumption of 2nd batch posting_date='2021-09-02', # backdated consumption of 2nd batch
purpose='Material Issue') 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): def make_serialized_item(**args):
args = frappe._dict(args) args = frappe._dict(args)
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])

View File

@ -1,6 +1,10 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import json
from operator import itemgetter
from uuid import uuid4
import frappe import frappe
from frappe.core.page.permission_manager.permission_manager import reset from frappe.core.page.permission_manager.permission_manager import reset
from frappe.utils import add_days, today from frappe.utils import add_days, today
@ -349,6 +353,317 @@ class TestStockLedgerEntry(ERPNextTestCase):
frappe.set_user("Administrator") frappe.set_user("Administrator")
user.remove_roles("Stock Manager") 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): def create_repack_entry(**args):
args = frappe._dict(args) args = frappe._dict(args)
@ -412,3 +727,118 @@ def create_items():
make_item(d, properties=properties) make_item(d, properties=properties)
return items 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()

View File

@ -200,7 +200,6 @@ class TestStockReconciliation(ERPNextTestCase):
def test_stock_reco_for_batch_item(self): def test_stock_reco_for_batch_item(self):
to_delete_records = [] to_delete_records = []
to_delete_serial_nos = []
# Add new serial nos # Add new serial nos
item_code = "Stock-Reco-batch-Item-1" item_code = "Stock-Reco-batch-Item-1"
@ -208,20 +207,22 @@ class TestStockReconciliation(ERPNextTestCase):
sr = create_stock_reconciliation(item_code=item_code, sr = create_stock_reconciliation(item_code=item_code,
warehouse = warehouse, qty=5, rate=200, do_not_submit=1) warehouse = warehouse, qty=5, rate=200, do_not_submit=1)
sr.save(ignore_permissions=True) sr.save()
sr.submit() 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) to_delete_records.append(sr.name)
sr1 = create_stock_reconciliation(item_code=item_code, 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 = { args = {
"item_code": item_code, "item_code": item_code,
"warehouse": warehouse, "warehouse": warehouse,
"posting_date": nowdate(), "posting_date": nowdate(),
"posting_time": nowtime(), "posting_time": nowtime(),
"batch_no": batch_no,
} }
valuation_rate = get_incoming_rate(args) valuation_rate = get_incoming_rate(args)
@ -230,7 +231,7 @@ class TestStockReconciliation(ERPNextTestCase):
sr2 = create_stock_reconciliation(item_code=item_code, 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) stock_value = get_stock_value_on(warehouse, nowdate(), item_code)
self.assertEqual(stock_value, 0) self.assertEqual(stock_value, 0)

View File

@ -60,6 +60,9 @@ def add_invariant_check_fields(sles):
fifo_qty += qty fifo_qty += qty
fifo_value += qty * rate 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_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: 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_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
sle.fifo_difference_diff = sle.fifo_stock_diff - sle.stock_value_difference 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 return sles
@ -134,6 +140,11 @@ def get_columns():
"label": "Batch", "label": "Batch",
"options": "Batch", "options": "Batch",
}, },
{
"fieldname": "use_batchwise_valuation",
"fieldtype": "Check",
"label": "Batchwise Valuation",
},
{ {
"fieldname": "actual_qty", "fieldname": "actual_qty",
"fieldtype": "Float", "fieldtype": "Float",
@ -145,9 +156,9 @@ def get_columns():
"label": "Incoming Rate", "label": "Incoming Rate",
}, },
{ {
"fieldname": "outgoing_rate", "fieldname": "consumption_rate",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Outgoing Rate", "label": "Consumption Rate",
}, },
{ {
"fieldname": "qty_after_transaction", "fieldname": "qty_after_transaction",

View File

@ -73,6 +73,7 @@ class TestReports(unittest.TestCase):
def test_execute_all_stock_reports(self): def test_execute_all_stock_reports(self):
"""Test that all script report in stock modules are executable with supported filters""" """Test that all script report in stock modules are executable with supported filters"""
for report, filter in REPORT_FILTER_TEST_CASES: for report, filter in REPORT_FILTER_TEST_CASES:
with self.subTest(report=report):
execute_script_report( execute_script_report(
report_name=report, report_name=report,
module="Stock", module="Stock",

View 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.

View 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

View File

@ -8,7 +8,9 @@ from typing import Optional
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder.functions import Sum
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate from frappe.utils import cint, cstr, flt, get_link_to_form, getdate, now, nowdate
from pypika import CustomFunction
import erpnext import erpnext
from erpnext.stock.doctype.bin.bin import update_qty as update_bin_qty 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_or_make_bin,
get_valuation_method, 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 NegativeStockError(frappe.ValidationError): pass
class SerialNoExistsInFutureTransaction(frappe.ValidationError): class SerialNoExistsInFutureTransaction(frappe.ValidationError):
pass pass
_exceptions = frappe.local('stockledger_exceptions')
def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False): def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_voucher=False):
from erpnext.controllers.stock_controller import future_sle_exists 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.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) 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: else:
if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no: if sle.voucher_type=="Stock Reconciliation" and not sle.batch_no:
# assert # 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) self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(self.wh_data.valuation_rate)
else: else:
self.update_queue_values(sle) self.update_queue_values(sle)
self.wh_data.qty_after_transaction += flt(sle.actual_qty)
# rounding as per precision # rounding as per precision
self.wh_data.stock_value = flt(self.wh_data.stock_value, self.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 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 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"): if not self.args.get("sle_id"):
self.update_outgoing_rate_on_transaction(sle) self.update_outgoing_rate_on_transaction(sle)
def validate_negative_stock(self, sle): def validate_negative_stock(self, sle):
""" """
validate negative stock for entries current datetime onwards 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: 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) allow_zero_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_rate: if not allow_zero_rate:
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, self.wh_data.valuation_rate = self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
def get_incoming_value_for_serial_nos(self, sle, serial_nos): def get_incoming_value_for_serial_nos(self, sle, serial_nos):
# get rate from serial nos within same company # 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: 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) allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, sle.voucher_detail_no)
if not allow_zero_valuation_rate: if not allow_zero_valuation_rate:
self.wh_data.valuation_rate = get_valuation_rate(sle.item_code, sle.warehouse, self.wh_data.valuation_rate = self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
def update_queue_values(self, sle): def update_queue_values(self, sle):
incoming_rate = flt(sle.incoming_rate) incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty) actual_qty = flt(sle.actual_qty)
outgoing_rate = flt(sle.outgoing_rate) 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": if self.valuation_method == "LIFO":
stock_queue = LIFOValuation(self.wh_data.stock_queue) stock_queue = LIFOValuation(self.wh_data.stock_queue)
else: else:
stock_queue = FIFOValuation(self.wh_data.stock_queue) stock_queue = FIFOValuation(self.wh_data.stock_queue)
_prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value()
if actual_qty > 0: if actual_qty > 0:
stock_queue.add_stock(qty=actual_qty, rate=incoming_rate) stock_queue.add_stock(qty=actual_qty, rate=incoming_rate)
else: else:
def rate_generator() -> float: def rate_generator() -> float:
allow_zero_valuation_rate = self.check_if_allow_zero_valuation_rate(sle.voucher_type, 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: if not allow_zero_valuation_rate:
return get_valuation_rate(sle.item_code, sle.warehouse, return self.get_fallback_rate(sle)
sle.voucher_type, sle.voucher_no, self.allow_zero_rate,
currency=erpnext.get_company_currency(sle.company), company=sle.company)
else: else:
return 0.0 return 0.0
stock_queue.remove_stock(qty=abs(actual_qty), outgoing_rate=outgoing_rate, rate_generator=rate_generator) 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_queue = stock_queue.state
self.wh_data.stock_value = stock_value self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + stock_value_difference)
if stock_qty:
self.wh_data.valuation_rate = stock_value / stock_qty
if not self.wh_data.stock_queue: 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]) 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): def check_if_allow_zero_valuation_rate(self, voucher_type, voucher_detail_no):
ref_item_dt = "" ref_item_dt = ""
@ -751,6 +778,13 @@ class update_entries_after(object):
else: else:
return 0 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): def get_sle_before_datetime(self, args):
"""get previous stock ledger entry before current time-bucket""" """get previous stock ledger entry before current time-bucket"""
sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False) sle = get_stock_ledger_entries(args, "<", "desc", "limit 1", for_update=False)
@ -897,13 +931,63 @@ 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'], ['item_code', 'warehouse', 'posting_date', 'posting_time', 'timestamp(posting_date, posting_time) as timestamp'],
as_dict=1) 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, 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: if not company:
company = frappe.get_cached_value("Warehouse", warehouse, "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 # Get valuation rate from last sle for the same item and warehouse
if not last_valuation_rate or last_valuation_rate[0][0] is None:
last_valuation_rate = frappe.db.sql("""select valuation_rate last_valuation_rate = frappe.db.sql("""select valuation_rate
from `tabStock Ledger Entry` force index (item_warehouse) from `tabStock Ledger Entry` force index (item_warehouse)
where where

View File

@ -7,7 +7,7 @@ from hypothesis import strategies as st
from erpnext.stock.doctype.item.test_item import make_item 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.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 from erpnext.tests.utils import ERPNextTestCase
qty_gen = st.floats(min_value=-1e6, max_value=1e6) qty_gen = st.floats(min_value=-1e6, max_value=1e6)
@ -113,11 +113,11 @@ class TestFIFOValuation(unittest.TestCase):
self.assertTotalQty(0) self.assertTotalQty(0)
def test_rounding_off_near_zero(self): def test_rounding_off_near_zero(self):
self.assertEqual(_round_off_if_near_zero(0), 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(-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(1e-8), 0) self.assertEqual(round_off_if_near_zero(1e-8), 0)
def test_totals(self): def test_totals(self):
self.queue.add_stock(1, 10) self.queue.add_stock(1, 10)

View File

@ -209,13 +209,28 @@ def _create_bin(item_code, warehouse):
@frappe.whitelist() @frappe.whitelist()
def get_incoming_rate(args, raise_error_if_no_rate=True): def get_incoming_rate(args, raise_error_if_no_rate=True):
"""Get Incoming Rate based on valuation method""" """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): if isinstance(args, str):
args = json.loads(args) 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(): if (args.get("serial_no") or "").strip():
in_rate = get_avg_purchase_rate(args.get("serial_no")) 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: else:
valuation_method = get_valuation_method(args.get("item_code")) valuation_method = get_valuation_method(args.get("item_code"))
previous_sle = get_previous_sle(args) 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': elif valuation_method == 'Moving Average':
in_rate = previous_sle.get('valuation_rate') or 0 in_rate = previous_sle.get('valuation_rate') or 0
if not in_rate: if in_rate is None:
voucher_no = args.get('voucher_no') or args.get('name')
in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'), in_rate = get_valuation_rate(args.get('item_code'), args.get('warehouse'),
args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'), args.get('voucher_type'), voucher_no, args.get('allow_zero_valuation'),
currency=erpnext.get_company_currency(args.get('company')), company=args.get('company'), 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) return flt(in_rate)
@ -247,7 +261,7 @@ def get_valuation_method(item_code):
"""get valuation method from item or default""" """get valuation method from item or default"""
val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True) val_method = frappe.db.get_value('Item', item_code, 'valuation_method', cache=True)
if not val_method: 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 return val_method
def get_fifo_rate(previous_stock_queue, qty): def get_fifo_rate(previous_stock_queue, qty):

View File

@ -34,7 +34,7 @@ class BinWiseValuation(ABC):
total_qty += flt(qty) total_qty += flt(qty)
total_value += flt(qty) * flt(rate) 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): def __repr__(self):
return str(self.state) return str(self.state)
@ -136,7 +136,7 @@ class FIFOValuation(BinWiseValuation):
fifo_bin = self.queue[index] fifo_bin = self.queue[index]
if qty >= fifo_bin[QTY]: if qty >= fifo_bin[QTY]:
# consume current bin # 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) to_consume = self.queue.pop(index)
consumed_bins.append(list(to_consume)) consumed_bins.append(list(to_consume))
@ -148,7 +148,7 @@ class FIFOValuation(BinWiseValuation):
break break
else: else:
# qty found in current bin consume it and exit # 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]]) consumed_bins.append([qty, fifo_bin[RATE]])
qty = 0 qty = 0
@ -231,7 +231,7 @@ class LIFOValuation(BinWiseValuation):
stock_bin = self.stack[index] stock_bin = self.stack[index]
if qty >= stock_bin[QTY]: if qty >= stock_bin[QTY]:
# consume current bin # 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) to_consume = self.stack.pop(index)
consumed_bins.append(list(to_consume)) consumed_bins.append(list(to_consume))
@ -243,14 +243,14 @@ class LIFOValuation(BinWiseValuation):
break break
else: else:
# qty found in current bin consume it and exit # 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]]) consumed_bins.append([qty, stock_bin[RATE]])
qty = 0 qty = 0
return consumed_bins 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 """Rounds off the number to zero only if number is close to zero for decimal
specified in precision. Precision defaults to 7. specified in precision. Precision defaults to 7.
""" """

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