mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-14 04:15:10 +00:00
Merge branch 'version-13-pre-release' into version-13
This commit is contained in:
@@ -5,7 +5,7 @@ import frappe
|
||||
|
||||
from erpnext.hooks import regional_overrides
|
||||
|
||||
__version__ = '13.17.0'
|
||||
__version__ = '13.18.0'
|
||||
|
||||
def get_default_company(user=None):
|
||||
'''Get default company for user'''
|
||||
@@ -56,9 +56,9 @@ def set_perpetual_inventory(enable=1, company=None):
|
||||
company.enable_perpetual_inventory = enable
|
||||
company.save()
|
||||
|
||||
def encode_company_abbr(name, company):
|
||||
def encode_company_abbr(name, company=None, abbr=None):
|
||||
'''Returns name encoded with company abbreviation'''
|
||||
company_abbr = frappe.get_cached_value('Company', company, "abbr")
|
||||
company_abbr = abbr or frappe.get_cached_value('Company', company, "abbr")
|
||||
parts = name.rsplit(" - ", 1)
|
||||
|
||||
if parts[-1].lower() != company_abbr.lower():
|
||||
|
||||
@@ -7,7 +7,7 @@ frappe.ui.form.on("Bank Reconciliation Tool", {
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: ["in", frm.doc.company],
|
||||
company: frm.doc.company,
|
||||
'is_company_account': 1
|
||||
},
|
||||
};
|
||||
|
||||
@@ -218,6 +218,8 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = json.loads(vouchers)
|
||||
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name)
|
||||
company_account = frappe.db.get_value('Bank Account', transaction.bank_account, 'account')
|
||||
|
||||
if transaction.unallocated_amount == 0:
|
||||
frappe.throw(_("This bank transaction is already fully reconciled"))
|
||||
total_amount = 0
|
||||
@@ -226,7 +228,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
|
||||
total_amount += get_paid_amount(frappe._dict({
|
||||
'payment_document': voucher['payment_doctype'],
|
||||
'payment_entry': voucher['payment_name'],
|
||||
}), transaction.currency)
|
||||
}), transaction.currency, company_account)
|
||||
|
||||
if total_amount > transaction.unallocated_amount:
|
||||
frappe.throw(_("The Sum Total of Amounts of All Selected Vouchers Should be Less than the Unallocated Amount of the Bank Transaction"))
|
||||
@@ -261,7 +263,7 @@ def get_linked_payments(bank_transaction_name, document_types = None):
|
||||
return matching
|
||||
|
||||
def check_matching(bank_account, company, transaction, document_types):
|
||||
# combine all types of vocuhers
|
||||
# combine all types of vouchers
|
||||
subquery = get_queries(bank_account, company, transaction, document_types)
|
||||
filters = {
|
||||
"amount": transaction.unallocated_amount,
|
||||
@@ -343,13 +345,11 @@ def get_pe_matching_query(amount_condition, account_from_to, transaction):
|
||||
def get_je_matching_query(amount_condition, transaction):
|
||||
# get matching journal entry query
|
||||
|
||||
# We have mapping at the bank level
|
||||
# So one bank could have both types of bank accounts like asset and liability
|
||||
# So cr_or_dr should be judged only on basis of withdrawal and deposit and not account type
|
||||
company_account = frappe.get_value("Bank Account", transaction.bank_account, "account")
|
||||
root_type = frappe.get_value("Account", company_account, "root_type")
|
||||
|
||||
if root_type == "Liability":
|
||||
cr_or_dr = "debit" if transaction.withdrawal > 0 else "credit"
|
||||
else:
|
||||
cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
|
||||
cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
|
||||
|
||||
return f"""
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ def get_total_allocated_amount(payment_entry):
|
||||
AND
|
||||
bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True)
|
||||
|
||||
def get_paid_amount(payment_entry, currency):
|
||||
def get_paid_amount(payment_entry, currency, bank_account):
|
||||
if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
|
||||
|
||||
paid_amount_field = "paid_amount"
|
||||
@@ -115,7 +115,7 @@ def get_paid_amount(payment_entry, currency):
|
||||
payment_entry.payment_entry, paid_amount_field)
|
||||
|
||||
elif payment_entry.payment_document == "Journal Entry":
|
||||
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_credit")
|
||||
return frappe.db.get_value('Journal Entry Account', {'parent': payment_entry.payment_entry, 'account': bank_account}, "sum(credit_in_account_currency)")
|
||||
|
||||
elif payment_entry.payment_document == "Expense Claim":
|
||||
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed")
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"issingle": 1,
|
||||
"modified": "2019-07-25 14:57:33.187689",
|
||||
"modified": "2022-01-04 13:40:15.927675",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool",
|
||||
|
||||
@@ -159,7 +159,8 @@ class OpeningInvoiceCreationTool(Document):
|
||||
frappe.scrub(row.party_type): row.party,
|
||||
"is_pos": 0,
|
||||
"doctype": "Sales Invoice" if self.invoice_type == "Sales" else "Purchase Invoice",
|
||||
"update_stock": 0
|
||||
"update_stock": 0,
|
||||
"invoice_number": row.invoice_number
|
||||
})
|
||||
|
||||
accounting_dimension = get_accounting_dimensions()
|
||||
@@ -200,10 +201,13 @@ def start_import(invoices):
|
||||
names = []
|
||||
for idx, d in enumerate(invoices):
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
invoice_number = d.invoice_number
|
||||
publish(idx, len(invoices), d.doctype)
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.insert()
|
||||
doc.insert(set_name=invoice_number)
|
||||
doc.submit()
|
||||
frappe.db.commit()
|
||||
names.append(doc.name)
|
||||
|
||||
@@ -18,10 +18,10 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
|
||||
if not frappe.db.exists("Company", "_Test Opening Invoice Company"):
|
||||
make_company()
|
||||
|
||||
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None):
|
||||
def make_invoices(self, invoice_type="Sales", company=None, party_1=None, party_2=None, invoice_number=None):
|
||||
doc = frappe.get_single("Opening Invoice Creation Tool")
|
||||
args = get_opening_invoice_creation_dict(invoice_type=invoice_type, company=company,
|
||||
party_1=party_1, party_2=party_2)
|
||||
party_1=party_1, party_2=party_2, invoice_number=invoice_number)
|
||||
doc.update(args)
|
||||
return doc.make_invoices()
|
||||
|
||||
@@ -92,6 +92,20 @@ class TestOpeningInvoiceCreationTool(unittest.TestCase):
|
||||
# teardown
|
||||
frappe.db.set_value("Company", company, "default_receivable_account", old_default_receivable_account)
|
||||
|
||||
def test_renaming_of_invoice_using_invoice_number_field(self):
|
||||
company = "_Test Opening Invoice Company"
|
||||
party_1, party_2 = make_customer("Customer A"), make_customer("Customer B")
|
||||
self.make_invoices(company=company, party_1=party_1, party_2=party_2, invoice_number="TEST-NEW-INV-11")
|
||||
|
||||
sales_inv1 = frappe.get_all('Sales Invoice', filters={'customer':'Customer A'})[0].get("name")
|
||||
sales_inv2 = frappe.get_all('Sales Invoice', filters={'customer':'Customer B'})[0].get("name")
|
||||
self.assertEqual(sales_inv1, "TEST-NEW-INV-11")
|
||||
|
||||
#teardown
|
||||
for inv in [sales_inv1, sales_inv2]:
|
||||
doc = frappe.get_doc('Sales Invoice', inv)
|
||||
doc.cancel()
|
||||
|
||||
def get_opening_invoice_creation_dict(**args):
|
||||
party = "Customer" if args.get("invoice_type", "Sales") == "Sales" else "Supplier"
|
||||
company = args.get("company", "_Test Company")
|
||||
@@ -107,7 +121,8 @@ def get_opening_invoice_creation_dict(**args):
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company)
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": args.get("invoice_number")
|
||||
},
|
||||
{
|
||||
"qty": 2.0,
|
||||
@@ -116,7 +131,8 @@ def get_opening_invoice_creation_dict(**args):
|
||||
"item_name": "Opening Item",
|
||||
"due_date": "2016-09-10",
|
||||
"posting_date": "2016-09-05",
|
||||
"temporary_opening_account": get_temporary_opening_account(company)
|
||||
"temporary_opening_account": get_temporary_opening_account(company),
|
||||
"invoice_number": None
|
||||
}
|
||||
]
|
||||
})
|
||||
@@ -132,7 +148,7 @@ def make_company():
|
||||
company.company_name = "_Test Opening Invoice Company"
|
||||
company.abbr = "_TOIC"
|
||||
company.default_currency = "INR"
|
||||
company.country = "India"
|
||||
company.country = "Pakistan"
|
||||
company.insert()
|
||||
return company
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"actions": [],
|
||||
"creation": "2017-08-29 04:26:36.159247",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"invoice_number",
|
||||
"party_type",
|
||||
"party",
|
||||
"temporary_opening_account",
|
||||
@@ -103,10 +105,18 @@
|
||||
{
|
||||
"fieldname": "dimension_col_break",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Reference number of the invoice from the previous system",
|
||||
"fieldname": "invoice_number",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Invoice Number"
|
||||
}
|
||||
],
|
||||
"istable": 1,
|
||||
"modified": "2019-07-25 15:00:00.460695",
|
||||
"links": [],
|
||||
"modified": "2022-01-04 18:40:15.927675",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
@@ -116,4 +126,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -963,7 +963,7 @@ class TestPurchaseInvoice(unittest.TestCase):
|
||||
|
||||
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
|
||||
pi.set_posting_time = 1
|
||||
pi.posting_date = '2019-03-15'
|
||||
pi.posting_date = '2019-01-10'
|
||||
pi.items[0].enable_deferred_expense = 1
|
||||
pi.items[0].service_start_date = "2019-01-10"
|
||||
pi.items[0].service_end_date = "2019-03-15"
|
||||
@@ -1213,7 +1213,7 @@ def check_gl_entries(doc, voucher_no, expected_gle, posting_date):
|
||||
def update_tax_witholding_category(company, account):
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
fiscal_year = get_fiscal_year(fiscal_year='_Test Fiscal Year 2021')
|
||||
fiscal_year = get_fiscal_year(date=nowdate())
|
||||
|
||||
if not frappe.db.get_value('Tax Withholding Rate',
|
||||
{'parent': 'TDS - 194 - Dividends - Individual', 'from_date': ('>=', fiscal_year[1]),
|
||||
|
||||
@@ -24,6 +24,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.accounts.party import get_party_account_currency
|
||||
|
||||
|
||||
class Subscription(Document):
|
||||
@@ -356,7 +357,10 @@ class Subscription(Document):
|
||||
if frappe.db.get_value('Supplier', self.party, 'tax_withholding_category'):
|
||||
invoice.apply_tds = 1
|
||||
|
||||
## Add dimensions in invoice for subscription:
|
||||
# Add party currency to invoice
|
||||
invoice.currency = get_party_account_currency(self.party_type, self.party, self.company)
|
||||
|
||||
# Add dimensions in invoice for subscription:
|
||||
accounting_dimensions = get_accounting_dimensions()
|
||||
|
||||
for dimension in accounting_dimensions:
|
||||
|
||||
@@ -60,15 +60,38 @@ def create_plan():
|
||||
plan.billing_interval_count = 3
|
||||
plan.insert()
|
||||
|
||||
if not frappe.db.exists('Subscription Plan', '_Test Plan Multicurrency'):
|
||||
plan = frappe.new_doc('Subscription Plan')
|
||||
plan.plan_name = '_Test Plan Multicurrency'
|
||||
plan.item = '_Test Non Stock Item'
|
||||
plan.price_determination = "Fixed Rate"
|
||||
plan.cost = 50
|
||||
plan.currency = 'USD'
|
||||
plan.billing_interval = 'Month'
|
||||
plan.billing_interval_count = 1
|
||||
plan.insert()
|
||||
|
||||
def create_parties():
|
||||
if not frappe.db.exists('Supplier', '_Test Supplier'):
|
||||
supplier = frappe.new_doc('Supplier')
|
||||
supplier.supplier_name = '_Test Supplier'
|
||||
supplier.supplier_group = 'All Supplier Groups'
|
||||
supplier.insert()
|
||||
|
||||
if not frappe.db.exists('Customer', '_Test Subscription Customer'):
|
||||
customer = frappe.new_doc('Customer')
|
||||
customer.customer_name = '_Test Subscription Customer'
|
||||
customer.billing_currency = 'USD'
|
||||
customer.append('accounts', {
|
||||
'company': '_Test Company',
|
||||
'account': '_Test Receivable USD - _TC'
|
||||
})
|
||||
customer.insert()
|
||||
|
||||
class TestSubscription(unittest.TestCase):
|
||||
def setUp(self):
|
||||
create_plan()
|
||||
create_parties()
|
||||
|
||||
def test_create_subscription_with_trial_with_correct_period(self):
|
||||
subscription = frappe.new_doc('Subscription')
|
||||
@@ -637,3 +660,22 @@ class TestSubscription(unittest.TestCase):
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
|
||||
def test_multicurrency_subscription(self):
|
||||
subscription = frappe.new_doc('Subscription')
|
||||
subscription.party_type = 'Customer'
|
||||
subscription.party = '_Test Subscription Customer'
|
||||
subscription.generate_invoice_at_period_start = 1
|
||||
subscription.company = '_Test Company'
|
||||
# select subscription start date as '2018-01-15'
|
||||
subscription.start_date = '2018-01-01'
|
||||
subscription.append('plans', {'plan': '_Test Plan Multicurrency', 'qty': 1})
|
||||
subscription.save()
|
||||
|
||||
subscription.process()
|
||||
self.assertEqual(len(subscription.invoices), 1)
|
||||
self.assertEqual(subscription.status, 'Unpaid')
|
||||
|
||||
# Check the currency of the created invoice
|
||||
currency = frappe.db.get_value('Sales Invoice', subscription.invoices[0].invoice, 'currency')
|
||||
self.assertEqual(currency, 'USD')
|
||||
@@ -75,7 +75,8 @@
|
||||
"fieldname": "cost",
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Cost"
|
||||
"label": "Cost",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.price_determination==\"Based On Price List\"",
|
||||
@@ -147,7 +148,7 @@
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2021-08-13 10:53:44.205774",
|
||||
"modified": "2021-12-10 15:24:15.794477",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscription Plan",
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
/* eslint-disable */
|
||||
|
||||
function get_filters() {
|
||||
let filters = [
|
||||
{
|
||||
"fieldname":"company",
|
||||
"label": __("Company"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Company",
|
||||
"default": frappe.defaults.get_user_default("Company"),
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"filter_based_on",
|
||||
"label": __("Filter Based On"),
|
||||
"fieldtype": "Select",
|
||||
"options": ["Fiscal Year", "Date Range"],
|
||||
"default": ["Fiscal Year"],
|
||||
"reqd": 1,
|
||||
on_change: function() {
|
||||
let filter_based_on = frappe.query_report.get_filter_value('filter_based_on');
|
||||
frappe.query_report.toggle_filter_display('from_fiscal_year', filter_based_on === 'Date Range');
|
||||
frappe.query_report.toggle_filter_display('to_fiscal_year', filter_based_on === 'Date Range');
|
||||
frappe.query_report.toggle_filter_display('period_start_date', filter_based_on === 'Fiscal Year');
|
||||
frappe.query_report.toggle_filter_display('period_end_date', filter_based_on === 'Fiscal Year');
|
||||
|
||||
frappe.query_report.refresh();
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldname":"period_start_date",
|
||||
"label": __("Start Date"),
|
||||
"fieldtype": "Date",
|
||||
"hidden": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"period_end_date",
|
||||
"label": __("End Date"),
|
||||
"fieldtype": "Date",
|
||||
"hidden": 1,
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"from_fiscal_year",
|
||||
"label": __("Start Year"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Fiscal Year",
|
||||
"default": frappe.defaults.get_user_default("fiscal_year"),
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname":"to_fiscal_year",
|
||||
"label": __("End Year"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Fiscal Year",
|
||||
"default": frappe.defaults.get_user_default("fiscal_year"),
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "periodicity",
|
||||
"label": __("Periodicity"),
|
||||
"fieldtype": "Select",
|
||||
"options": [
|
||||
{ "value": "Monthly", "label": __("Monthly") },
|
||||
{ "value": "Quarterly", "label": __("Quarterly") },
|
||||
{ "value": "Half-Yearly", "label": __("Half-Yearly") },
|
||||
{ "value": "Yearly", "label": __("Yearly") }
|
||||
],
|
||||
"default": "Monthly",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "type",
|
||||
"label": __("Invoice Type"),
|
||||
"fieldtype": "Select",
|
||||
"options": [
|
||||
{ "value": "Revenue", "label": __("Revenue") },
|
||||
{ "value": "Expense", "label": __("Expense") }
|
||||
],
|
||||
"default": "Revenue",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname" : "with_upcoming_postings",
|
||||
"label": __("Show with upcoming revenue/expense"),
|
||||
"fieldtype": "Check",
|
||||
"default": 1
|
||||
}
|
||||
]
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
frappe.query_reports["Deferred Revenue and Expense"] = {
|
||||
"filters": get_filters(),
|
||||
"formatter": function(value, row, column, data, default_formatter){
|
||||
return default_formatter(value, row, column, data);
|
||||
},
|
||||
onload: function(report){
|
||||
let fiscal_year = frappe.defaults.get_user_default("fiscal_year");
|
||||
|
||||
frappe.model.with_doc("Fiscal Year", fiscal_year, function(r) {
|
||||
var fy = frappe.model.get_doc("Fiscal Year", fiscal_year);
|
||||
frappe.query_report.set_filter_value({
|
||||
period_start_date: fy.year_start_date,
|
||||
period_end_date: fy.year_end_date
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"add_total_row": 0,
|
||||
"columns": [],
|
||||
"creation": "2021-12-10 19:27:14.654220",
|
||||
"disable_prepared_report": 0,
|
||||
"disabled": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "Report",
|
||||
"filters": [],
|
||||
"idx": 0,
|
||||
"is_standard": "Yes",
|
||||
"modified": "2021-12-10 19:27:14.654220",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Deferred Revenue and Expense",
|
||||
"owner": "Administrator",
|
||||
"prepared_report": 0,
|
||||
"ref_doctype": "GL Entry",
|
||||
"report_name": "Deferred Revenue and Expense",
|
||||
"report_type": "Script Report",
|
||||
"roles": [
|
||||
{
|
||||
"role": "Accounts User"
|
||||
},
|
||||
{
|
||||
"role": "Accounts Manager"
|
||||
},
|
||||
{
|
||||
"role": "Auditor"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# License: MIT. See LICENSE
|
||||
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Column, functions
|
||||
from frappe.utils import add_days, date_diff, flt, get_first_day, get_last_day, rounded
|
||||
|
||||
from erpnext.accounts.report.financial_statements import get_period_list
|
||||
|
||||
|
||||
class Deferred_Item(object):
|
||||
"""
|
||||
Helper class for processing items with deferred revenue/expense
|
||||
"""
|
||||
|
||||
def __init__(self, item, inv, gle_entries):
|
||||
self.name = item
|
||||
self.parent = inv.name
|
||||
self.item_name = gle_entries[0].item_name
|
||||
self.service_start_date = gle_entries[0].service_start_date
|
||||
self.service_end_date = gle_entries[0].service_end_date
|
||||
self.base_net_amount = gle_entries[0].base_net_amount
|
||||
self.filters = inv.filters
|
||||
self.period_list = inv.period_list
|
||||
|
||||
if gle_entries[0].deferred_revenue_account:
|
||||
self.type = "Deferred Sale Item"
|
||||
self.deferred_account = gle_entries[0].deferred_revenue_account
|
||||
elif gle_entries[0].deferred_expense_account:
|
||||
self.type = "Deferred Purchase Item"
|
||||
self.deferred_account = gle_entries[0].deferred_expense_account
|
||||
|
||||
self.gle_entries = []
|
||||
# holds period wise total for item
|
||||
self.period_total = []
|
||||
self.last_entry_date = self.service_start_date
|
||||
|
||||
if gle_entries:
|
||||
self.gle_entries = gle_entries
|
||||
for x in self.gle_entries:
|
||||
if self.get_amount(x):
|
||||
self.last_entry_date = x.gle_posting_date
|
||||
|
||||
def report_data(self):
|
||||
"""
|
||||
Generate report data for output
|
||||
"""
|
||||
ret_data = frappe._dict({"name": self.item_name})
|
||||
for period in self.period_total:
|
||||
ret_data[period.key] = period.total
|
||||
ret_data.indent = 1
|
||||
return ret_data
|
||||
|
||||
def get_amount(self, entry):
|
||||
"""
|
||||
For a given GL/Journal posting, get balance based on item type
|
||||
"""
|
||||
if self.type == "Deferred Sale Item":
|
||||
return entry.debit - entry.credit
|
||||
elif self.type == "Deferred Purchase Item":
|
||||
return -(entry.credit - entry.debit)
|
||||
return 0
|
||||
|
||||
def get_item_total(self):
|
||||
"""
|
||||
Helper method - calculate booked amount. Includes simulated postings as well
|
||||
"""
|
||||
total = 0
|
||||
for gle_posting in self.gle_entries:
|
||||
total += self.get_amount(gle_posting)
|
||||
|
||||
return total
|
||||
|
||||
def calculate_amount(self, start_date, end_date):
|
||||
"""
|
||||
start_date, end_date - datetime.datetime.date
|
||||
return - estimated amount to post for given period
|
||||
Calculated based on already booked amount and item service period
|
||||
"""
|
||||
total_months = (
|
||||
(self.service_end_date.year - self.service_start_date.year) * 12
|
||||
+ (self.service_end_date.month - self.service_start_date.month)
|
||||
+ 1
|
||||
)
|
||||
|
||||
prorate = date_diff(self.service_end_date, self.service_start_date) / date_diff(
|
||||
get_last_day(self.service_end_date), get_first_day(self.service_start_date)
|
||||
)
|
||||
|
||||
actual_months = rounded(total_months * prorate, 1)
|
||||
|
||||
already_booked_amount = self.get_item_total()
|
||||
base_amount = self.base_net_amount / actual_months
|
||||
|
||||
if base_amount + already_booked_amount > self.base_net_amount:
|
||||
base_amount = self.base_net_amount - already_booked_amount
|
||||
|
||||
if not (get_first_day(start_date) == start_date and get_last_day(end_date) == end_date):
|
||||
partial_month = flt(date_diff(end_date, start_date)) / flt(
|
||||
date_diff(get_last_day(end_date), get_first_day(start_date))
|
||||
)
|
||||
base_amount *= rounded(partial_month, 1)
|
||||
|
||||
return base_amount
|
||||
|
||||
def make_dummy_gle(self, name, date, amount):
|
||||
"""
|
||||
return - frappe._dict() of a dummy gle entry
|
||||
"""
|
||||
entry = frappe._dict(
|
||||
{"name": name, "gle_posting_date": date, "debit": 0, "credit": 0, "posted": "not"}
|
||||
)
|
||||
if self.type == "Deferred Sale Item":
|
||||
entry.debit = amount
|
||||
elif self.type == "Deferred Purchase Item":
|
||||
entry.credit = amount
|
||||
return entry
|
||||
|
||||
def simulate_future_posting(self):
|
||||
"""
|
||||
simulate future posting by creating dummy gl entries. starts from the last posting date.
|
||||
"""
|
||||
if add_days(self.last_entry_date, 1) < self.period_list[-1].to_date:
|
||||
self.estimate_for_period_list = get_period_list(
|
||||
self.filters.from_fiscal_year,
|
||||
self.filters.to_fiscal_year,
|
||||
add_days(self.last_entry_date, 1),
|
||||
self.period_list[-1].to_date,
|
||||
"Date Range",
|
||||
"Monthly",
|
||||
company=self.filters.company,
|
||||
)
|
||||
for period in self.estimate_for_period_list:
|
||||
amount = self.calculate_amount(period.from_date, period.to_date)
|
||||
gle = self.make_dummy_gle(period.key, period.to_date, amount)
|
||||
self.gle_entries.append(gle)
|
||||
|
||||
def calculate_item_revenue_expense_for_period(self):
|
||||
"""
|
||||
calculate item postings for each period and update period_total list
|
||||
"""
|
||||
for period in self.period_list:
|
||||
period_sum = 0
|
||||
actual = 0
|
||||
for posting in self.gle_entries:
|
||||
# if period.from_date <= posting.posting_date <= period.to_date:
|
||||
if period.from_date <= posting.gle_posting_date <= period.to_date:
|
||||
period_sum += self.get_amount(posting)
|
||||
if posting.posted == "posted":
|
||||
actual += self.get_amount(posting)
|
||||
|
||||
self.period_total.append(
|
||||
frappe._dict({"key": period.key, "total": period_sum, "actual": actual})
|
||||
)
|
||||
return self.period_total
|
||||
|
||||
|
||||
class Deferred_Invoice(object):
|
||||
def __init__(self, invoice, items, filters, period_list):
|
||||
"""
|
||||
Helper class for processing invoices with deferred revenue/expense items
|
||||
invoice - string : invoice name
|
||||
items - list : frappe._dict() with item details. Refer Deferred_Item for required fields
|
||||
"""
|
||||
self.name = invoice
|
||||
self.posting_date = items[0].posting_date
|
||||
self.filters = filters
|
||||
self.period_list = period_list
|
||||
# holds period wise total for invoice
|
||||
self.period_total = []
|
||||
|
||||
if items[0].deferred_revenue_account:
|
||||
self.type = "Sales"
|
||||
elif items[0].deferred_expense_account:
|
||||
self.type = "Purchase"
|
||||
|
||||
self.items = []
|
||||
# for each uniq items
|
||||
self.uniq_items = set([x.item for x in items])
|
||||
for item in self.uniq_items:
|
||||
self.items.append(Deferred_Item(item, self, [x for x in items if x.item == item]))
|
||||
|
||||
def calculate_invoice_revenue_expense_for_period(self):
|
||||
"""
|
||||
calculate deferred revenue/expense for all items in invoice
|
||||
"""
|
||||
# initialize period_total list for invoice
|
||||
for period in self.period_list:
|
||||
self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
|
||||
|
||||
for item in self.items:
|
||||
item_total = item.calculate_item_revenue_expense_for_period()
|
||||
# update invoice total
|
||||
for idx, period in enumerate(self.period_list, 0):
|
||||
self.period_total[idx].total += item_total[idx].total
|
||||
self.period_total[idx].actual += item_total[idx].actual
|
||||
return self.period_total
|
||||
|
||||
def estimate_future(self):
|
||||
"""
|
||||
create dummy GL entries for upcoming months for all items in invoice
|
||||
"""
|
||||
[item.simulate_future_posting() for item in self.items]
|
||||
|
||||
def report_data(self):
|
||||
"""
|
||||
generate report data for invoice, includes invoice total
|
||||
"""
|
||||
ret_data = []
|
||||
inv_total = frappe._dict({"name": self.name})
|
||||
for x in self.period_total:
|
||||
inv_total[x.key] = x.total
|
||||
inv_total.indent = 0
|
||||
ret_data.append(inv_total)
|
||||
list(map(lambda item: ret_data.append(item.report_data()), self.items))
|
||||
return ret_data
|
||||
|
||||
|
||||
class Deferred_Revenue_and_Expense_Report(object):
|
||||
def __init__(self, filters=None):
|
||||
"""
|
||||
Initialize deferred revenue/expense report with user provided filters or system defaults, if none is provided
|
||||
"""
|
||||
|
||||
# If no filters are provided, get user defaults
|
||||
if not filters:
|
||||
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
|
||||
self.filters = frappe._dict(
|
||||
{
|
||||
"company": frappe.defaults.get_user_default("Company"),
|
||||
"filter_based_on": "Fiscal Year",
|
||||
"period_start_date": fiscal_year.year_start_date,
|
||||
"period_end_date": fiscal_year.year_end_date,
|
||||
"from_fiscal_year": fiscal_year.year,
|
||||
"to_fiscal_year": fiscal_year.year,
|
||||
"periodicity": "Monthly",
|
||||
"type": "Revenue",
|
||||
"with_upcoming_postings": True,
|
||||
}
|
||||
)
|
||||
else:
|
||||
self.filters = frappe._dict(filters)
|
||||
|
||||
self.period_list = None
|
||||
self.deferred_invoices = []
|
||||
# holds period wise total for report
|
||||
self.period_total = []
|
||||
|
||||
def get_period_list(self):
|
||||
"""
|
||||
Figure out selected period based on filters
|
||||
"""
|
||||
self.period_list = get_period_list(
|
||||
self.filters.from_fiscal_year,
|
||||
self.filters.to_fiscal_year,
|
||||
self.filters.period_start_date,
|
||||
self.filters.period_end_date,
|
||||
self.filters.filter_based_on,
|
||||
self.filters.periodicity,
|
||||
company=self.filters.company,
|
||||
)
|
||||
|
||||
def get_invoices(self):
|
||||
"""
|
||||
Get all sales and purchase invoices which has deferred revenue/expense items
|
||||
"""
|
||||
gle = qb.DocType("GL Entry")
|
||||
# column doesn't have an alias option
|
||||
posted = Column("posted")
|
||||
|
||||
if self.filters.type == "Revenue":
|
||||
inv = qb.DocType("Sales Invoice")
|
||||
inv_item = qb.DocType("Sales Invoice Item")
|
||||
deferred_flag_field = inv_item["enable_deferred_revenue"]
|
||||
deferred_account_field = inv_item["deferred_revenue_account"]
|
||||
|
||||
elif self.filters.type == "Expense":
|
||||
inv = qb.DocType("Purchase Invoice")
|
||||
inv_item = qb.DocType("Purchase Invoice Item")
|
||||
deferred_flag_field = inv_item["enable_deferred_expense"]
|
||||
deferred_account_field = inv_item["deferred_expense_account"]
|
||||
|
||||
query = (
|
||||
qb.from_(inv_item)
|
||||
.join(inv)
|
||||
.on(inv.name == inv_item.parent)
|
||||
.join(gle)
|
||||
.on((inv_item.name == gle.voucher_detail_no) & (deferred_account_field == gle.account))
|
||||
.select(
|
||||
inv.name.as_("doc"),
|
||||
inv.posting_date,
|
||||
inv_item.name.as_("item"),
|
||||
inv_item.item_name,
|
||||
inv_item.service_start_date,
|
||||
inv_item.service_end_date,
|
||||
inv_item.base_net_amount,
|
||||
deferred_account_field,
|
||||
gle.posting_date.as_("gle_posting_date"),
|
||||
functions.Sum(gle.debit).as_("debit"),
|
||||
functions.Sum(gle.credit).as_("credit"),
|
||||
posted,
|
||||
)
|
||||
.where(
|
||||
(inv.docstatus == 1)
|
||||
& (deferred_flag_field == 1)
|
||||
& (
|
||||
(
|
||||
(self.period_list[0].from_date >= inv_item.service_start_date)
|
||||
& (inv_item.service_end_date >= self.period_list[0].from_date)
|
||||
)
|
||||
| (
|
||||
(inv_item.service_start_date >= self.period_list[0].from_date)
|
||||
& (inv_item.service_start_date <= self.period_list[-1].to_date)
|
||||
)
|
||||
)
|
||||
)
|
||||
.groupby(inv.name, inv_item.name, gle.posting_date)
|
||||
.orderby(gle.posting_date)
|
||||
)
|
||||
self.invoices = query.run(as_dict=True)
|
||||
|
||||
uniq_invoice = set([x.doc for x in self.invoices])
|
||||
for inv in uniq_invoice:
|
||||
self.deferred_invoices.append(
|
||||
Deferred_Invoice(
|
||||
inv, [x for x in self.invoices if x.doc == inv], self.filters, self.period_list
|
||||
)
|
||||
)
|
||||
|
||||
def estimate_future(self):
|
||||
"""
|
||||
For all Invoices estimate upcoming postings
|
||||
"""
|
||||
for x in self.deferred_invoices:
|
||||
x.estimate_future()
|
||||
|
||||
def calculate_revenue_and_expense(self):
|
||||
"""
|
||||
calculate the deferred revenue/expense for all invoices
|
||||
"""
|
||||
# initialize period_total list for report
|
||||
for period in self.period_list:
|
||||
self.period_total.append(frappe._dict({"key": period.key, "total": 0, "actual": 0}))
|
||||
|
||||
for inv in self.deferred_invoices:
|
||||
inv_total = inv.calculate_invoice_revenue_expense_for_period()
|
||||
# calculate total for whole report
|
||||
for idx, period in enumerate(self.period_list, 0):
|
||||
self.period_total[idx].total += inv_total[idx].total
|
||||
self.period_total[idx].actual += inv_total[idx].actual
|
||||
|
||||
def get_columns(self):
|
||||
columns = []
|
||||
columns.append({"label": _("Name"), "fieldname": "name", "fieldtype": "Data", "read_only": 1})
|
||||
for period in self.period_list:
|
||||
columns.append(
|
||||
{
|
||||
"label": _(period.label),
|
||||
"fieldname": period.key,
|
||||
"fieldtype": "Currency",
|
||||
"read_only": 1,
|
||||
})
|
||||
return columns
|
||||
|
||||
def generate_report_data(self):
|
||||
"""
|
||||
Generate report data for all invoices. Adds total rows for revenue and expense
|
||||
"""
|
||||
ret = []
|
||||
|
||||
for inv in self.deferred_invoices:
|
||||
ret += inv.report_data()
|
||||
|
||||
# empty row for padding
|
||||
ret += [{}]
|
||||
|
||||
# add total row
|
||||
if ret is not []:
|
||||
if self.filters.type == "Revenue":
|
||||
total_row = frappe._dict({"name": "Total Deferred Income"})
|
||||
elif self.filters.type == "Expense":
|
||||
total_row = frappe._dict({"name": "Total Deferred Expense"})
|
||||
|
||||
for idx, period in enumerate(self.period_list, 0):
|
||||
total_row[period.key] = self.period_total[idx].total
|
||||
ret.append(total_row)
|
||||
|
||||
return ret
|
||||
|
||||
def prepare_chart(self):
|
||||
chart = {
|
||||
"data": {
|
||||
"labels": [period.label for period in self.period_list],
|
||||
"datasets": [
|
||||
{
|
||||
"name": "Actual Posting",
|
||||
"chartType": "bar",
|
||||
"values": [x.actual for x in self.period_total],
|
||||
}
|
||||
],
|
||||
},
|
||||
"type": "axis-mixed",
|
||||
"height": 500,
|
||||
"axisOptions": {"xAxisMode": "Tick", "xIsSeries": True},
|
||||
"barOptions": {"stacked": False, "spaceRatio": 0.5},
|
||||
}
|
||||
|
||||
if self.filters.with_upcoming_postings:
|
||||
chart["data"]["datasets"].append({
|
||||
"name": "Expected",
|
||||
"chartType": "line",
|
||||
"values": [x.total for x in self.period_total]
|
||||
})
|
||||
|
||||
return chart
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
"""
|
||||
Run report and generate data
|
||||
"""
|
||||
self.deferred_invoices.clear()
|
||||
self.get_period_list()
|
||||
self.get_invoices()
|
||||
|
||||
if self.filters.with_upcoming_postings:
|
||||
self.estimate_future()
|
||||
self.calculate_revenue_and_expense()
|
||||
|
||||
|
||||
def execute(filters=None):
|
||||
report = Deferred_Revenue_and_Expense_Report(filters=filters)
|
||||
report.run()
|
||||
|
||||
columns = report.get_columns()
|
||||
data = report.generate_report_data()
|
||||
message = []
|
||||
chart = report.prepare_chart()
|
||||
|
||||
return columns, data, message, chart
|
||||
@@ -0,0 +1,253 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import nowdate
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.report.deferred_revenue_and_expense.deferred_revenue_and_expense import (
|
||||
Deferred_Revenue_and_Expense_Report,
|
||||
)
|
||||
from erpnext.buying.doctype.supplier.test_supplier import create_supplier
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
|
||||
|
||||
class TestDeferredRevenueAndExpense(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(self):
|
||||
clear_old_entries()
|
||||
create_company()
|
||||
|
||||
def test_deferred_revenue(self):
|
||||
# created deferred expense accounts, if not found
|
||||
deferred_revenue_account = create_account(
|
||||
account_name="Deferred Revenue",
|
||||
parent_account="Current Liabilities - _CD",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
|
||||
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
|
||||
acc_settings.book_deferred_entries_based_on = "Months"
|
||||
acc_settings.save()
|
||||
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = "_Test Customer DR"
|
||||
customer.type = "Individual"
|
||||
customer.insert()
|
||||
|
||||
item = create_item(
|
||||
"_Test Internet Subscription",
|
||||
is_stock_item=0,
|
||||
warehouse="All Warehouses - _CD",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
item.enable_deferred_revenue = 1
|
||||
item.deferred_revenue_account = deferred_revenue_account
|
||||
item.no_of_months = 3
|
||||
item.save()
|
||||
|
||||
si = create_sales_invoice(
|
||||
item=item.name,
|
||||
company="_Test Company DR",
|
||||
customer="_Test Customer DR",
|
||||
debit_to="Debtors - _CD",
|
||||
posting_date="2021-05-01",
|
||||
parent_cost_center="Main - _CD",
|
||||
cost_center="Main - _CD",
|
||||
do_not_submit=True,
|
||||
rate=300,
|
||||
price_list_rate=300,
|
||||
)
|
||||
si.items[0].enable_deferred_revenue = 1
|
||||
si.items[0].service_start_date = "2021-05-01"
|
||||
si.items[0].service_end_date = "2021-08-01"
|
||||
si.items[0].deferred_revenue_account = deferred_revenue_account
|
||||
si.items[0].income_account = "Sales - _CD"
|
||||
si.save()
|
||||
si.submit()
|
||||
|
||||
pda = frappe.get_doc(
|
||||
dict(
|
||||
doctype="Process Deferred Accounting",
|
||||
posting_date=nowdate(),
|
||||
start_date="2021-05-01",
|
||||
end_date="2021-08-01",
|
||||
type="Income",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
)
|
||||
pda.insert()
|
||||
pda.submit()
|
||||
|
||||
# execute report
|
||||
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
|
||||
self.filters = frappe._dict(
|
||||
{
|
||||
"company": frappe.defaults.get_user_default("Company"),
|
||||
"filter_based_on": "Date Range",
|
||||
"period_start_date": "2021-05-01",
|
||||
"period_end_date": "2021-08-01",
|
||||
"from_fiscal_year": fiscal_year.year,
|
||||
"to_fiscal_year": fiscal_year.year,
|
||||
"periodicity": "Monthly",
|
||||
"type": "Revenue",
|
||||
"with_upcoming_postings": False,
|
||||
}
|
||||
)
|
||||
|
||||
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
|
||||
report.run()
|
||||
expected = [
|
||||
{"key": "may_2021", "total": 100.0, "actual": 100.0},
|
||||
{"key": "jun_2021", "total": 100.0, "actual": 100.0},
|
||||
{"key": "jul_2021", "total": 100.0, "actual": 100.0},
|
||||
{"key": "aug_2021", "total": 0, "actual": 0},
|
||||
]
|
||||
self.assertEqual(report.period_total, expected)
|
||||
|
||||
def test_deferred_expense(self):
|
||||
# created deferred expense accounts, if not found
|
||||
deferred_expense_account = create_account(
|
||||
account_name="Deferred Expense",
|
||||
parent_account="Current Assets - _CD",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
|
||||
acc_settings = frappe.get_doc("Accounts Settings", "Accounts Settings")
|
||||
acc_settings.book_deferred_entries_based_on = "Months"
|
||||
acc_settings.save()
|
||||
|
||||
supplier = create_supplier(
|
||||
supplier_name="_Test Furniture Supplier", supplier_group="Local", supplier_type="Company"
|
||||
)
|
||||
supplier.save()
|
||||
|
||||
item = create_item(
|
||||
"_Test Office Desk",
|
||||
is_stock_item=0,
|
||||
warehouse="All Warehouses - _CD",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
item.enable_deferred_expense = 1
|
||||
item.deferred_expense_account = deferred_expense_account
|
||||
item.no_of_months_exp = 3
|
||||
item.save()
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
item=item.name,
|
||||
company="_Test Company DR",
|
||||
supplier="_Test Furniture Supplier",
|
||||
is_return=False,
|
||||
update_stock=False,
|
||||
posting_date=frappe.utils.datetime.date(2021, 5, 1),
|
||||
parent_cost_center="Main - _CD",
|
||||
cost_center="Main - _CD",
|
||||
do_not_save=True,
|
||||
rate=300,
|
||||
price_list_rate=300,
|
||||
warehouse="All Warehouses - _CD",
|
||||
qty=1,
|
||||
)
|
||||
pi.set_posting_time = True
|
||||
pi.items[0].enable_deferred_expense = 1
|
||||
pi.items[0].service_start_date = "2021-05-01"
|
||||
pi.items[0].service_end_date = "2021-08-01"
|
||||
pi.items[0].deferred_expense_account = deferred_expense_account
|
||||
pi.items[0].expense_account = "Office Maintenance Expenses - _CD"
|
||||
pi.save()
|
||||
pi.submit()
|
||||
|
||||
pda = frappe.get_doc(
|
||||
dict(
|
||||
doctype="Process Deferred Accounting",
|
||||
posting_date=nowdate(),
|
||||
start_date="2021-05-01",
|
||||
end_date="2021-08-01",
|
||||
type="Expense",
|
||||
company="_Test Company DR",
|
||||
)
|
||||
)
|
||||
pda.insert()
|
||||
pda.submit()
|
||||
|
||||
# execute report
|
||||
fiscal_year = frappe.get_doc("Fiscal Year", frappe.defaults.get_user_default("fiscal_year"))
|
||||
self.filters = frappe._dict(
|
||||
{
|
||||
"company": frappe.defaults.get_user_default("Company"),
|
||||
"filter_based_on": "Date Range",
|
||||
"period_start_date": "2021-05-01",
|
||||
"period_end_date": "2021-08-01",
|
||||
"from_fiscal_year": fiscal_year.year,
|
||||
"to_fiscal_year": fiscal_year.year,
|
||||
"periodicity": "Monthly",
|
||||
"type": "Expense",
|
||||
"with_upcoming_postings": False,
|
||||
}
|
||||
)
|
||||
|
||||
report = Deferred_Revenue_and_Expense_Report(filters=self.filters)
|
||||
report.run()
|
||||
expected = [
|
||||
{"key": "may_2021", "total": -100.0, "actual": -100.0},
|
||||
{"key": "jun_2021", "total": -100.0, "actual": -100.0},
|
||||
{"key": "jul_2021", "total": -100.0, "actual": -100.0},
|
||||
{"key": "aug_2021", "total": 0, "actual": 0},
|
||||
]
|
||||
self.assertEqual(report.period_total, expected)
|
||||
|
||||
|
||||
def create_company():
|
||||
company = frappe.db.exists("Company", "_Test Company DR")
|
||||
if not company:
|
||||
company = frappe.new_doc("Company")
|
||||
company.company_name = "_Test Company DR"
|
||||
company.default_currency = "INR"
|
||||
company.chart_of_accounts = "Standard"
|
||||
company.insert()
|
||||
|
||||
|
||||
def clear_old_entries():
|
||||
item = qb.DocType("Item")
|
||||
account = qb.DocType("Account")
|
||||
customer = qb.DocType("Customer")
|
||||
supplier = qb.DocType("Supplier")
|
||||
sinv = qb.DocType("Sales Invoice")
|
||||
sinv_item = qb.DocType("Sales Invoice Item")
|
||||
pinv = qb.DocType("Purchase Invoice")
|
||||
pinv_item = qb.DocType("Purchase Invoice Item")
|
||||
|
||||
qb.from_(account).delete().where(
|
||||
(account.account_name == "Deferred Revenue")
|
||||
| (account.account_name == "Deferred Expense") & (account.company == "_Test Company DR")
|
||||
).run()
|
||||
qb.from_(item).delete().where(
|
||||
(item.item_code == "_Test Internet Subscription") | (item.item_code == "_Test Office Rent")
|
||||
).run()
|
||||
qb.from_(customer).delete().where(customer.customer_name == "_Test Customer DR").run()
|
||||
qb.from_(supplier).delete().where(supplier.supplier_name == "_Test Furniture Supplier").run()
|
||||
|
||||
# delete existing invoices with deferred items
|
||||
deferred_invoices = (
|
||||
qb.from_(sinv)
|
||||
.join(sinv_item)
|
||||
.on(sinv.name == sinv_item.parent)
|
||||
.select(sinv.name)
|
||||
.where(sinv_item.enable_deferred_revenue == 1)
|
||||
.run()
|
||||
)
|
||||
if deferred_invoices:
|
||||
qb.from_(sinv).delete().where(sinv.name.isin(deferred_invoices)).run()
|
||||
|
||||
deferred_invoices = (
|
||||
qb.from_(pinv)
|
||||
.join(pinv_item)
|
||||
.on(pinv.name == pinv_item.parent)
|
||||
.select(pinv.name)
|
||||
.where(pinv_item.enable_deferred_expense == 1)
|
||||
.run()
|
||||
)
|
||||
if deferred_invoices:
|
||||
qb.from_(pinv).delete().where(pinv.name.isin(deferred_invoices)).run()
|
||||
@@ -124,6 +124,14 @@ frappe.ui.form.on("Request for Quotation",{
|
||||
dialog.show()
|
||||
},
|
||||
|
||||
schedule_date(frm) {
|
||||
if(frm.doc.schedule_date){
|
||||
frm.doc.items.forEach((item) => {
|
||||
item.schedule_date = frm.doc.schedule_date;
|
||||
})
|
||||
}
|
||||
refresh_field("items");
|
||||
},
|
||||
preview: (frm) => {
|
||||
let dialog = new frappe.ui.Dialog({
|
||||
title: __('Preview Email'),
|
||||
@@ -184,7 +192,13 @@ frappe.ui.form.on("Request for Quotation",{
|
||||
dialog.show();
|
||||
}
|
||||
})
|
||||
|
||||
frappe.ui.form.on("Request for Quotation Item", {
|
||||
items_add(frm, cdt, cdn) {
|
||||
if (frm.doc.schedule_date) {
|
||||
frappe.model.set_value(cdt, cdn, 'schedule_date', frm.doc.schedule_date);
|
||||
}
|
||||
}
|
||||
});
|
||||
frappe.ui.form.on("Request for Quotation Supplier",{
|
||||
supplier: function(frm, cdt, cdn) {
|
||||
var d = locals[cdt][cdn]
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"vendor",
|
||||
"column_break1",
|
||||
"transaction_date",
|
||||
"schedule_date",
|
||||
"status",
|
||||
"amended_from",
|
||||
"suppliers_section",
|
||||
@@ -246,16 +247,22 @@
|
||||
"fieldname": "sec_break_email_2",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "schedule_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Required Date"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-shopping-cart",
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2020-11-05 22:04:29.017134",
|
||||
"modified": "2021-11-24 17:47:49.909000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation",
|
||||
"naming_rule": "By \"Naming Series\" field",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
43
erpnext/change_log/v13/v13_18_0.md
Normal file
43
erpnext/change_log/v13/v13_18_0.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Version 13.18.0 Release Notes
|
||||
|
||||
### Features & Enhancements
|
||||
|
||||
- Deferred Revenue and Expense report with actual and upcoming postings ([#28822](https://github.com/frappe/erpnext/pull/28822))
|
||||
- 'Invoice Number' field in Opening Invoice Creation Tool ([#29147](https://github.com/frappe/erpnext/pull/29147))
|
||||
- Added required_date field to set date in child table ([#28432](https://github.com/frappe/erpnext/pull/28432))
|
||||
|
||||
### Fixes
|
||||
|
||||
- Enable ksa POS Invoice print format ([#28911](https://github.com/frappe/erpnext/pull/28911))
|
||||
- Rename non existent doctype field to the right one ([#29055](https://github.com/frappe/erpnext/pull/29055))
|
||||
- Mapped accounting dimensions for Bank Entry against Payroll Entry ([#29142](https://github.com/frappe/erpnext/pull/29142))
|
||||
- Validate Finished Goods for independent Manufacture entries ([#28555](https://github.com/frappe/erpnext/pull/28555))
|
||||
- Incorrect posting time fetching incorrect stock quantity in stock reconciliation ([#29103](https://github.com/frappe/erpnext/pull/29103))
|
||||
- Stock Ageing Report - Negative Opening Stock ([#28966](https://github.com/frappe/erpnext/pull/28966))
|
||||
- Can't change valuation_method on item ([#28876](https://github.com/frappe/erpnext/pull/28876))
|
||||
- Optimize rate updation on changing price list ([#28953](https://github.com/frappe/erpnext/pull/28953))
|
||||
- Added filter for dispatch address ([#28937](https://github.com/frappe/erpnext/pull/28937))
|
||||
- Convert Item links to Website Item links in `Item Card Group` template data ([#28985](https://github.com/frappe/erpnext/pull/28985))
|
||||
- Earned Leave allocation from Leave Policy Assignment ([#29163](https://github.com/frappe/erpnext/pull/29163))
|
||||
- Items not mapped when trying to create a Maintenance Visit via Maintenance Schedule ([#28917](https://github.com/frappe/erpnext/pull/28917))
|
||||
- For performance improvement, removed forcing of posting sort index on stock balance report ([#28902](https://github.com/frappe/erpnext/pull/28902))
|
||||
- Future recurring period calculation ([#29083](https://github.com/frappe/erpnext/pull/29083))
|
||||
- Nonstock items are showing in the Itemwise Recommended Reorder Level report ([#28873](https://github.com/frappe/erpnext/pull/28873))
|
||||
- Incorrect amount based on payment days in timesheet salary slip ([#28845](https://github.com/frappe/erpnext/pull/28845))
|
||||
- Currency fix for `cost` field in subscription plan ([#28821](https://github.com/frappe/erpnext/pull/28821))
|
||||
- Fetch selling price with pricing rule ([#28951](https://github.com/frappe/erpnext/pull/28951))
|
||||
- Filter out Claimed employee advances in Expense Claim ([#29046](https://github.com/frappe/erpnext/pull/29046))
|
||||
- Tax and Charges template not getting fetched based on tax category assigned ([#29092](https://github.com/frappe/erpnext/pull/29092))
|
||||
- Ignore links while setting default notification templates in Settings ([#29042](https://github.com/frappe/erpnext/pull/29042))
|
||||
- Reset "Value After Depreciation" on reversing journal entry during Asset return ([#28975](https://github.com/frappe/erpnext/pull/28975))
|
||||
- Multicurrency invoices using subscription ([#28916](https://github.com/frappe/erpnext/pull/28916))
|
||||
- Fetch the appointment letter content in the same order as template ([#28968](https://github.com/frappe/erpnext/pull/28968))
|
||||
- Incorrect serial no valuation report showing cancelled entries ([#29172](https://github.com/frappe/erpnext/pull/29172))
|
||||
- Start date validation for deferred invoices ([#29009](https://github.com/frappe/erpnext/pull/29009))
|
||||
- HSN-Wise summary report is incorrect if an invoice has same item code multiple times ([#28783](https://github.com/frappe/erpnext/pull/28783))
|
||||
- Incorrect logic for the "Reserved Qty for Production" field in BIN ([#28880](https://github.com/frappe/erpnext/pull/28880))
|
||||
- Issues in Bank Reconciliation tool ([#28996](https://github.com/frappe/erpnext/pull/28996))
|
||||
- Hide Raw Material table in the Job Card if material transfer is against work order ([#28746](https://github.com/frappe/erpnext/pull/28746))
|
||||
- Added "Is Reverse Charge" checkbox in Tax Category for Indian Companies ([#28935](https://github.com/frappe/erpnext/pull/28935))
|
||||
- Updates in term loan processing ([#28034](https://github.com/frappe/erpnext/pull/28034))
|
||||
- Incorrect bin qty on backdated reconciliation ([#28588](https://github.com/frappe/erpnext/pull/28588))
|
||||
@@ -190,6 +190,8 @@ class AccountsController(TransactionBase):
|
||||
frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx))
|
||||
elif getdate(self.posting_date) > getdate(d.service_end_date):
|
||||
frappe.throw(_("Row #{0}: Service End Date cannot be before Invoice Posting Date").format(d.idx))
|
||||
elif getdate(self.posting_date) > getdate(d.service_start_date):
|
||||
frappe.throw(_("Row #{0}: Service Start Date cannot be before Invoice Posting Date").format(d.idx))
|
||||
|
||||
def validate_invoice_documents_schedule(self):
|
||||
self.validate_payment_schedule_dates()
|
||||
|
||||
@@ -46,12 +46,26 @@ class TestWebsiteItem(unittest.TestCase):
|
||||
]
|
||||
})
|
||||
elif self._testMethodName in WEBITEM_PRICE_TESTS:
|
||||
create_user_and_customer_if_not_exists("test_contact_customer@example.com", "_Test Contact For _Test Customer")
|
||||
create_regular_web_item()
|
||||
make_web_item_price(item_code="Test Mobile Phone")
|
||||
|
||||
# Note: When testing web item pricing rule logged-in user pricing rule must differ from guest pricing rule or test will falsely pass.
|
||||
# This is because make_web_pricing_rule creates a pricing rule "selling": 1, without specifying "applicable_for". Therefor,
|
||||
# when testing for logged-in user the test will get the previous pricing rule because "selling" is still true.
|
||||
#
|
||||
# I've attempted to mitigate this by setting applicable_for=Customer, and customer=Guest however, this only results in PermissionError failing the test.
|
||||
make_web_pricing_rule(
|
||||
title="Test Pricing Rule for Test Mobile Phone",
|
||||
item_code="Test Mobile Phone",
|
||||
selling=1)
|
||||
make_web_pricing_rule(
|
||||
title="Test Pricing Rule for Test Mobile Phone (Customer)",
|
||||
item_code="Test Mobile Phone",
|
||||
selling=1,
|
||||
discount_percentage="25",
|
||||
applicable_for="Customer",
|
||||
customer="_Test Customer")
|
||||
|
||||
def test_index_creation(self):
|
||||
"Check if index is getting created in db."
|
||||
@@ -188,22 +202,27 @@ class TestWebsiteItem(unittest.TestCase):
|
||||
|
||||
# price and pricing rule added via setUp
|
||||
|
||||
# login as customer with pricing rule
|
||||
frappe.set_user("test_contact_customer@example.com")
|
||||
|
||||
# check if price and slashed price is fetched correctly
|
||||
frappe.local.shopping_cart_settings = None
|
||||
data = get_product_info_for_website(item_code, skip_quotation_creation=True)
|
||||
self.assertTrue(bool(data.product_info["price"]))
|
||||
|
||||
price_object = data.product_info["price"]
|
||||
self.assertEqual(price_object.get("discount_percent"), 10)
|
||||
self.assertEqual(price_object.get("price_list_rate"), 900)
|
||||
self.assertEqual(price_object.get("discount_percent"), 25)
|
||||
self.assertEqual(price_object.get("price_list_rate"), 750)
|
||||
self.assertEqual(price_object.get("formatted_mrp"), "₹ 1,000.00")
|
||||
self.assertEqual(price_object.get("formatted_price"), "₹ 900.00")
|
||||
self.assertEqual(price_object.get("formatted_discount_percent"), "10%")
|
||||
self.assertEqual(price_object.get("formatted_price"), "₹ 750.00")
|
||||
self.assertEqual(price_object.get("formatted_discount_percent"), "25%")
|
||||
|
||||
# disable show price
|
||||
# switch to admin and disable show price
|
||||
frappe.set_user("Administrator")
|
||||
setup_e_commerce_settings({"show_price": 0})
|
||||
|
||||
# price should not be fetched
|
||||
# price should not be fetched for logged in user.
|
||||
frappe.set_user("test_contact_customer@example.com")
|
||||
frappe.local.shopping_cart_settings = None
|
||||
data = get_product_info_for_website(item_code, skip_quotation_creation=True)
|
||||
self.assertFalse(bool(data.product_info["price"]))
|
||||
@@ -485,10 +504,34 @@ def make_web_pricing_rule(**kwargs):
|
||||
"discount_percentage": kwargs.get("discount_percentage") or 10,
|
||||
"company": kwargs.get("company") or "_Test Company",
|
||||
"currency": kwargs.get("currency") or "INR",
|
||||
"for_price_list": kwargs.get("price_list") or "_Test Price List India"
|
||||
"for_price_list": kwargs.get("price_list") or "_Test Price List India",
|
||||
"applicable_for": kwargs.get("applicable_for") or "",
|
||||
"customer": kwargs.get("customer") or "",
|
||||
})
|
||||
pricing_rule.insert()
|
||||
else:
|
||||
pricing_rule = frappe.get_doc("Pricing Rule", {"title": title})
|
||||
|
||||
return pricing_rule
|
||||
return pricing_rule
|
||||
|
||||
|
||||
def create_user_and_customer_if_not_exists(email, first_name = None):
|
||||
if frappe.db.exists("User", email):
|
||||
return
|
||||
|
||||
frappe.get_doc({
|
||||
"doctype": "User",
|
||||
"user_type": "Website User",
|
||||
"email": email,
|
||||
"send_welcome_email": 0,
|
||||
"first_name": first_name or email.split("@")[0]
|
||||
}).insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.get_last_doc("Contact", filters={"email_id": email})
|
||||
link = contact.append('links', {})
|
||||
link.link_doctype = "Customer"
|
||||
link.link_name = "_Test Customer"
|
||||
link.link_title = "_Test Customer"
|
||||
contact.save()
|
||||
|
||||
test_dependencies = ["Price List", "Item Price", "Customer", "Contact", "Item"]
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
{%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%}
|
||||
{%- set item = values['card_' + index + '_item'] -%}
|
||||
{%- if item -%}
|
||||
{%- set item = frappe.get_doc("Item", item) -%}
|
||||
{%- set web_item = frappe.get_doc("Website Item", item) -%}
|
||||
{{ item_card(
|
||||
item, is_featured=values['card_' + index + '_featured'],
|
||||
web_item, is_featured=values['card_' + index + '_featured'],
|
||||
is_full_width=True, align="Center"
|
||||
) }}
|
||||
{%- endif -%}
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
{
|
||||
"fieldname": "card_1_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -56,8 +56,8 @@
|
||||
{
|
||||
"fieldname": "card_2_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -76,8 +76,8 @@
|
||||
{
|
||||
"fieldname": "card_3_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -95,8 +95,8 @@
|
||||
{
|
||||
"fieldname": "card_4_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -114,8 +114,8 @@
|
||||
{
|
||||
"fieldname": "card_5_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -133,8 +133,8 @@
|
||||
{
|
||||
"fieldname": "card_6_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -152,8 +152,8 @@
|
||||
{
|
||||
"fieldname": "card_7_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -171,8 +171,8 @@
|
||||
{
|
||||
"fieldname": "card_8_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -190,8 +190,8 @@
|
||||
{
|
||||
"fieldname": "card_9_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -209,8 +209,8 @@
|
||||
{
|
||||
"fieldname": "card_10_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -228,8 +228,8 @@
|
||||
{
|
||||
"fieldname": "card_11_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -247,8 +247,8 @@
|
||||
{
|
||||
"fieldname": "card_12_item",
|
||||
"fieldtype": "Link",
|
||||
"label": "Item",
|
||||
"options": "Item",
|
||||
"label": "Website Item",
|
||||
"options": "Website Item",
|
||||
"reqd": 0
|
||||
},
|
||||
{
|
||||
@@ -259,7 +259,7 @@
|
||||
}
|
||||
],
|
||||
"idx": 0,
|
||||
"modified": "2021-02-24 16:05:31.242342",
|
||||
"modified": "2021-12-21 14:44:59.821335",
|
||||
"modified_by": "Administrator",
|
||||
"module": "E-commerce",
|
||||
"name": "Item Card Group",
|
||||
|
||||
@@ -388,7 +388,7 @@ scheduler_events = {
|
||||
"erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_appointment_status",
|
||||
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_status",
|
||||
"erpnext.accounts.doctype.process_statement_of_accounts.process_statement_of_accounts.send_auto_email",
|
||||
"erpnext.non_profit.doctype.membership.membership.set_expired_status"
|
||||
"erpnext.non_profit.doctype.membership.membership.set_expired_status",
|
||||
"erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder"
|
||||
],
|
||||
"daily_long": [
|
||||
|
||||
@@ -12,14 +12,15 @@ class AppointmentLetter(Document):
|
||||
@frappe.whitelist()
|
||||
def get_appointment_letter_details(template):
|
||||
body = []
|
||||
intro= frappe.get_list("Appointment Letter Template",
|
||||
fields = ['introduction', 'closing_notes'],
|
||||
filters={'name': template
|
||||
})[0]
|
||||
content = frappe.get_list("Appointment Letter content",
|
||||
fields = ['title', 'description'],
|
||||
filters={'parent': template
|
||||
})
|
||||
intro = frappe.get_list('Appointment Letter Template',
|
||||
fields=['introduction', 'closing_notes'],
|
||||
filters={'name': template}
|
||||
)[0]
|
||||
content = frappe.get_all('Appointment Letter content',
|
||||
fields=['title', 'description'],
|
||||
filters={'parent': template},
|
||||
order_by='idx'
|
||||
)
|
||||
body.append(intro)
|
||||
body.append({'description': content})
|
||||
return body
|
||||
|
||||
@@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", {
|
||||
['docstatus', '=', 1],
|
||||
['employee', '=', frm.doc.employee],
|
||||
['paid_amount', '>', 0],
|
||||
['paid_amount', '>', 'claimed_amount']
|
||||
['status', '!=', 'Claimed']
|
||||
]
|
||||
};
|
||||
});
|
||||
|
||||
@@ -10,15 +10,17 @@ from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry
|
||||
|
||||
test_records = frappe.get_test_records('Expense Claim')
|
||||
test_dependencies = ['Employee']
|
||||
company_name = '_Test Company 4'
|
||||
company_name = '_Test Company 3'
|
||||
|
||||
|
||||
class TestExpenseClaim(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_total_expense_claim_for_project(self):
|
||||
frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """)
|
||||
frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """)
|
||||
frappe.db.sql("""delete from `tabTask`""")
|
||||
frappe.db.sql("""delete from `tabProject`""")
|
||||
frappe.db.sql("update `tabExpense Claim` set project = '', task = ''")
|
||||
|
||||
project = frappe.get_doc({
|
||||
@@ -37,12 +39,12 @@ class TestExpenseClaim(unittest.TestCase):
|
||||
task_name = task.name
|
||||
payable_account = get_payable_account(company_name)
|
||||
|
||||
make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4", project.name, task_name)
|
||||
make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3", project.name, task_name)
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200)
|
||||
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 200)
|
||||
|
||||
expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC4", project.name, task_name)
|
||||
expense_claim2 = make_expense_claim(payable_account, 600, 500, company_name, "Travel Expenses - _TC3", project.name, task_name)
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700)
|
||||
self.assertEqual(frappe.db.get_value("Project", project.name, "total_expense_claim"), 700)
|
||||
@@ -54,7 +56,7 @@ class TestExpenseClaim(unittest.TestCase):
|
||||
|
||||
def test_expense_claim_status(self):
|
||||
payable_account = get_payable_account(company_name)
|
||||
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4")
|
||||
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3")
|
||||
|
||||
je_dict = make_bank_entry("Expense Claim", expense_claim.name)
|
||||
je = frappe.get_doc(je_dict)
|
||||
@@ -73,7 +75,7 @@ class TestExpenseClaim(unittest.TestCase):
|
||||
def test_expense_claim_gl_entry(self):
|
||||
payable_account = get_payable_account(company_name)
|
||||
taxes = generate_taxes()
|
||||
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC4",
|
||||
expense_claim = make_expense_claim(payable_account, 300, 200, company_name, "Travel Expenses - _TC3",
|
||||
do_not_submit=True, taxes=taxes)
|
||||
expense_claim.submit()
|
||||
|
||||
@@ -84,9 +86,9 @@ class TestExpenseClaim(unittest.TestCase):
|
||||
self.assertTrue(gl_entries)
|
||||
|
||||
expected_values = dict((d[0], d) for d in [
|
||||
['Output Tax CGST - _TC4',18.0, 0.0],
|
||||
['Output Tax CGST - _TC3',18.0, 0.0],
|
||||
[payable_account, 0.0, 218.0],
|
||||
["Travel Expenses - _TC4", 200.0, 0.0]
|
||||
["Travel Expenses - _TC3", 200.0, 0.0]
|
||||
])
|
||||
|
||||
for gle in gl_entries:
|
||||
@@ -102,7 +104,7 @@ class TestExpenseClaim(unittest.TestCase):
|
||||
"payable_account": payable_account,
|
||||
"approval_status": "Rejected",
|
||||
"expenses":
|
||||
[{ "expense_type": "Travel", "default_account": "Travel Expenses - _TC4", "amount": 300, "sanctioned_amount": 200 }]
|
||||
[{"expense_type": "Travel", "default_account": "Travel Expenses - _TC3", "amount": 300, "sanctioned_amount": 200}]
|
||||
})
|
||||
expense_claim.submit()
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
@@ -5,6 +5,7 @@ import frappe
|
||||
from frappe.utils import add_days, add_months, getdate, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
|
||||
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
|
||||
|
||||
@@ -14,16 +15,19 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
def setUpClass(cls):
|
||||
frappe.db.sql("delete from `tabLeave Period`")
|
||||
|
||||
def test_overlapping_allocation(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
emp_id = make_employee("test_emp_leave_allocation@salary.com")
|
||||
cls.employee = frappe.get_doc("Employee", emp_id)
|
||||
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def test_overlapping_allocation(self):
|
||||
leaves = [
|
||||
{
|
||||
"doctype": "Leave Allocation",
|
||||
"__islocal": 1,
|
||||
"employee": employee.name,
|
||||
"employee_name": employee.employee_name,
|
||||
"employee": self.employee.name,
|
||||
"employee_name": self.employee.employee_name,
|
||||
"leave_type": "_Test Leave Type",
|
||||
"from_date": getdate("2015-10-01"),
|
||||
"to_date": getdate("2015-10-31"),
|
||||
@@ -33,8 +37,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
{
|
||||
"doctype": "Leave Allocation",
|
||||
"__islocal": 1,
|
||||
"employee": employee.name,
|
||||
"employee_name": employee.employee_name,
|
||||
"employee": self.employee.name,
|
||||
"employee_name": self.employee.employee_name,
|
||||
"leave_type": "_Test Leave Type",
|
||||
"from_date": getdate("2015-09-01"),
|
||||
"to_date": getdate("2015-11-30"),
|
||||
@@ -46,40 +50,36 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save)
|
||||
|
||||
def test_invalid_period(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Leave Allocation",
|
||||
"__islocal": 1,
|
||||
"employee": employee.name,
|
||||
"employee_name": employee.employee_name,
|
||||
"employee": self.employee.name,
|
||||
"employee_name": self.employee.employee_name,
|
||||
"leave_type": "_Test Leave Type",
|
||||
"from_date": getdate("2015-09-30"),
|
||||
"to_date": getdate("2015-09-1"),
|
||||
"new_leaves_allocated": 5
|
||||
})
|
||||
|
||||
#invalid period
|
||||
# invalid period
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_allocated_leave_days_over_period(self):
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
doc = frappe.get_doc({
|
||||
"doctype": "Leave Allocation",
|
||||
"__islocal": 1,
|
||||
"employee": employee.name,
|
||||
"employee_name": employee.employee_name,
|
||||
"employee": self.employee.name,
|
||||
"employee_name": self.employee.employee_name,
|
||||
"leave_type": "_Test Leave Type",
|
||||
"from_date": getdate("2015-09-1"),
|
||||
"to_date": getdate("2015-09-30"),
|
||||
"new_leaves_allocated": 35
|
||||
})
|
||||
#allocated leave more than period
|
||||
|
||||
# allocated leave more than period
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_carry_forward_calculation(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
frappe.db.sql("delete from `tabLeave Ledger Entry`")
|
||||
leave_type = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
|
||||
leave_type.maximum_carry_forwarded_leaves = 10
|
||||
leave_type.max_leaves_allowed = 30
|
||||
@@ -87,6 +87,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
|
||||
# initial leave allocation = 15
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave",
|
||||
from_date=add_months(nowdate(), -12),
|
||||
to_date=add_months(nowdate(), -1),
|
||||
@@ -96,6 +98,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
# carry forwarded leaves considering maximum_carry_forwarded_leaves
|
||||
# new_leaves = 15, carry_forwarded = 10
|
||||
leave_allocation_1 = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave",
|
||||
carry_forward=1)
|
||||
leave_allocation_1.submit()
|
||||
@@ -107,6 +111,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
# carry forwarded leaves considering max_leave_allowed
|
||||
# max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5
|
||||
leave_allocation_2 = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave",
|
||||
carry_forward=1,
|
||||
new_leaves_allocated=25)
|
||||
@@ -115,8 +121,6 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
self.assertEqual(leave_allocation_2.unused_leaves, 5)
|
||||
|
||||
def test_carry_forward_leaves_expiry(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
frappe.db.sql("delete from `tabLeave Ledger Entry`")
|
||||
leave_type = create_leave_type(
|
||||
leave_type_name="_Test_CF_leave_expiry",
|
||||
is_carry_forward=1,
|
||||
@@ -125,6 +129,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
|
||||
# initial leave allocation
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave_expiry",
|
||||
from_date=add_months(nowdate(), -24),
|
||||
to_date=add_months(nowdate(), -12),
|
||||
@@ -132,6 +138,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
leave_allocation.submit()
|
||||
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave_expiry",
|
||||
from_date=add_days(nowdate(), -90),
|
||||
to_date=add_days(nowdate(), 100),
|
||||
@@ -143,6 +151,8 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
|
||||
# leave allocation with carry forward of only new leaves allocated
|
||||
leave_allocation_1 = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name,
|
||||
leave_type="_Test_CF_leave_expiry",
|
||||
carry_forward=1,
|
||||
from_date=add_months(nowdate(), 6),
|
||||
@@ -152,9 +162,10 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
self.assertEqual(leave_allocation_1.unused_leaves, leave_allocation.new_leaves_allocated)
|
||||
|
||||
def test_creation_of_leave_ledger_entry_on_submit(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
|
||||
leave_allocation = create_leave_allocation()
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name
|
||||
)
|
||||
leave_allocation.submit()
|
||||
|
||||
leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_allocation.name))
|
||||
@@ -169,10 +180,10 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name}))
|
||||
|
||||
def test_leave_addition_after_submit(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
frappe.db.sql("delete from `tabLeave Ledger Entry`")
|
||||
|
||||
leave_allocation = create_leave_allocation()
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name
|
||||
)
|
||||
leave_allocation.submit()
|
||||
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
|
||||
leave_allocation.new_leaves_allocated = 40
|
||||
@@ -180,44 +191,55 @@ class TestLeaveAllocation(unittest.TestCase):
|
||||
self.assertTrue(leave_allocation.total_leaves_allocated, 40)
|
||||
|
||||
def test_leave_subtraction_after_submit(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
frappe.db.sql("delete from `tabLeave Ledger Entry`")
|
||||
leave_allocation = create_leave_allocation()
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name
|
||||
)
|
||||
leave_allocation.submit()
|
||||
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
|
||||
leave_allocation.new_leaves_allocated = 10
|
||||
leave_allocation.submit()
|
||||
self.assertTrue(leave_allocation.total_leaves_allocated, 10)
|
||||
|
||||
def test_against_leave_application_validation_after_submit(self):
|
||||
frappe.db.sql("delete from `tabLeave Allocation`")
|
||||
frappe.db.sql("delete from `tabLeave Ledger Entry`")
|
||||
def test_validation_against_leave_application_after_submit(self):
|
||||
from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
|
||||
|
||||
leave_allocation = create_leave_allocation()
|
||||
make_holiday_list()
|
||||
frappe.db.set_value("Company", self.employee.company, "default_holiday_list", "Salary Slip Test Holiday List")
|
||||
|
||||
leave_allocation = create_leave_allocation(
|
||||
employee=self.employee.name,
|
||||
employee_name=self.employee.employee_name
|
||||
)
|
||||
leave_allocation.submit()
|
||||
self.assertTrue(leave_allocation.total_leaves_allocated, 15)
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
|
||||
leave_application = frappe.get_doc({
|
||||
"doctype": 'Leave Application',
|
||||
"employee": employee.name,
|
||||
"employee": self.employee.name,
|
||||
"leave_type": "_Test Leave Type",
|
||||
"from_date": add_months(nowdate(), 2),
|
||||
"to_date": add_months(add_days(nowdate(), 10), 2),
|
||||
"company": erpnext.get_default_company() or "_Test Company",
|
||||
"company": self.employee.company,
|
||||
"docstatus": 1,
|
||||
"status": "Approved",
|
||||
"leave_approver": 'test@example.com'
|
||||
})
|
||||
leave_application.submit()
|
||||
leave_allocation.new_leaves_allocated = 8
|
||||
leave_allocation.total_leaves_allocated = 8
|
||||
leave_application.reload()
|
||||
|
||||
# allocate less leaves than the ones which are already approved
|
||||
leave_allocation.new_leaves_allocated = leave_application.total_leave_days - 1
|
||||
leave_allocation.total_leaves_allocated = leave_application.total_leave_days - 1
|
||||
self.assertRaises(frappe.ValidationError, leave_allocation.submit)
|
||||
|
||||
def create_leave_allocation(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0])
|
||||
leave_allocation = frappe.get_doc({
|
||||
emp_id = make_employee("test_emp_leave_allocation@salary.com")
|
||||
employee = frappe.get_doc("Employee", emp_id)
|
||||
|
||||
return frappe.get_doc({
|
||||
"doctype": "Leave Allocation",
|
||||
"__islocal": 1,
|
||||
"employee": args.employee or employee.name,
|
||||
@@ -228,6 +250,5 @@ def create_leave_allocation(**args):
|
||||
"carry_forward": args.carry_forward or 0,
|
||||
"to_date": args.to_date or add_months(nowdate(), 12)
|
||||
})
|
||||
return leave_allocation
|
||||
|
||||
test_dependencies = ["Employee", "Leave Type"]
|
||||
|
||||
@@ -131,6 +131,8 @@ class LeavePolicyAssignment(Document):
|
||||
monthly_earned_leave = get_monthly_earned_leave(new_leaves_allocated,
|
||||
leave_type_details.get(leave_type).earned_leave_frequency, leave_type_details.get(leave_type).rounding)
|
||||
new_leaves_allocated = monthly_earned_leave * months_passed
|
||||
else:
|
||||
new_leaves_allocated = 0
|
||||
|
||||
return new_leaves_allocated
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, get_first_day, getdate
|
||||
|
||||
from erpnext.hr.doctype.leave_application.test_leave_application import (
|
||||
get_employee,
|
||||
@@ -17,9 +18,8 @@ from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
|
||||
test_dependencies = ["Employee"]
|
||||
|
||||
class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
|
||||
def setUp(self):
|
||||
for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
|
||||
for doctype in ["Leave Period", "Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
|
||||
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
|
||||
|
||||
def test_grant_leaves(self):
|
||||
@@ -54,8 +54,8 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
|
||||
self.assertEqual(leave_alloc_doc.new_leaves_allocated, 10)
|
||||
self.assertEqual(leave_alloc_doc.leave_type, "_Test Leave Type")
|
||||
self.assertEqual(leave_alloc_doc.from_date, leave_period.from_date)
|
||||
self.assertEqual(leave_alloc_doc.to_date, leave_period.to_date)
|
||||
self.assertEqual(getdate(leave_alloc_doc.from_date), getdate(leave_period.from_date))
|
||||
self.assertEqual(getdate(leave_alloc_doc.to_date), getdate(leave_period.to_date))
|
||||
self.assertEqual(leave_alloc_doc.leave_policy, leave_policy.name)
|
||||
self.assertEqual(leave_alloc_doc.leave_policy_assignment, leave_policy_assignments[0])
|
||||
|
||||
@@ -101,6 +101,55 @@ class TestLeavePolicyAssignment(unittest.TestCase):
|
||||
# User are now allowed to grant leave
|
||||
self.assertEqual(leave_policy_assignment_doc.leaves_allocated, 0)
|
||||
|
||||
def test_earned_leave_allocation(self):
|
||||
leave_period = create_leave_period("Test Earned Leave Period")
|
||||
employee = get_employee()
|
||||
leave_type = create_earned_leave_type("Test Earned Leave")
|
||||
|
||||
leave_policy = frappe.get_doc({
|
||||
"doctype": "Leave Policy",
|
||||
"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
|
||||
}).insert()
|
||||
|
||||
data = {
|
||||
"assignment_based_on": "Leave Period",
|
||||
"leave_policy": leave_policy.name,
|
||||
"leave_period": leave_period.name
|
||||
}
|
||||
leave_policy_assignments = create_assignment_for_multiple_employees([employee.name], frappe._dict(data))
|
||||
|
||||
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
|
||||
leaves_allocated = frappe.db.get_value("Leave Allocation", {
|
||||
"leave_policy_assignment": leave_policy_assignments[0]
|
||||
}, "total_leaves_allocated")
|
||||
self.assertEqual(leaves_allocated, 0)
|
||||
|
||||
def tearDown(self):
|
||||
for doctype in ["Leave Application", "Leave Allocation", "Leave Policy Assignment", "Leave Ledger Entry"]:
|
||||
frappe.db.sql("delete from `tab{0}`".format(doctype)) #nosec
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
def create_earned_leave_type(leave_type):
|
||||
frappe.delete_doc_if_exists("Leave Type", leave_type, force=1)
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
leave_type_name=leave_type,
|
||||
doctype="Leave Type",
|
||||
is_earned_leave=1,
|
||||
earned_leave_frequency="Monthly",
|
||||
rounding=0.5,
|
||||
max_leaves_allowed=6
|
||||
)).insert()
|
||||
|
||||
|
||||
def create_leave_period(name):
|
||||
frappe.delete_doc_if_exists("Leave Period", name, force=1)
|
||||
start_date = get_first_day(getdate())
|
||||
|
||||
return frappe.get_doc(dict(
|
||||
name=name,
|
||||
doctype="Leave Period",
|
||||
from_date=start_date,
|
||||
to_date=add_months(start_date, 12),
|
||||
company="_Test Company",
|
||||
is_active=1
|
||||
)).insert()
|
||||
@@ -240,12 +240,14 @@
|
||||
"label": "Repayment Schedule"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"depends_on": "eval:doc.is_term_loan == 1",
|
||||
"fieldname": "repayment_schedule",
|
||||
"fieldtype": "Table",
|
||||
"label": "Repayment Schedule",
|
||||
"no_copy": 1,
|
||||
"options": "Repayment Schedule"
|
||||
"options": "Repayment Schedule",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_17",
|
||||
@@ -359,10 +361,11 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-10-12 18:10:32.360818",
|
||||
"modified": "2021-10-20 08:28:16.796105",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Loan Management",
|
||||
"name": "Loan",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -389,4 +392,4 @@
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import math
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_months, flt, getdate, now_datetime, nowdate
|
||||
from frappe.utils import add_months, flt, get_last_day, getdate, now_datetime, nowdate
|
||||
from six import string_types
|
||||
|
||||
import erpnext
|
||||
@@ -63,7 +63,7 @@ class Loan(AccountsController):
|
||||
self.rate_of_interest = frappe.db.get_value("Loan Type", self.loan_type, "rate_of_interest")
|
||||
|
||||
if self.repayment_method == "Repay Over Number of Periods":
|
||||
self.monthly_repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
|
||||
self.monthly_repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
|
||||
|
||||
def check_sanctioned_amount_limit(self):
|
||||
sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company)
|
||||
@@ -100,7 +100,7 @@ class Loan(AccountsController):
|
||||
"total_payment": total_payment,
|
||||
"balance_loan_amount": balance_amount
|
||||
})
|
||||
next_payment_date = add_months(payment_date, 1)
|
||||
next_payment_date = add_single_month(payment_date)
|
||||
payment_date = next_payment_date
|
||||
|
||||
def set_repayment_period(self):
|
||||
@@ -212,7 +212,7 @@ def validate_repayment_method(repayment_method, loan_amount, monthly_repayment_a
|
||||
if monthly_repayment_amount > loan_amount:
|
||||
frappe.throw(_("Monthly Repayment Amount cannot be greater than Loan Amount"))
|
||||
|
||||
def get_monthly_repayment_amount(repayment_method, loan_amount, rate_of_interest, repayment_periods):
|
||||
def get_monthly_repayment_amount(loan_amount, rate_of_interest, repayment_periods):
|
||||
if rate_of_interest:
|
||||
monthly_interest_rate = flt(rate_of_interest) / (12 *100)
|
||||
monthly_repayment_amount = math.ceil((loan_amount * monthly_interest_rate *
|
||||
@@ -396,3 +396,9 @@ def get_shortfall_applicants():
|
||||
"value": len(applicants),
|
||||
"fieldtype": "Int"
|
||||
}
|
||||
|
||||
def add_single_month(date):
|
||||
if getdate(date) == get_last_day(date):
|
||||
return get_last_day(add_months(date, 1))
|
||||
else:
|
||||
return add_months(date, 1)
|
||||
@@ -218,6 +218,14 @@ class TestLoan(unittest.TestCase):
|
||||
self.assertEqual(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid -
|
||||
penalty_amount - total_interest_paid, 0))
|
||||
|
||||
# Check Repayment Entry cancel
|
||||
repayment_entry.load_from_db()
|
||||
repayment_entry.cancel()
|
||||
|
||||
loan.load_from_db()
|
||||
self.assertEqual(loan.total_principal_paid, 0)
|
||||
self.assertEqual(loan.total_principal_paid, 0)
|
||||
|
||||
def test_loan_closure(self):
|
||||
pledge = [{
|
||||
"loan_security": "Test Security 1",
|
||||
@@ -295,6 +303,27 @@ class TestLoan(unittest.TestCase):
|
||||
self.assertEqual(amounts[0], 11250.00)
|
||||
self.assertEqual(amounts[1], 78303.00)
|
||||
|
||||
def test_repayment_schedule_update(self):
|
||||
loan = create_loan(self.applicant2, "Personal Loan", 200000, "Repay Over Number of Periods", 4,
|
||||
applicant_type='Customer', repayment_start_date='2021-04-30', posting_date='2021-04-01')
|
||||
|
||||
loan.submit()
|
||||
|
||||
make_loan_disbursement_entry(loan.name, loan.loan_amount, disbursement_date='2021-04-01')
|
||||
|
||||
process_loan_interest_accrual_for_term_loans(posting_date='2021-05-01')
|
||||
process_loan_interest_accrual_for_term_loans(posting_date='2021-06-01')
|
||||
|
||||
repayment_entry = create_repayment_entry(loan.name, self.applicant2, '2021-06-05', 120000)
|
||||
repayment_entry.submit()
|
||||
|
||||
loan.load_from_db()
|
||||
|
||||
self.assertEqual(flt(loan.get('repayment_schedule')[3].principal_amount, 2), 41369.83)
|
||||
self.assertEqual(flt(loan.get('repayment_schedule')[3].interest_amount, 2), 289.59)
|
||||
self.assertEqual(flt(loan.get('repayment_schedule')[3].total_payment, 2), 41659.41)
|
||||
self.assertEqual(flt(loan.get('repayment_schedule')[3].balance_loan_amount, 2), 0)
|
||||
|
||||
def test_security_shortfall(self):
|
||||
pledges = [{
|
||||
"loan_security": "Test Security 2",
|
||||
@@ -938,18 +967,18 @@ def create_loan_application(company, applicant, loan_type, proposed_pledges, rep
|
||||
|
||||
|
||||
def create_loan(applicant, loan_type, loan_amount, repayment_method, repayment_periods,
|
||||
repayment_start_date=None, posting_date=None):
|
||||
applicant_type=None, repayment_start_date=None, posting_date=None):
|
||||
|
||||
loan = frappe.get_doc({
|
||||
"doctype": "Loan",
|
||||
"applicant_type": "Employee",
|
||||
"applicant_type": applicant_type or "Employee",
|
||||
"company": "_Test Company",
|
||||
"applicant": applicant,
|
||||
"loan_type": loan_type,
|
||||
"loan_amount": loan_amount,
|
||||
"repayment_method": repayment_method,
|
||||
"repayment_periods": repayment_periods,
|
||||
"repayment_start_date": nowdate(),
|
||||
"repayment_start_date": repayment_start_date or nowdate(),
|
||||
"is_term_loan": 1,
|
||||
"posting_date": posting_date or nowdate()
|
||||
})
|
||||
|
||||
@@ -81,7 +81,7 @@ class LoanApplication(Document):
|
||||
|
||||
if self.is_term_loan:
|
||||
if self.repayment_method == "Repay Over Number of Periods":
|
||||
self.repayment_amount = get_monthly_repayment_amount(self.repayment_method, self.loan_amount, self.rate_of_interest, self.repayment_periods)
|
||||
self.repayment_amount = get_monthly_repayment_amount(self.loan_amount, self.rate_of_interest, self.repayment_periods)
|
||||
|
||||
if self.repayment_method == "Repay Fixed Amount per Period":
|
||||
monthly_interest_rate = flt(self.rate_of_interest) / (12 *100)
|
||||
|
||||
@@ -176,20 +176,19 @@ def get_total_pledged_security_value(loan):
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_disbursal_amount(loan, on_current_security_price=0):
|
||||
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
|
||||
get_pending_principal_amount,
|
||||
)
|
||||
|
||||
loan_details = frappe.get_value("Loan", loan, ["loan_amount", "disbursed_amount", "total_payment",
|
||||
"total_principal_paid", "total_interest_payable", "status", "is_term_loan", "is_secured_loan",
|
||||
"maximum_loan_amount"], as_dict=1)
|
||||
"maximum_loan_amount", "written_off_amount"], as_dict=1)
|
||||
|
||||
if loan_details.is_secured_loan and frappe.get_all('Loan Security Shortfall', filters={'loan': loan,
|
||||
'status': 'Pending'}):
|
||||
return 0
|
||||
|
||||
if loan_details.status == 'Disbursed':
|
||||
pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
|
||||
- flt(loan_details.total_principal_paid)
|
||||
else:
|
||||
pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
|
||||
- flt(loan_details.total_principal_paid)
|
||||
pending_principal_amount = get_pending_principal_amount(loan_details)
|
||||
|
||||
security_value = 0.0
|
||||
if loan_details.is_secured_loan and on_current_security_price:
|
||||
|
||||
@@ -74,6 +74,39 @@ class LoanInterestAccrual(AccountsController):
|
||||
})
|
||||
)
|
||||
|
||||
if self.payable_principal_amount:
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": self.loan_account,
|
||||
"party_type": self.applicant_type,
|
||||
"party": self.applicant,
|
||||
"against": self.interest_income_account,
|
||||
"debit": self.payable_principal_amount,
|
||||
"debit_in_account_currency": self.interest_amount,
|
||||
"against_voucher_type": "Loan",
|
||||
"against_voucher": self.loan,
|
||||
"remarks": _("Interest accrued from {0} to {1} against loan: {2}").format(
|
||||
self.last_accrual_date, self.posting_date, self.loan),
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
"posting_date": self.posting_date
|
||||
})
|
||||
)
|
||||
|
||||
gle_map.append(
|
||||
self.get_gl_dict({
|
||||
"account": self.interest_income_account,
|
||||
"against": self.loan_account,
|
||||
"credit": self.payable_principal_amount,
|
||||
"credit_in_account_currency": self.interest_amount,
|
||||
"against_voucher_type": "Loan",
|
||||
"against_voucher": self.loan,
|
||||
"remarks": ("Interest accrued from {0} to {1} against loan: {2}").format(
|
||||
self.last_accrual_date, self.posting_date, self.loan),
|
||||
"cost_center": erpnext.get_default_cost_center(self.company),
|
||||
"posting_date": self.posting_date
|
||||
})
|
||||
)
|
||||
|
||||
if gle_map:
|
||||
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
|
||||
|
||||
@@ -82,7 +115,10 @@ class LoanInterestAccrual(AccountsController):
|
||||
# rate of interest is 13.5 then first loan interest accural will be on '01-10-2019'
|
||||
# which means interest will be accrued for 30 days which should be equal to 11095.89
|
||||
def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_interest, accrual_type):
|
||||
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts
|
||||
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
|
||||
calculate_amounts,
|
||||
get_pending_principal_amount,
|
||||
)
|
||||
|
||||
no_of_days = get_no_of_days_for_interest_accural(loan, posting_date)
|
||||
precision = cint(frappe.db.get_default("currency_precision")) or 2
|
||||
@@ -90,12 +126,7 @@ def calculate_accrual_amount_for_demand_loans(loan, posting_date, process_loan_i
|
||||
if no_of_days <= 0:
|
||||
return
|
||||
|
||||
if loan.status == 'Disbursed':
|
||||
pending_principal_amount = flt(loan.total_payment) - flt(loan.total_interest_payable) \
|
||||
- flt(loan.total_principal_paid) - flt(loan.written_off_amount)
|
||||
else:
|
||||
pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_interest_payable) \
|
||||
- flt(loan.total_principal_paid) - flt(loan.written_off_amount)
|
||||
pending_principal_amount = get_pending_principal_amount(loan)
|
||||
|
||||
interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date)
|
||||
payable_interest = interest_per_day * no_of_days
|
||||
@@ -133,7 +164,7 @@ def make_accrual_interest_entry_for_demand_loans(posting_date, process_loan_inte
|
||||
|
||||
if not open_loans:
|
||||
open_loans = frappe.get_all("Loan",
|
||||
fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account",
|
||||
fields=["name", "total_payment", "total_amount_paid", "loan_account", "interest_income_account", "loan_amount",
|
||||
"is_term_loan", "status", "disbursement_date", "disbursed_amount", "applicant_type", "applicant",
|
||||
"rate_of_interest", "total_interest_payable", "written_off_amount", "total_principal_paid", "repayment_start_date"],
|
||||
filters=query_filters)
|
||||
@@ -190,7 +221,8 @@ def get_term_loans(date, term_loan=None, loan_type=None):
|
||||
AND l.is_term_loan =1
|
||||
AND rs.payment_date <= %s
|
||||
AND rs.is_accrued=0 {0}
|
||||
AND l.status = 'Disbursed'""".format(condition), (getdate(date)), as_dict=1)
|
||||
AND l.status = 'Disbursed'
|
||||
ORDER BY rs.payment_date""".format(condition), (getdate(date)), as_dict=1)
|
||||
|
||||
return term_loans
|
||||
|
||||
|
||||
@@ -36,9 +36,12 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
def on_submit(self):
|
||||
self.update_paid_amount()
|
||||
self.update_repayment_schedule()
|
||||
self.make_gl_entries()
|
||||
|
||||
def on_cancel(self):
|
||||
self.check_future_accruals()
|
||||
self.update_repayment_schedule(cancel=1)
|
||||
self.mark_as_unpaid()
|
||||
self.ignore_linked_doctypes = ['GL Entry']
|
||||
self.make_gl_entries(cancel=1)
|
||||
@@ -91,7 +94,7 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
def book_unaccrued_interest(self):
|
||||
precision = cint(frappe.db.get_default("currency_precision")) or 2
|
||||
if self.total_interest_paid > self.interest_payable:
|
||||
if flt(self.total_interest_paid, precision) > flt(self.interest_payable, precision):
|
||||
if not self.is_term_loan:
|
||||
# get last loan interest accrual date
|
||||
last_accrual_date = get_last_accrual_date(self.against_loan)
|
||||
@@ -122,7 +125,18 @@ class LoanRepayment(AccountsController):
|
||||
})
|
||||
|
||||
def update_paid_amount(self):
|
||||
loan = frappe.get_doc("Loan", self.against_loan)
|
||||
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
|
||||
'written_off_amount'], as_dict=1)
|
||||
|
||||
loan.update({
|
||||
'total_amount_paid': loan.total_amount_paid + self.amount_paid,
|
||||
'total_principal_paid': loan.total_principal_paid + self.principal_amount_paid
|
||||
})
|
||||
|
||||
pending_principal_amount = get_pending_principal_amount(loan)
|
||||
if not loan.is_secured_loan and pending_principal_amount <= 0:
|
||||
loan.update({'status': 'Loan Closure Requested'})
|
||||
|
||||
for payment in self.repayment_details:
|
||||
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
|
||||
@@ -131,17 +145,31 @@ class LoanRepayment(AccountsController):
|
||||
WHERE name = %s""",
|
||||
(flt(payment.paid_principal_amount), flt(payment.paid_interest_amount), payment.loan_interest_accrual))
|
||||
|
||||
frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
|
||||
WHERE name = %s """, (loan.total_amount_paid + self.amount_paid,
|
||||
loan.total_principal_paid + self.principal_amount_paid, self.against_loan))
|
||||
frappe.db.sql(""" UPDATE `tabLoan`
|
||||
SET total_amount_paid = %s, total_principal_paid = %s, status = %s
|
||||
WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status,
|
||||
self.against_loan))
|
||||
|
||||
update_shortfall_status(self.against_loan, self.principal_amount_paid)
|
||||
|
||||
def mark_as_unpaid(self):
|
||||
loan = frappe.get_doc("Loan", self.against_loan)
|
||||
loan = frappe.get_value("Loan", self.against_loan, ['total_amount_paid', 'total_principal_paid',
|
||||
'status', 'is_secured_loan', 'total_payment', 'loan_amount', 'total_interest_payable',
|
||||
'written_off_amount'], as_dict=1)
|
||||
|
||||
no_of_repayments = len(self.repayment_details)
|
||||
|
||||
loan.update({
|
||||
'total_amount_paid': loan.total_amount_paid - self.amount_paid,
|
||||
'total_principal_paid': loan.total_principal_paid - self.principal_amount_paid
|
||||
})
|
||||
|
||||
if loan.status == 'Loan Closure Requested':
|
||||
if loan.disbursed_amount >= loan.loan_amount:
|
||||
loan['status'] = 'Disbursed'
|
||||
else:
|
||||
loan['status'] = 'Partially Disbursed'
|
||||
|
||||
for payment in self.repayment_details:
|
||||
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
|
||||
SET paid_principal_amount = `paid_principal_amount` - %s,
|
||||
@@ -155,12 +183,20 @@ class LoanRepayment(AccountsController):
|
||||
lia_doc = frappe.get_doc('Loan Interest Accrual', payment.loan_interest_accrual)
|
||||
lia_doc.cancel()
|
||||
|
||||
frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s
|
||||
WHERE name = %s """, (loan.total_amount_paid - self.amount_paid,
|
||||
loan.total_principal_paid - self.principal_amount_paid, self.against_loan))
|
||||
frappe.db.sql(""" UPDATE `tabLoan`
|
||||
SET total_amount_paid = %s, total_principal_paid = %s, status = %s
|
||||
WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status, self.against_loan))
|
||||
|
||||
if loan.status == "Loan Closure Requested":
|
||||
frappe.db.set_value("Loan", self.against_loan, "status", "Disbursed")
|
||||
def check_future_accruals(self):
|
||||
future_accrual_date = frappe.db.get_value("Loan Interest Accrual", {"posting_date": (">", self.posting_date),
|
||||
"docstatus": 1, "loan": self.against_loan}, 'posting_date')
|
||||
|
||||
if future_accrual_date:
|
||||
frappe.throw("Cannot cancel. Interest accruals already processed till {0}".format(get_datetime(future_accrual_date)))
|
||||
|
||||
def update_repayment_schedule(self, cancel=0):
|
||||
if self.is_term_loan and self.principal_amount_paid > self.payable_principal_amount:
|
||||
regenerate_repayment_schedule(self.against_loan, cancel)
|
||||
|
||||
def allocate_amounts(self, repayment_details):
|
||||
self.set('repayment_details', [])
|
||||
@@ -183,50 +219,93 @@ class LoanRepayment(AccountsController):
|
||||
|
||||
interest_paid -= self.total_penalty_paid
|
||||
|
||||
total_interest_paid = 0
|
||||
# interest_paid = self.amount_paid - self.principal_amount_paid - self.penalty_amount
|
||||
if self.is_term_loan:
|
||||
interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
|
||||
self.allocate_principal_amount_for_term_loans(interest_paid, repayment_details, updated_entries)
|
||||
else:
|
||||
interest_paid, updated_entries = self.allocate_interest_amount(interest_paid, repayment_details)
|
||||
self.allocate_excess_payment_for_demand_loans(interest_paid, repayment_details)
|
||||
|
||||
def allocate_interest_amount(self, interest_paid, repayment_details):
|
||||
updated_entries = {}
|
||||
self.total_interest_paid = 0
|
||||
idx = 1
|
||||
|
||||
if interest_paid > 0:
|
||||
for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])):
|
||||
if amounts['interest_amount'] + amounts['payable_principal_amount'] <= interest_paid:
|
||||
interest_amount = 0
|
||||
if amounts['interest_amount'] <= interest_paid:
|
||||
interest_amount = amounts['interest_amount']
|
||||
paid_principal = amounts['payable_principal_amount']
|
||||
self.principal_amount_paid += paid_principal
|
||||
interest_paid -= (interest_amount + paid_principal)
|
||||
self.total_interest_paid += interest_amount
|
||||
interest_paid -= interest_amount
|
||||
elif interest_paid:
|
||||
if interest_paid >= amounts['interest_amount']:
|
||||
interest_amount = amounts['interest_amount']
|
||||
paid_principal = interest_paid - interest_amount
|
||||
self.principal_amount_paid += paid_principal
|
||||
self.total_interest_paid += interest_amount
|
||||
interest_paid = 0
|
||||
else:
|
||||
interest_amount = interest_paid
|
||||
self.total_interest_paid += interest_amount
|
||||
interest_paid = 0
|
||||
paid_principal=0
|
||||
|
||||
total_interest_paid += interest_amount
|
||||
self.append('repayment_details', {
|
||||
'loan_interest_accrual': lia,
|
||||
'paid_interest_amount': interest_amount,
|
||||
'paid_principal_amount': paid_principal
|
||||
})
|
||||
if interest_amount:
|
||||
self.append('repayment_details', {
|
||||
'loan_interest_accrual': lia,
|
||||
'paid_interest_amount': interest_amount,
|
||||
'paid_principal_amount': 0
|
||||
})
|
||||
updated_entries[lia] = idx
|
||||
idx += 1
|
||||
|
||||
return interest_paid, updated_entries
|
||||
|
||||
def allocate_principal_amount_for_term_loans(self, interest_paid, repayment_details, updated_entries):
|
||||
if interest_paid > 0:
|
||||
for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])):
|
||||
paid_principal = 0
|
||||
if amounts['payable_principal_amount'] <= interest_paid:
|
||||
paid_principal = amounts['payable_principal_amount']
|
||||
self.principal_amount_paid += paid_principal
|
||||
interest_paid -= paid_principal
|
||||
elif interest_paid:
|
||||
if interest_paid >= amounts['payable_principal_amount']:
|
||||
paid_principal = amounts['payable_principal_amount']
|
||||
self.principal_amount_paid += paid_principal
|
||||
interest_paid = 0
|
||||
else:
|
||||
paid_principal = interest_paid
|
||||
self.principal_amount_paid += paid_principal
|
||||
interest_paid = 0
|
||||
|
||||
if updated_entries.get(lia):
|
||||
idx = updated_entries.get(lia)
|
||||
self.get('repayment_details')[idx-1].paid_principal_amount += paid_principal
|
||||
else:
|
||||
self.append('repayment_details', {
|
||||
'loan_interest_accrual': lia,
|
||||
'paid_interest_amount': 0,
|
||||
'paid_principal_amount': paid_principal
|
||||
})
|
||||
|
||||
if interest_paid > 0:
|
||||
self.principal_amount_paid += interest_paid
|
||||
|
||||
def allocate_excess_payment_for_demand_loans(self, interest_paid, repayment_details):
|
||||
if repayment_details['unaccrued_interest'] and interest_paid > 0:
|
||||
# no of days for which to accrue interest
|
||||
# Interest can only be accrued for an entire day and not partial
|
||||
if interest_paid > repayment_details['unaccrued_interest']:
|
||||
interest_paid -= repayment_details['unaccrued_interest']
|
||||
total_interest_paid += repayment_details['unaccrued_interest']
|
||||
self.total_interest_paid += repayment_details['unaccrued_interest']
|
||||
else:
|
||||
# get no of days for which interest can be paid
|
||||
per_day_interest = get_per_day_interest(self.pending_principal_amount,
|
||||
self.rate_of_interest, self.posting_date)
|
||||
|
||||
no_of_days = cint(interest_paid/per_day_interest)
|
||||
total_interest_paid += no_of_days * per_day_interest
|
||||
self.total_interest_paid += no_of_days * per_day_interest
|
||||
interest_paid -= no_of_days * per_day_interest
|
||||
|
||||
self.total_interest_paid = total_interest_paid
|
||||
if interest_paid > 0:
|
||||
self.principal_amount_paid += interest_paid
|
||||
|
||||
@@ -362,6 +441,76 @@ def get_penalty_details(against_loan):
|
||||
else:
|
||||
return None, 0
|
||||
|
||||
def regenerate_repayment_schedule(loan, cancel=0):
|
||||
from erpnext.loan_management.doctype.loan.loan import (
|
||||
add_single_month,
|
||||
get_monthly_repayment_amount,
|
||||
)
|
||||
|
||||
loan_doc = frappe.get_doc('Loan', loan)
|
||||
next_accrual_date = None
|
||||
accrued_entries = 0
|
||||
last_repayment_amount = 0
|
||||
last_balance_amount = 0
|
||||
|
||||
for term in reversed(loan_doc.get('repayment_schedule')):
|
||||
if not term.is_accrued:
|
||||
next_accrual_date = term.payment_date
|
||||
loan_doc.remove(term)
|
||||
else:
|
||||
accrued_entries += 1
|
||||
if not last_repayment_amount:
|
||||
last_repayment_amount = term.total_payment
|
||||
if not last_balance_amount:
|
||||
last_balance_amount = term.balance_loan_amount
|
||||
|
||||
loan_doc.save()
|
||||
|
||||
balance_amount = get_pending_principal_amount(loan_doc)
|
||||
|
||||
if loan_doc.repayment_method == 'Repay Fixed Amount per Period':
|
||||
monthly_repayment_amount = flt(balance_amount/len(loan_doc.get('repayment_schedule')) - accrued_entries)
|
||||
else:
|
||||
if not cancel:
|
||||
monthly_repayment_amount = get_monthly_repayment_amount(balance_amount,
|
||||
loan_doc.rate_of_interest, loan_doc.repayment_periods - accrued_entries)
|
||||
else:
|
||||
monthly_repayment_amount = last_repayment_amount
|
||||
balance_amount = last_balance_amount
|
||||
|
||||
payment_date = next_accrual_date
|
||||
|
||||
while(balance_amount > 0):
|
||||
interest_amount = flt(balance_amount * flt(loan_doc.rate_of_interest) / (12*100))
|
||||
principal_amount = monthly_repayment_amount - interest_amount
|
||||
balance_amount = flt(balance_amount + interest_amount - monthly_repayment_amount)
|
||||
if balance_amount < 0:
|
||||
principal_amount += balance_amount
|
||||
balance_amount = 0.0
|
||||
|
||||
total_payment = principal_amount + interest_amount
|
||||
loan_doc.append("repayment_schedule", {
|
||||
"payment_date": payment_date,
|
||||
"principal_amount": principal_amount,
|
||||
"interest_amount": interest_amount,
|
||||
"total_payment": total_payment,
|
||||
"balance_loan_amount": balance_amount
|
||||
})
|
||||
next_payment_date = add_single_month(payment_date)
|
||||
payment_date = next_payment_date
|
||||
|
||||
loan_doc.save()
|
||||
|
||||
def get_pending_principal_amount(loan):
|
||||
if loan.status in ('Disbursed', 'Closed') or loan.disbursed_amount >= loan.loan_amount:
|
||||
pending_principal_amount = flt(loan.total_payment) - flt(loan.total_principal_paid) \
|
||||
- flt(loan.total_interest_payable) - flt(loan.written_off_amount)
|
||||
else:
|
||||
pending_principal_amount = flt(loan.disbursed_amount) - flt(loan.total_principal_paid) \
|
||||
- flt(loan.total_interest_payable) - flt(loan.written_off_amount)
|
||||
|
||||
return pending_principal_amount
|
||||
|
||||
# This function returns the amounts that are payable at the time of loan repayment based on posting date
|
||||
# So it pulls all the unpaid Loan Interest Accrual Entries and calculates the penalty if applicable
|
||||
|
||||
@@ -409,12 +558,7 @@ def get_amounts(amounts, against_loan, posting_date):
|
||||
if due_date and not final_due_date:
|
||||
final_due_date = add_days(due_date, loan_type_details.grace_period_in_days)
|
||||
|
||||
if against_loan_doc.status in ('Disbursed', 'Closed') or against_loan_doc.disbursed_amount >= against_loan_doc.loan_amount:
|
||||
pending_principal_amount = against_loan_doc.total_payment - against_loan_doc.total_principal_paid \
|
||||
- against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
|
||||
else:
|
||||
pending_principal_amount = against_loan_doc.disbursed_amount - against_loan_doc.total_principal_paid \
|
||||
- against_loan_doc.total_interest_payable - against_loan_doc.written_off_amount
|
||||
pending_principal_amount = get_pending_principal_amount(against_loan_doc)
|
||||
|
||||
unaccrued_interest = 0
|
||||
if due_date:
|
||||
|
||||
@@ -28,6 +28,9 @@ class LoanSecurityUnpledge(Document):
|
||||
d.idx, frappe.bold(d.loan_security)))
|
||||
|
||||
def validate_unpledge_qty(self):
|
||||
from erpnext.loan_management.doctype.loan_repayment.loan_repayment import (
|
||||
get_pending_principal_amount,
|
||||
)
|
||||
from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import (
|
||||
get_ltv_ratio,
|
||||
)
|
||||
@@ -44,15 +47,10 @@ class LoanSecurityUnpledge(Document):
|
||||
"valid_upto": (">=", get_datetime())
|
||||
}, as_list=1))
|
||||
|
||||
loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid',
|
||||
loan_details = frappe.get_value("Loan", self.loan, ['total_payment', 'total_principal_paid', 'loan_amount',
|
||||
'total_interest_payable', 'written_off_amount', 'disbursed_amount', 'status'], as_dict=1)
|
||||
|
||||
if loan_details.status == 'Disbursed':
|
||||
pending_principal_amount = flt(loan_details.total_payment) - flt(loan_details.total_interest_payable) \
|
||||
- flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
|
||||
else:
|
||||
pending_principal_amount = flt(loan_details.disbursed_amount) - flt(loan_details.total_interest_payable) \
|
||||
- flt(loan_details.total_principal_paid) - flt(loan_details.written_off_amount)
|
||||
pending_principal_amount = get_pending_principal_amount(loan_details)
|
||||
|
||||
security_value = 0
|
||||
unpledge_qty_map = {}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, today
|
||||
|
||||
from erpnext import get_company_currency
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
from .blanket_order import make_order
|
||||
|
||||
|
||||
class TestBlanketOrder(unittest.TestCase):
|
||||
class TestBlanketOrder(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
frappe.flags.args = frappe._dict()
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import unittest
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
|
||||
@@ -18,10 +17,11 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.tests.test_subcontracting import set_backflush_based_on
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_records = frappe.get_test_records('BOM')
|
||||
|
||||
class TestBOM(unittest.TestCase):
|
||||
class TestBOM(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
if not frappe.get_value('Item', '_Test Item'):
|
||||
make_test_records('Item')
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_records = frappe.get_test_records('BOM')
|
||||
|
||||
class TestBOMUpdateTool(unittest.TestCase):
|
||||
class TestBOMUpdateTool(ERPNextTestCase):
|
||||
def test_replace_bom(self):
|
||||
current_bom = "BOM-_Test Item Home Desktop Manufactured-001"
|
||||
|
||||
|
||||
@@ -76,6 +76,15 @@ frappe.ui.form.on('Job Card', {
|
||||
frm.trigger("prepare_timer_buttons");
|
||||
}
|
||||
frm.trigger("setup_quality_inspection");
|
||||
|
||||
if (frm.doc.work_order) {
|
||||
frappe.db.get_value('Work Order', frm.doc.work_order,
|
||||
'transfer_material_against').then((r) => {
|
||||
if (r.message.transfer_material_against == 'Work Order') {
|
||||
frm.set_df_property('items', 'hidden', 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setup_quality_inspection: function(frm) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import random_string
|
||||
@@ -12,9 +11,10 @@ from erpnext.manufacturing.doctype.job_card.job_card import (
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestJobCard(unittest.TestCase):
|
||||
class TestJobCard(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
make_bom_for_jc_tests()
|
||||
|
||||
@@ -329,4 +329,4 @@ def make_bom_for_jc_tests():
|
||||
bom.rm_cost_as_per = "Valuation Rate"
|
||||
bom.items[0].uom = "_Test UOM 1"
|
||||
bom.items[0].conversion_factor = 5
|
||||
bom.insert()
|
||||
bom.insert()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, flt, now_datetime, nowdate
|
||||
|
||||
@@ -17,9 +14,10 @@ from erpnext.stock.doctype.item.test_item import create_item
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestProductionPlan(unittest.TestCase):
|
||||
class TestProductionPlan(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
for item in ['Test Production Item 1', 'Subassembly Item 1',
|
||||
'Raw Material Item 1', 'Raw Material Item 2']:
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
|
||||
from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestRouting(unittest.TestCase):
|
||||
class TestRouting(ERPNextTestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.item_code = "Test Routing Item - A"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_months, cint, flt, now, today
|
||||
@@ -21,9 +20,10 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item
|
||||
from erpnext.stock.doctype.stock_entry import test_stock_entry
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.stock.utils import get_bin
|
||||
from erpnext.tests.utils import ERPNextTestCase, timeout
|
||||
|
||||
|
||||
class TestWorkOrder(unittest.TestCase):
|
||||
class TestWorkOrder(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
self.warehouse = '_Test Warehouse 2 - _TC'
|
||||
self.item = '_Test Item'
|
||||
@@ -91,7 +91,7 @@ class TestWorkOrder(unittest.TestCase):
|
||||
|
||||
def test_reserved_qty_for_partial_completion(self):
|
||||
item = "_Test Item"
|
||||
warehouse = create_warehouse("Test Warehouse for reserved_qty - _TC")
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
bin1_at_start = get_bin(item, warehouse)
|
||||
|
||||
@@ -196,8 +196,6 @@ class TestWorkOrder(unittest.TestCase):
|
||||
# no change in reserved / projected
|
||||
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
|
||||
cint(bin1_on_start_production.reserved_qty_for_production))
|
||||
self.assertEqual(cint(bin1_on_end_production.projected_qty),
|
||||
cint(bin1_on_end_production.projected_qty))
|
||||
|
||||
def test_backflush_qty_for_overpduction_manufacture(self):
|
||||
cancel_stock_entry = []
|
||||
@@ -376,6 +374,7 @@ class TestWorkOrder(unittest.TestCase):
|
||||
self.assertEqual(len(ste.additional_costs), 1)
|
||||
self.assertEqual(ste.total_additional_costs, 1000)
|
||||
|
||||
@timeout(seconds=60)
|
||||
def test_job_card(self):
|
||||
stock_entries = []
|
||||
bom = frappe.get_doc('BOM', {
|
||||
@@ -769,6 +768,7 @@ class TestWorkOrder(unittest.TestCase):
|
||||
total_pl_qty
|
||||
)
|
||||
|
||||
@timeout(seconds=60)
|
||||
def test_job_card_scrap_item(self):
|
||||
items = ['Test FG Item for Scrap Item Test', 'Test RM Item 1 for Scrap Item Test',
|
||||
'Test RM Item 2 for Scrap Item Test']
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
|
||||
# See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
|
||||
@@ -13,12 +10,13 @@ from erpnext.manufacturing.doctype.workstation.workstation import (
|
||||
WorkstationHolidayError,
|
||||
check_if_within_operating_hours,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_dependencies = ["Warehouse"]
|
||||
test_records = frappe.get_test_records('Workstation')
|
||||
make_test_records('Workstation')
|
||||
|
||||
class TestWorkstation(unittest.TestCase):
|
||||
class TestWorkstation(ERPNextTestCase):
|
||||
def test_validate_timings(self):
|
||||
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 11:00:00", "2013-02-02 19:00:00")
|
||||
check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00")
|
||||
|
||||
@@ -410,7 +410,7 @@ def get_plan_from_razorpay_id(plan_id):
|
||||
def set_expired_status():
|
||||
frappe.db.sql("""
|
||||
UPDATE
|
||||
`tabMembership` SET `status` = 'Expired'
|
||||
`tabMembership` SET `membership_status` = 'Expired'
|
||||
WHERE
|
||||
`status` not in ('Cancelled') AND `to_date` < %s
|
||||
`membership_status` not in ('Cancelled') AND `to_date` < %s
|
||||
""", (nowdate()))
|
||||
|
||||
@@ -336,4 +336,6 @@ erpnext.patches.v13_0.item_naming_series_not_mandatory
|
||||
erpnext.patches.v13_0.update_category_in_ltds_certificate
|
||||
erpnext.patches.v13_0.create_ksa_vat_custom_fields
|
||||
erpnext.patches.v13_0.rename_ksa_qr_field
|
||||
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
|
||||
erpnext.patches.v13_0.convert_to_website_item_in_item_card_group_template
|
||||
|
||||
@@ -23,4 +23,5 @@ def execute():
|
||||
|
||||
delivery_settings = frappe.get_doc("Delivery Settings")
|
||||
delivery_settings.dispatch_template = _("Dispatch Notification")
|
||||
delivery_settings.flags.ignore_links = True
|
||||
delivery_settings.save()
|
||||
|
||||
@@ -98,6 +98,8 @@ def execute():
|
||||
'itc_central_tax': 0,
|
||||
'itc_cess_amount': 0
|
||||
})
|
||||
if not gst_accounts:
|
||||
continue
|
||||
|
||||
if d.account_head in gst_accounts.get('igst_account'):
|
||||
amount_map[d.parent]['itc_integrated_tax'] += d.amount
|
||||
|
||||
@@ -7,7 +7,7 @@ def execute():
|
||||
frappe.reload_doc("selling", "doctype", "sales_order_item")
|
||||
|
||||
for doctype in ["Sales Order", "Material Request"]:
|
||||
condition = " and child_doc.stock_qty > child_doc.produced_qty"
|
||||
condition = " and child_doc.stock_qty > child_doc.produced_qty and doc.per_delivered < 100"
|
||||
if doctype == "Material Request":
|
||||
condition = " and doc.per_ordered < 100 and doc.material_request_type = 'Manufacture'"
|
||||
|
||||
@@ -16,5 +16,6 @@ def execute():
|
||||
child_doc.bom_no = item.default_bom
|
||||
WHERE
|
||||
child_doc.item_code = item.name and child_doc.docstatus < 2
|
||||
and child_doc.parent = doc.name
|
||||
and item.default_bom is not null and item.default_bom != '' {cond}
|
||||
""".format(doc = doctype, cond = condition))
|
||||
|
||||
@@ -33,4 +33,5 @@ def execute():
|
||||
hr_settings = frappe.get_doc('HR Settings')
|
||||
hr_settings.interview_reminder_template = _('Interview Reminder')
|
||||
hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder')
|
||||
hr_settings.flags.ignore_links = True
|
||||
hr_settings.save()
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import json
|
||||
from typing import List, Union
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.e_commerce.doctype.website_item.website_item import make_website_item
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
Convert all Item links to Website Item link values in
|
||||
exisitng 'Item Card Group' Web Page Block data.
|
||||
"""
|
||||
frappe.reload_doc("e_commerce", "web_template", "item_card_group")
|
||||
|
||||
blocks = frappe.db.get_all(
|
||||
"Web Page Block",
|
||||
filters={"web_template": "Item Card Group"},
|
||||
fields=["parent", "web_template_values", "name"]
|
||||
)
|
||||
|
||||
fields = generate_fields_to_edit()
|
||||
|
||||
for block in blocks:
|
||||
web_template_value = json.loads(block.get('web_template_values'))
|
||||
|
||||
for field in fields:
|
||||
item = web_template_value.get(field)
|
||||
if not item:
|
||||
continue
|
||||
|
||||
if frappe.db.exists("Website Item", {"item_code": item}):
|
||||
website_item = frappe.db.get_value("Website Item", {"item_code": item})
|
||||
else:
|
||||
website_item = make_new_website_item(item)
|
||||
|
||||
if website_item:
|
||||
web_template_value[field] = website_item
|
||||
|
||||
frappe.db.set_value("Web Page Block", block.name, "web_template_values", json.dumps(web_template_value))
|
||||
|
||||
def generate_fields_to_edit() -> List:
|
||||
fields = []
|
||||
for i in range(1, 13):
|
||||
fields.append(f"card_{i}_item") # fields like 'card_1_item', etc.
|
||||
|
||||
return fields
|
||||
|
||||
def make_new_website_item(item: str) -> Union[str, None]:
|
||||
try:
|
||||
doc = frappe.get_doc("Item", item)
|
||||
web_item = make_website_item(doc) # returns [website_item.name, item_name]
|
||||
return web_item[0]
|
||||
except Exception:
|
||||
title = f"{item}: Error while converting to Website Item "
|
||||
frappe.log_error(title + "for Item Card Group Template" + "\n\n" + frappe.get_traceback(), title=title)
|
||||
return None
|
||||
31
erpnext/patches/v13_0/update_tax_category_for_rcm.py
Normal file
31
erpnext/patches/v13_0/update_tax_category_for_rcm.py
Normal file
@@ -0,0 +1,31 @@
|
||||
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()
|
||||
@@ -350,23 +350,24 @@ class PayrollEntry(Document):
|
||||
currencies = []
|
||||
multi_currency = 0
|
||||
company_currency = erpnext.get_company_currency(self.company)
|
||||
accounting_dimensions = get_accounting_dimensions() or []
|
||||
|
||||
exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(self.payment_account, je_payment_amount, company_currency, currencies)
|
||||
accounts.append({
|
||||
accounts.append(self.update_accounting_dimensions({
|
||||
"account": self.payment_account,
|
||||
"bank_account": self.bank_account,
|
||||
"credit_in_account_currency": flt(amount, precision),
|
||||
"exchange_rate": flt(exchange_rate),
|
||||
})
|
||||
}, accounting_dimensions))
|
||||
|
||||
exchange_rate, amount = self.get_amount_and_exchange_rate_for_journal_entry(payroll_payable_account, je_payment_amount, company_currency, currencies)
|
||||
accounts.append({
|
||||
accounts.append(self.update_accounting_dimensions({
|
||||
"account": payroll_payable_account,
|
||||
"debit_in_account_currency": flt(amount, precision),
|
||||
"exchange_rate": flt(exchange_rate),
|
||||
"reference_type": self.doctype,
|
||||
"reference_name": self.name
|
||||
})
|
||||
}, accounting_dimensions))
|
||||
|
||||
if len(currencies) > 1:
|
||||
multi_currency = 1
|
||||
|
||||
@@ -936,18 +936,23 @@ class SalarySlip(TransactionBase):
|
||||
def get_future_recurring_additional_amount(self, additional_salary, monthly_additional_amount):
|
||||
future_recurring_additional_amount = 0
|
||||
to_date = frappe.db.get_value("Additional Salary", additional_salary, 'to_date')
|
||||
|
||||
# future month count excluding current
|
||||
future_recurring_period = (getdate(to_date).month - getdate(self.start_date).month)
|
||||
from_date, to_date = getdate(self.start_date), getdate(to_date)
|
||||
future_recurring_period = ((to_date.year - from_date.year) * 12) + (to_date.month - from_date.month)
|
||||
|
||||
if future_recurring_period > 0:
|
||||
future_recurring_additional_amount = monthly_additional_amount * future_recurring_period # Used earning.additional_amount to consider the amount for the full month
|
||||
return future_recurring_additional_amount
|
||||
|
||||
def get_amount_based_on_payment_days(self, row, joining_date, relieving_date):
|
||||
amount, additional_amount = row.amount, row.additional_amount
|
||||
timesheet_component = frappe.db.get_value("Salary Structure", self.salary_structure, "salary_component")
|
||||
|
||||
if (self.salary_structure and
|
||||
cint(row.depends_on_payment_days) and cint(self.total_working_days)
|
||||
and not (row.additional_salary and row.default_amount) # to identify overwritten additional salary
|
||||
and (not self.salary_slip_based_on_timesheet or
|
||||
and (row.salary_component != timesheet_component or
|
||||
getdate(self.start_date) < joining_date or
|
||||
(relieving_date and getdate(self.end_date) > relieving_date)
|
||||
)):
|
||||
@@ -956,7 +961,7 @@ class SalarySlip(TransactionBase):
|
||||
amount = flt((flt(row.default_amount) * flt(self.payment_days)
|
||||
/ cint(self.total_working_days)), row.precision("amount")) + additional_amount
|
||||
|
||||
elif not self.payment_days and not self.salary_slip_based_on_timesheet and cint(row.depends_on_payment_days):
|
||||
elif not self.payment_days and row.salary_component != timesheet_component and cint(row.depends_on_payment_days):
|
||||
amount, additional_amount = 0, 0
|
||||
elif not row.amount:
|
||||
amount = flt(row.default_amount) + flt(row.additional_amount)
|
||||
@@ -1034,7 +1039,8 @@ class SalarySlip(TransactionBase):
|
||||
data.update({"annual_taxable_earning": annual_taxable_earning})
|
||||
tax_amount = 0
|
||||
for slab in tax_slab.slabs:
|
||||
if slab.condition and not self.eval_tax_slab_condition(slab.condition, data):
|
||||
cond = cstr(slab.condition).strip()
|
||||
if cond and not self.eval_tax_slab_condition(cond, data):
|
||||
continue
|
||||
if not slab.to_amount and annual_taxable_earning >= slab.from_amount:
|
||||
tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction *.01
|
||||
|
||||
@@ -134,6 +134,58 @@ class TestSalarySlip(unittest.TestCase):
|
||||
|
||||
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
|
||||
|
||||
def test_payment_days_in_salary_slip_based_on_timesheet(self):
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.projects.doctype.timesheet.test_timesheet import (
|
||||
make_salary_structure_for_timesheet,
|
||||
make_timesheet,
|
||||
)
|
||||
from erpnext.projects.doctype.timesheet.timesheet import (
|
||||
make_salary_slip as make_salary_slip_for_timesheet,
|
||||
)
|
||||
|
||||
# Payroll based on attendance
|
||||
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
|
||||
|
||||
emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company")
|
||||
frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
|
||||
|
||||
# mark attendance
|
||||
month_start_date = get_first_day(nowdate())
|
||||
month_end_date = get_last_day(nowdate())
|
||||
|
||||
first_sunday = frappe.db.sql("""
|
||||
select holiday_date from `tabHoliday`
|
||||
where parent = 'Salary Slip Test Holiday List'
|
||||
and holiday_date between %s and %s
|
||||
order by holiday_date
|
||||
""", (month_start_date, month_end_date))[0][0]
|
||||
|
||||
mark_attendance(emp, add_days(first_sunday, 1), 'Absent', ignore_validate=True) # counted as absent
|
||||
|
||||
# salary structure based on timesheet
|
||||
make_salary_structure_for_timesheet(emp)
|
||||
timesheet = make_timesheet(emp, simulate=True, is_billable=1)
|
||||
salary_slip = make_salary_slip_for_timesheet(timesheet.name)
|
||||
salary_slip.start_date = month_start_date
|
||||
salary_slip.end_date = month_end_date
|
||||
salary_slip.save()
|
||||
salary_slip.submit()
|
||||
salary_slip.reload()
|
||||
|
||||
no_of_days = self.get_no_of_days()
|
||||
days_in_month = no_of_days[0]
|
||||
no_of_holidays = no_of_days[1]
|
||||
|
||||
self.assertEqual(salary_slip.payment_days, days_in_month - no_of_holidays - 1)
|
||||
|
||||
# gross pay calculation based on attendance (payment days)
|
||||
gross_pay = 78100 - ((78000 / (days_in_month - no_of_holidays)) * flt(salary_slip.leave_without_pay + salary_slip.absent_days))
|
||||
|
||||
self.assertEqual(salary_slip.gross_pay, flt(gross_pay, 2))
|
||||
|
||||
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
|
||||
|
||||
def test_component_amount_dependent_on_another_payment_days_based_component(self):
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_attendance
|
||||
from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
|
||||
|
||||
@@ -34,10 +34,6 @@ class TestTimesheet(unittest.TestCase):
|
||||
for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]:
|
||||
frappe.db.sql("delete from `tab%s`" % dt)
|
||||
|
||||
if not frappe.db.exists("Salary Component", "Timesheet Component"):
|
||||
frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
|
||||
|
||||
|
||||
def test_timesheet_billing_amount(self):
|
||||
emp = make_employee("test_employee_6@salary.com")
|
||||
|
||||
@@ -160,6 +156,9 @@ def make_salary_structure_for_timesheet(employee, company=None):
|
||||
salary_structure_name = "Timesheet Salary Structure Test"
|
||||
frequency = "Monthly"
|
||||
|
||||
if not frappe.db.exists("Salary Component", "Timesheet Component"):
|
||||
frappe.get_doc({"doctype": "Salary Component", "salary_component": "Timesheet Component"}).insert()
|
||||
|
||||
salary_structure = make_salary_structure(salary_structure_name, frequency, company=company, dont_submit=True)
|
||||
salary_structure.salary_component = "Timesheet Component"
|
||||
salary_structure.salary_slip_based_on_timesheet = 1
|
||||
|
||||
@@ -26,6 +26,7 @@ class TestProjectProfitability(unittest.TestCase):
|
||||
|
||||
self.timesheet = make_timesheet(emp, is_billable=1)
|
||||
self.salary_slip = make_salary_slip(self.timesheet.name)
|
||||
self.salary_slip.start_date = self.timesheet.start_date
|
||||
|
||||
holidays = self.salary_slip.get_holidays_for_employee(date, date)
|
||||
if holidays:
|
||||
@@ -42,8 +43,8 @@ class TestProjectProfitability(unittest.TestCase):
|
||||
def test_project_profitability(self):
|
||||
filters = {
|
||||
'company': '_Test Company',
|
||||
'start_date': add_days(getdate(), -3),
|
||||
'end_date': getdate()
|
||||
'start_date': add_days(self.timesheet.start_date, -3),
|
||||
'end_date': self.timesheet.start_date
|
||||
}
|
||||
|
||||
report = execute(filters)
|
||||
|
||||
@@ -680,7 +680,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
var item = frappe.get_doc(cdt, cdn);
|
||||
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]);
|
||||
|
||||
// check if child doctype is Sales Order Item/Qutation Item and calculate the rate
|
||||
// check if child doctype is Sales Order Item/Quotation Item and calculate the rate
|
||||
if (in_list(["Quotation Item", "Sales Order Item", "Delivery Note Item", "Sales Invoice Item", "POS Invoice Item", "Purchase Invoice Item", "Purchase Order Item", "Purchase Receipt Item"]), cdt)
|
||||
this.apply_pricing_rule_on_item(item);
|
||||
else
|
||||
@@ -1562,25 +1562,27 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
|
||||
_set_values_for_item_list: function(children) {
|
||||
var me = this;
|
||||
var price_list_rate_changed = false;
|
||||
var items_rule_dict = {};
|
||||
|
||||
for(var i=0, l=children.length; i<l; i++) {
|
||||
var d = children[i];
|
||||
var d = children[i] ;
|
||||
let item_row = frappe.get_doc(d.doctype, d.name);
|
||||
var existing_pricing_rule = frappe.model.get_value(d.doctype, d.name, "pricing_rules");
|
||||
for(var k in d) {
|
||||
var v = d[k];
|
||||
if (["doctype", "name"].indexOf(k)===-1) {
|
||||
if(k=="price_list_rate") {
|
||||
if(flt(v) != flt(d.price_list_rate)) price_list_rate_changed = true;
|
||||
item_row['rate'] = v;
|
||||
}
|
||||
|
||||
if (k !== 'free_item_data') {
|
||||
frappe.model.set_value(d.doctype, d.name, k, v);
|
||||
item_row[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frappe.model.round_floats_in(item_row, ["price_list_rate", "discount_percentage"]);
|
||||
|
||||
// if pricing rule set as blank from an existing value, apply price_list
|
||||
if(!me.frm.doc.ignore_pricing_rule && existing_pricing_rule && !d.pricing_rules) {
|
||||
me.apply_price_list(frappe.get_doc(d.doctype, d.name));
|
||||
@@ -1597,9 +1599,10 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
|
||||
}
|
||||
}
|
||||
|
||||
me.frm.refresh_field('items');
|
||||
me.apply_rule_on_other_items(items_rule_dict);
|
||||
|
||||
if(!price_list_rate_changed) me.calculate_taxes_and_totals();
|
||||
me.calculate_taxes_and_totals();
|
||||
},
|
||||
|
||||
apply_rule_on_other_items: function(args) {
|
||||
|
||||
@@ -83,6 +83,13 @@ $.extend(erpnext.queries, {
|
||||
};
|
||||
},
|
||||
|
||||
dispatch_address_query: function(doc) {
|
||||
return {
|
||||
query: 'frappe.contacts.doctype.address.address.address_query',
|
||||
filters: { link_doctype: 'Company', link_name: doc.company || '' }
|
||||
};
|
||||
},
|
||||
|
||||
supplier_filter: function(doc) {
|
||||
if(!doc.supplier) {
|
||||
frappe.throw(__("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "supplier", doc.name))]));
|
||||
|
||||
@@ -430,12 +430,9 @@ erpnext.utils.select_alternate_items = function(opts) {
|
||||
qty = row.qty;
|
||||
}
|
||||
row[item_field] = d.alternate_item;
|
||||
frm.script_manager.trigger(item_field, row.doctype, row.name)
|
||||
.then(() => {
|
||||
frappe.model.set_value(row.doctype, row.name, 'qty', qty);
|
||||
frappe.model.set_value(row.doctype, row.name,
|
||||
opts.original_item_field, d.item_code);
|
||||
});
|
||||
frappe.model.set_value(row.doctype, row.name, 'qty', qty);
|
||||
frappe.model.set_value(row.doctype, row.name, opts.original_item_field, d.item_code);
|
||||
frm.trigger(item_field, row.doctype, row.name);
|
||||
});
|
||||
|
||||
refresh_field(opts.child_docname);
|
||||
|
||||
@@ -284,8 +284,10 @@ def get_custom_fields():
|
||||
inter_state_gst_field = [
|
||||
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_inter_state'),
|
||||
insert_after='is_reverse_charge'),
|
||||
dict(fieldname='gst_state', label='Source State', fieldtype='Select',
|
||||
options='\n'.join(states), insert_after='company')
|
||||
]
|
||||
|
||||
@@ -69,7 +69,8 @@ def validate_pan_for_india(doc, method):
|
||||
frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN."))
|
||||
|
||||
def validate_tax_category(doc, method):
|
||||
if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state}):
|
||||
if doc.get('gst_state') and frappe.db.get_value('Tax Category', {'gst_state': doc.gst_state, 'is_inter_state': doc.is_inter_state,
|
||||
'is_reverse_charge': doc.is_reverse_charge}):
|
||||
if doc.is_inter_state:
|
||||
frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state))
|
||||
else:
|
||||
@@ -216,7 +217,7 @@ def get_regional_address_details(party_details, doctype, company):
|
||||
|
||||
if tax_template_by_category:
|
||||
party_details['taxes_and_charges'] = tax_template_by_category
|
||||
return
|
||||
return party_details
|
||||
|
||||
if not party_details.place_of_supply: return party_details
|
||||
if not party_details.company_gstin: return party_details
|
||||
@@ -266,7 +267,7 @@ def get_tax_template_based_on_category(master_doctype, company, party_details):
|
||||
|
||||
def get_tax_template(master_doctype, company, is_inter_state, state_code):
|
||||
tax_categories = frappe.get_all('Tax Category', fields = ['name', 'is_inter_state', 'gst_state'],
|
||||
filters = {'is_inter_state': is_inter_state})
|
||||
filters = {'is_inter_state': is_inter_state, 'is_reverse_charge': 0})
|
||||
|
||||
default_tax = ''
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.test_runner import make_test_records
|
||||
from frappe.utils import flt
|
||||
@@ -11,7 +9,7 @@ from frappe.utils import flt
|
||||
from erpnext.accounts.party import get_due_date
|
||||
from erpnext.exceptions import PartyDisabled, PartyFrozen
|
||||
from erpnext.selling.doctype.customer.customer import get_credit_limit, get_customer_outstanding
|
||||
from erpnext.tests.utils import create_test_contact_and_address
|
||||
from erpnext.tests.utils import ERPNextTestCase, create_test_contact_and_address
|
||||
|
||||
test_ignore = ["Price List"]
|
||||
test_dependencies = ['Payment Term', 'Payment Terms Template']
|
||||
@@ -20,7 +18,7 @@ test_records = frappe.get_test_records('Customer')
|
||||
from six import iteritems
|
||||
|
||||
|
||||
class TestCustomer(unittest.TestCase):
|
||||
class TestCustomer(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
if not frappe.get_value('Item', '_Test Item'):
|
||||
make_test_records('Item')
|
||||
|
||||
@@ -6,6 +6,7 @@ import unittest
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.queries import item_query
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_dependencies = ['Item', 'Customer', 'Supplier']
|
||||
|
||||
@@ -17,7 +18,7 @@ def create_party_specific_item(**args):
|
||||
psi.based_on_value = args.get('based_on_value')
|
||||
psi.insert()
|
||||
|
||||
class TestPartySpecificItem(unittest.TestCase):
|
||||
class TestPartySpecificItem(ERPNextTestCase):
|
||||
def setUp(self):
|
||||
self.customer = frappe.get_last_doc("Customer")
|
||||
self.supplier = frappe.get_last_doc("Supplier")
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, add_months, flt, getdate, nowdate
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
test_dependencies = ["Product Bundle"]
|
||||
|
||||
|
||||
class TestQuotation(unittest.TestCase):
|
||||
class TestQuotation(ERPNextTestCase):
|
||||
def test_make_quotation_without_terms(self):
|
||||
quotation = make_quotation(do_not_save=1)
|
||||
self.assertFalse(quotation.get('payment_schedule'))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
import frappe.permissions
|
||||
@@ -22,12 +21,14 @@ from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
)
|
||||
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.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestSalesOrder(unittest.TestCase):
|
||||
class TestSalesOrder(ERPNextTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings",
|
||||
"unlink_advance_payment_on_cancelation_of_order"))
|
||||
|
||||
@@ -36,6 +37,7 @@ class TestSalesOrder(unittest.TestCase):
|
||||
# reset config to previous state
|
||||
frappe.db.set_value("Accounts Settings", "Accounts Settings",
|
||||
"unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting)
|
||||
super().tearDownClass()
|
||||
|
||||
def tearDown(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from frappe.utils import add_months, nowdate
|
||||
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_material_request
|
||||
@@ -11,9 +9,10 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde
|
||||
from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestPendingSOItemsForPurchaseRequest(unittest.TestCase):
|
||||
class TestPendingSOItemsForPurchaseRequest(ERPNextTestCase):
|
||||
def test_result_for_partial_material_request(self):
|
||||
so = make_sales_order()
|
||||
mr=make_material_request(so.name)
|
||||
|
||||
@@ -2,15 +2,14 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.selling.report.sales_analytics.sales_analytics import execute
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestAnalytics(unittest.TestCase):
|
||||
class TestAnalytics(ERPNextTestCase):
|
||||
def test_sales_analytics(self):
|
||||
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
|
||||
me.frm.set_query('contact_person', erpnext.queries.contact_query);
|
||||
me.frm.set_query('customer_address', erpnext.queries.address_query);
|
||||
me.frm.set_query('shipping_address_name', erpnext.queries.address_query);
|
||||
me.frm.set_query('dispatch_address_name', erpnext.queries.dispatch_address_query);
|
||||
|
||||
|
||||
if(this.frm.fields_dict.selling_price_list) {
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"abbr": "_TC3",
|
||||
"company_name": "_Test Company 3",
|
||||
"is_group": 1,
|
||||
"country": "India",
|
||||
"country": "Pakistan",
|
||||
"default_currency": "INR",
|
||||
"doctype": "Company",
|
||||
"domain": "Manufacturing",
|
||||
@@ -49,7 +49,7 @@
|
||||
"company_name": "_Test Company 4",
|
||||
"parent_company": "_Test Company 3",
|
||||
"is_group": 1,
|
||||
"country": "India",
|
||||
"country": "Pakistan",
|
||||
"default_currency": "INR",
|
||||
"doctype": "Company",
|
||||
"domain": "Manufacturing",
|
||||
@@ -61,7 +61,7 @@
|
||||
"abbr": "_TC5",
|
||||
"company_name": "_Test Company 5",
|
||||
"parent_company": "_Test Company 4",
|
||||
"country": "India",
|
||||
"country": "Pakistan",
|
||||
"default_currency": "INR",
|
||||
"doctype": "Company",
|
||||
"domain": "Manufacturing",
|
||||
|
||||
@@ -1178,11 +1178,13 @@
|
||||
{
|
||||
"title": "Reverse Charge In-State",
|
||||
"is_inter_state": 0,
|
||||
"is_reverse_charge": 1,
|
||||
"gst_state": ""
|
||||
},
|
||||
{
|
||||
"title": "Reverse Charge Out-State",
|
||||
"is_inter_state": 1,
|
||||
"is_reverse_charge": 1,
|
||||
"gst_state": ""
|
||||
},
|
||||
{
|
||||
|
||||
@@ -33,10 +33,10 @@ class Bin(Document):
|
||||
in open work orders'''
|
||||
self.reserved_qty_for_production = frappe.db.sql('''
|
||||
SELECT
|
||||
CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
|
||||
SUM(item.required_qty - item.transferred_qty)
|
||||
SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
|
||||
item.required_qty - item.transferred_qty
|
||||
ELSE
|
||||
SUM(item.required_qty - item.consumed_qty)
|
||||
item.required_qty - item.consumed_qty END)
|
||||
END
|
||||
FROM `tabWork Order` pro, `tabWork Order Item` item
|
||||
WHERE
|
||||
@@ -103,8 +103,8 @@ def update_stock(bin_name, args, allow_negative_stock=False, via_landed_cost_vou
|
||||
"""WARNING: This function is deprecated. Inline this function instead of using it."""
|
||||
from erpnext.stock.stock_ledger import repost_current_voucher
|
||||
|
||||
update_qty(bin_name, args)
|
||||
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
|
||||
update_qty(bin_name, args)
|
||||
|
||||
def get_bin_details(bin_name):
|
||||
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty',
|
||||
@@ -112,13 +112,23 @@ def get_bin_details(bin_name):
|
||||
'reserved_qty_for_sub_contract'], as_dict=1)
|
||||
|
||||
def update_qty(bin_name, args):
|
||||
bin_details = get_bin_details(bin_name)
|
||||
from erpnext.controllers.stock_controller import future_sle_exists
|
||||
|
||||
# update the stock values (for current quantities)
|
||||
if args.get("voucher_type")=="Stock Reconciliation":
|
||||
actual_qty = args.get('qty_after_transaction')
|
||||
else:
|
||||
actual_qty = bin_details.actual_qty + flt(args.get("actual_qty"))
|
||||
bin_details = get_bin_details(bin_name)
|
||||
# actual qty is already updated by processing current voucher
|
||||
actual_qty = bin_details.actual_qty
|
||||
|
||||
# actual qty is not up to date in case of backdated transaction
|
||||
if future_sle_exists(args):
|
||||
actual_qty = frappe.db.get_value("Stock Ledger Entry",
|
||||
filters={
|
||||
"item_code": args.get("item_code"),
|
||||
"warehouse": args.get("warehouse"),
|
||||
"is_cancelled": 0
|
||||
},
|
||||
fieldname="qty_after_transaction",
|
||||
order_by="posting_date desc, posting_time desc, creation desc",
|
||||
) or 0.0
|
||||
|
||||
ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))
|
||||
reserved_qty = flt(bin_details.reserved_qty) + flt(args.get("reserved_qty"))
|
||||
|
||||
@@ -359,8 +359,7 @@
|
||||
"fieldname": "valuation_method",
|
||||
"fieldtype": "Select",
|
||||
"label": "Valuation Method",
|
||||
"options": "\nFIFO\nMoving Average",
|
||||
"set_only_once": 1
|
||||
"options": "\nFIFO\nMoving Average"
|
||||
},
|
||||
{
|
||||
"depends_on": "is_stock_item",
|
||||
@@ -956,7 +955,7 @@
|
||||
"image_field": "image",
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2021-12-03 08:32:03.869294",
|
||||
"modified": "2021-12-14 04:13:16.857534",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -1,451 +1,140 @@
|
||||
{
|
||||
"allow_copy": 0,
|
||||
"allow_import": 0,
|
||||
"allow_rename": 0,
|
||||
"autoname": "hash",
|
||||
"beta": 0,
|
||||
"creation": "2013-04-08 13:10:16",
|
||||
"custom": 0,
|
||||
"docstatus": 0,
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"actions": [],
|
||||
"autoname": "hash",
|
||||
"creation": "2013-04-08 13:10:16",
|
||||
"doctype": "DocType",
|
||||
"document_type": "Document",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"item_code",
|
||||
"column_break_2",
|
||||
"item_name",
|
||||
"batch_no",
|
||||
"desc_section",
|
||||
"description",
|
||||
"quantity_section",
|
||||
"qty",
|
||||
"net_weight",
|
||||
"column_break_10",
|
||||
"stock_uom",
|
||||
"weight_uom",
|
||||
"page_break",
|
||||
"dn_detail"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 1,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Item Code",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Item",
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "100px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fieldname": "item_code",
|
||||
"fieldtype": "Link",
|
||||
"in_global_search": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Item Code",
|
||||
"options": "Item",
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Item Name",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "item_code.item_name",
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "200px",
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fetch_from": "item_code.item_name",
|
||||
"fieldname": "item_name",
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "Item Name",
|
||||
"print_width": "200px",
|
||||
"read_only": 1,
|
||||
"width": "200px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "batch_no",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Batch No",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "Batch",
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "batch_no",
|
||||
"fieldtype": "Link",
|
||||
"label": "Batch No",
|
||||
"options": "Batch"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 1,
|
||||
"columns": 0,
|
||||
"fieldname": "desc_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Description",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"collapsible": 1,
|
||||
"fieldname": "desc_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Description",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "description",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Description"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "quantity_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Quantity",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "quantity_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Quantity"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Quantity",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "100px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 1,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fieldname": "qty",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Quantity",
|
||||
"print_width": "100px",
|
||||
"reqd": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "net_weight",
|
||||
"fieldtype": "Float",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Net Weight",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "100px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fieldname": "net_weight",
|
||||
"fieldtype": "Float",
|
||||
"in_list_view": 1,
|
||||
"label": "Net Weight",
|
||||
"print_width": "100px",
|
||||
"width": "100px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "column_break_10",
|
||||
"fieldtype": "Column Break",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"precision": "",
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"fieldname": "column_break_10",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "UOM",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "UOM",
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "100px",
|
||||
"read_only": 1,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fieldname": "stock_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "UOM",
|
||||
"options": "UOM",
|
||||
"print_width": "100px",
|
||||
"read_only": 1,
|
||||
"width": "100px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "weight_uom",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 0,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Weight UOM",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"options": "UOM",
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"print_width": "100px",
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0,
|
||||
"fieldname": "weight_uom",
|
||||
"fieldtype": "Link",
|
||||
"label": "Weight UOM",
|
||||
"options": "UOM",
|
||||
"print_width": "100px",
|
||||
"width": "100px"
|
||||
},
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "page_break",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 0,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "Page Break",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
},
|
||||
"allow_on_submit": 1,
|
||||
"default": "0",
|
||||
"fieldname": "page_break",
|
||||
"fieldtype": "Check",
|
||||
"in_list_view": 1,
|
||||
"label": "Page Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 0,
|
||||
"bold": 0,
|
||||
"collapsible": 0,
|
||||
"columns": 0,
|
||||
"fieldname": "dn_detail",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"ignore_user_permissions": 0,
|
||||
"ignore_xss_filter": 0,
|
||||
"in_filter": 0,
|
||||
"in_global_search": 0,
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 0,
|
||||
"label": "DN Detail",
|
||||
"length": 0,
|
||||
"no_copy": 0,
|
||||
"permlevel": 0,
|
||||
"print_hide": 0,
|
||||
"print_hide_if_no_value": 0,
|
||||
"read_only": 0,
|
||||
"remember_last_selected_value": 0,
|
||||
"report_hide": 0,
|
||||
"reqd": 0,
|
||||
"search_index": 0,
|
||||
"set_only_once": 0,
|
||||
"unique": 0
|
||||
"fieldname": "dn_detail",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "DN Detail"
|
||||
}
|
||||
],
|
||||
"hide_heading": 0,
|
||||
"hide_toolbar": 0,
|
||||
"idx": 1,
|
||||
"image_view": 0,
|
||||
"in_create": 0,
|
||||
|
||||
"is_submittable": 0,
|
||||
"issingle": 0,
|
||||
"istable": 1,
|
||||
"max_attachments": 0,
|
||||
"modified": "2018-06-01 07:21:58.220980",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packing Slip Item",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 0,
|
||||
"read_only": 0,
|
||||
"read_only_onload": 0,
|
||||
"show_name_in_global_search": 0,
|
||||
"track_changes": 1,
|
||||
"track_seen": 0
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2021-12-14 01:22:00.715935",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Packing Slip Item",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class RepostItemValuation(Document):
|
||||
self.db_set('status', self.status)
|
||||
|
||||
def on_submit(self):
|
||||
if not frappe.flags.in_test or self.flags.dont_run_in_test:
|
||||
if not frappe.flags.in_test or self.flags.dont_run_in_test or frappe.flags.dont_execute_stock_reposts:
|
||||
return
|
||||
|
||||
frappe.enqueue(repost, timeout=1800, queue='long',
|
||||
@@ -97,7 +97,8 @@ def repost(doc):
|
||||
return
|
||||
|
||||
doc.set_status('In Progress')
|
||||
frappe.db.commit()
|
||||
if not frappe.flags.in_test:
|
||||
frappe.db.commit()
|
||||
|
||||
repost_sl_entries(doc)
|
||||
repost_gl_entries(doc)
|
||||
|
||||
@@ -39,9 +39,9 @@ def create_test_delivery_note():
|
||||
"description": 'Test delivery note for shipment',
|
||||
"qty": 5,
|
||||
"uom": 'Nos',
|
||||
"warehouse": 'Stores - SC',
|
||||
"warehouse": 'Stores - _TC',
|
||||
"rate": item.standard_rate,
|
||||
"cost_center": 'Main - SC'
|
||||
"cost_center": 'Main - _TC'
|
||||
}
|
||||
)
|
||||
delivery_note.insert()
|
||||
@@ -127,13 +127,7 @@ def get_shipment_company_address(company_name):
|
||||
return create_shipment_address(address_title, company_name, 80331)
|
||||
|
||||
def get_shipment_company():
|
||||
company_name = 'Shipment Company'
|
||||
abbr = 'SC'
|
||||
companies = frappe.get_all("Company", fields=["name"], filters = {"company_name": company_name})
|
||||
if len(companies):
|
||||
return companies[0]
|
||||
else:
|
||||
return create_shipment_company(company_name, abbr)
|
||||
return frappe.get_doc("Company", "_Test Company")
|
||||
|
||||
def get_shipment_item(company_name):
|
||||
item_name = 'Testing Shipment item'
|
||||
@@ -182,17 +176,6 @@ def create_customer_contact(fname, lname):
|
||||
customer.insert()
|
||||
return customer
|
||||
|
||||
|
||||
def create_shipment_company(company_name, abbr):
|
||||
company = frappe.new_doc("Company")
|
||||
company.company_name = company_name
|
||||
company.abbr = abbr
|
||||
company.default_currency = 'EUR'
|
||||
company.country = 'Germany'
|
||||
company.enable_perpetual_inventory = 0
|
||||
company.insert()
|
||||
return company
|
||||
|
||||
def create_shipment_customer(customer_name):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
@@ -211,12 +194,12 @@ def create_material_receipt(item, company):
|
||||
stock.posting_date = posting_date.strftime("%Y-%m-%d")
|
||||
stock.append('items',
|
||||
{
|
||||
"t_warehouse": 'Stores - SC',
|
||||
"t_warehouse": 'Stores - _TC',
|
||||
"item_code": item.name,
|
||||
"qty": 5,
|
||||
"uom": 'Nos',
|
||||
"basic_rate": item.standard_rate,
|
||||
"cost_center": 'Main - SC'
|
||||
"cost_center": 'Main - _TC'
|
||||
}
|
||||
)
|
||||
stock.insert()
|
||||
@@ -233,7 +216,7 @@ def create_shipment_item(item_name, company_name):
|
||||
item.append('item_defaults',
|
||||
{
|
||||
"company": company_name,
|
||||
"default_warehouse": 'Stores - SC'
|
||||
"default_warehouse": 'Stores - _TC'
|
||||
}
|
||||
)
|
||||
item.insert()
|
||||
|
||||
@@ -36,10 +36,16 @@ from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get
|
||||
from erpnext.stock.utils import get_bin, get_incoming_rate
|
||||
|
||||
|
||||
class IncorrectValuationRateError(frappe.ValidationError): pass
|
||||
class DuplicateEntryForWorkOrderError(frappe.ValidationError): pass
|
||||
class OperationsNotCompleteError(frappe.ValidationError): pass
|
||||
class MaxSampleAlreadyRetainedError(frappe.ValidationError): pass
|
||||
class FinishedGoodError(frappe.ValidationError):
|
||||
pass
|
||||
class IncorrectValuationRateError(frappe.ValidationError):
|
||||
pass
|
||||
class DuplicateEntryForWorkOrderError(frappe.ValidationError):
|
||||
pass
|
||||
class OperationsNotCompleteError(frappe.ValidationError):
|
||||
pass
|
||||
class MaxSampleAlreadyRetainedError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
from erpnext.controllers.stock_controller import StockController
|
||||
|
||||
@@ -702,6 +708,11 @@ class StockEntry(StockController):
|
||||
|
||||
finished_item = self.get_finished_item()
|
||||
|
||||
if not finished_item and self.purpose == "Manufacture":
|
||||
# In case of independent Manufacture entry, don't auto set
|
||||
# user must decide and set
|
||||
return
|
||||
|
||||
for d in self.items:
|
||||
if d.t_warehouse and not d.s_warehouse:
|
||||
if self.purpose=="Repack" or d.item_code == finished_item:
|
||||
@@ -722,38 +733,64 @@ class StockEntry(StockController):
|
||||
return finished_item
|
||||
|
||||
def validate_finished_goods(self):
|
||||
"""validation: finished good quantity should be same as manufacturing quantity"""
|
||||
if not self.work_order: return
|
||||
"""
|
||||
1. Check if FG exists
|
||||
2. Check if Multiple FG Items are present
|
||||
3. Check FG Item and Qty against WO if present
|
||||
"""
|
||||
production_item, wo_qty, finished_items = None, 0, []
|
||||
|
||||
production_item, wo_qty = frappe.db.get_value("Work Order",
|
||||
self.work_order, ["production_item", "qty"])
|
||||
wo_details = frappe.db.get_value(
|
||||
"Work Order", self.work_order, ["production_item", "qty"]
|
||||
)
|
||||
if wo_details:
|
||||
production_item, wo_qty = wo_details
|
||||
|
||||
finished_items = []
|
||||
for d in self.get('items'):
|
||||
if d.is_finished_item:
|
||||
if not self.work_order:
|
||||
finished_items.append(d.item_code)
|
||||
continue # Independent Manufacture Entry, no WO to match against
|
||||
|
||||
if d.item_code != production_item:
|
||||
frappe.throw(_("Finished Item {0} does not match with Work Order {1}")
|
||||
.format(d.item_code, self.work_order))
|
||||
.format(d.item_code, self.work_order)
|
||||
)
|
||||
elif flt(d.transfer_qty) > flt(self.fg_completed_qty):
|
||||
frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \
|
||||
format(d.idx, d.transfer_qty, self.fg_completed_qty))
|
||||
frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}")
|
||||
.format(d.idx, d.transfer_qty, self.fg_completed_qty)
|
||||
)
|
||||
|
||||
finished_items.append(d.item_code)
|
||||
|
||||
if len(set(finished_items)) > 1:
|
||||
frappe.throw(_("Multiple items cannot be marked as finished item"))
|
||||
frappe.throw(
|
||||
msg=_("Multiple items cannot be marked as finished item"),
|
||||
title=_("Note"),
|
||||
exc=FinishedGoodError
|
||||
)
|
||||
|
||||
if self.purpose == "Manufacture":
|
||||
if not finished_items:
|
||||
frappe.throw(_('Finished Good has not set in the stock entry {0}')
|
||||
.format(self.name))
|
||||
frappe.throw(
|
||||
msg=_("There must be atleast 1 Finished Good in this Stock Entry").format(self.name),
|
||||
title=_("Missing Finished Good"),
|
||||
exc=FinishedGoodError
|
||||
)
|
||||
|
||||
allowance_percentage = flt(frappe.db.get_single_value("Manufacturing Settings",
|
||||
"overproduction_percentage_for_work_order"))
|
||||
allowance_percentage = flt(
|
||||
frappe.db.get_single_value(
|
||||
"Manufacturing Settings","overproduction_percentage_for_work_order"
|
||||
)
|
||||
)
|
||||
allowed_qty = wo_qty + ((allowance_percentage/100) * wo_qty)
|
||||
|
||||
allowed_qty = wo_qty + (allowance_percentage/100 * wo_qty)
|
||||
if self.fg_completed_qty > allowed_qty:
|
||||
frappe.throw(_("For quantity {0} should not be greater than work order quantity {1}")
|
||||
.format(flt(self.fg_completed_qty), wo_qty))
|
||||
# No work order could mean independent Manufacture entry, if so skip validation
|
||||
if self.work_order and self.fg_completed_qty > allowed_qty:
|
||||
frappe.throw(
|
||||
_("For quantity {0} should not be greater than work order quantity {1}")
|
||||
.format(flt(self.fg_completed_qty), wo_qty)
|
||||
)
|
||||
|
||||
def update_stock_ledger(self):
|
||||
sl_entries = []
|
||||
|
||||
@@ -16,7 +16,10 @@ from erpnext.stock.doctype.item.test_item import (
|
||||
set_item_variant_settings,
|
||||
)
|
||||
from erpnext.stock.doctype.serial_no.serial_no import * # noqa
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import move_sample_to_retention_warehouse
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import (
|
||||
FinishedGoodError,
|
||||
move_sample_to_retention_warehouse,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError
|
||||
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
|
||||
@@ -1007,6 +1010,38 @@ class TestStockEntry(ERPNextTestCase):
|
||||
posting_date='2021-09-02', # backdated consumption of 2nd batch
|
||||
purpose='Material Issue')
|
||||
|
||||
def test_independent_manufacture_entry(self):
|
||||
"Test FG items and incoming rate calculation in Maniufacture Entry without WO or BOM linked."
|
||||
se = frappe.get_doc(
|
||||
doctype="Stock Entry",
|
||||
purpose="Manufacture",
|
||||
stock_entry_type="Manufacture",
|
||||
company="_Test Company",
|
||||
items=[
|
||||
frappe._dict(item_code="_Test Item", qty=1, basic_rate=200, s_warehouse="_Test Warehouse - _TC"),
|
||||
frappe._dict(item_code="_Test FG Item", qty=4, t_warehouse="_Test Warehouse 1 - _TC")
|
||||
]
|
||||
)
|
||||
# SE must have atleast one FG
|
||||
self.assertRaises(FinishedGoodError, se.save)
|
||||
|
||||
se.items[0].is_finished_item = 1
|
||||
se.items[1].is_finished_item = 1
|
||||
# SE cannot have multiple FGs
|
||||
self.assertRaises(FinishedGoodError, se.save)
|
||||
|
||||
se.items[0].is_finished_item = 0
|
||||
se.save()
|
||||
|
||||
# Check if FG cost is calculated based on RM total cost
|
||||
# RM total cost = 200, FG rate = 200/4(FG qty) = 50
|
||||
self.assertEqual(se.items[1].basic_rate, 50)
|
||||
self.assertEqual(se.value_difference, 0.0)
|
||||
self.assertEqual(se.total_incoming_value, se.total_outgoing_value)
|
||||
|
||||
# teardown
|
||||
se.delete()
|
||||
|
||||
def make_serialized_item(**args):
|
||||
args = frappe._dict(args)
|
||||
se = frappe.copy_doc(test_records[0])
|
||||
|
||||
@@ -24,11 +24,15 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
|
||||
|
||||
class TestStockReconciliation(ERPNextTestCase):
|
||||
@classmethod
|
||||
def setUpClass(self):
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
create_batch_or_serial_no_items()
|
||||
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1)
|
||||
|
||||
def tearDown(self):
|
||||
frappe.flags.dont_execute_stock_reposts = None
|
||||
|
||||
|
||||
def test_reco_for_fifo(self):
|
||||
self._test_reco_sle_gle("FIFO")
|
||||
|
||||
@@ -392,6 +396,41 @@ class TestStockReconciliation(ERPNextTestCase):
|
||||
repost_exists = bool(frappe.db.exists("Repost Item Valuation", {"voucher_no": sr.name}))
|
||||
self.assertFalse(repost_exists, msg="Negative stock validation not working on reco cancellation")
|
||||
|
||||
def test_intermediate_sr_bin_update(self):
|
||||
"""Bin should show correct qty even for backdated entries.
|
||||
|
||||
-------------------------------------------
|
||||
| creation | Var | Doc | Qty | balance qty
|
||||
-------------------------------------------
|
||||
| 1 | SR | Reco | 10 | 10 (posting date: today+10)
|
||||
| 3 | SR2 | Reco | 11 | 11 (posting date: today+11)
|
||||
| 2 | DN | DN | 5 | 6 <-- assert in BIN (posting date: today+12)
|
||||
"""
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
|
||||
|
||||
# repost will make this test useless, qty should update in realtime without reposts
|
||||
frappe.flags.dont_execute_stock_reposts = True
|
||||
frappe.db.rollback()
|
||||
|
||||
item_code = "Backdated-Reco-Cancellation-Item"
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
create_item(item_code)
|
||||
|
||||
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=10, rate=100,
|
||||
posting_date=add_days(nowdate(), 10))
|
||||
|
||||
create_delivery_note(item_code=item_code, warehouse=warehouse, qty=5, rate=120,
|
||||
posting_date=add_days(nowdate(), 12))
|
||||
old_bin_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
|
||||
|
||||
create_stock_reconciliation(item_code=item_code, warehouse=warehouse, qty=11, rate=100,
|
||||
posting_date=add_days(nowdate(), 11))
|
||||
new_bin_qty = frappe.db.get_value("Bin", {"item_code": item_code, "warehouse": warehouse}, "actual_qty")
|
||||
|
||||
self.assertEqual(old_bin_qty + 1, new_bin_qty)
|
||||
frappe.db.rollback()
|
||||
|
||||
|
||||
def test_valid_batch(self):
|
||||
create_batch_item_with_batch("Testing Batch Item 1", "001")
|
||||
create_batch_item_with_batch("Testing Batch Item 2", "002")
|
||||
|
||||
@@ -1098,7 +1098,7 @@ def apply_price_list(args, as_doc=False):
|
||||
}
|
||||
|
||||
def apply_price_list_on_item(args):
|
||||
item_doc = frappe.get_doc("Item", args.item_code)
|
||||
item_doc = frappe.db.get_value("Item", args.item_code, ['name', 'variant_of'], as_dict=1)
|
||||
item_details = get_price_list_rate(args, item_doc)
|
||||
|
||||
item_details.update(get_pricing_rule_for_item(args, item_details.price_list_rate))
|
||||
|
||||
@@ -74,7 +74,7 @@ def get_stock_ledger_entries(report_filters):
|
||||
fields = ['name', 'voucher_type', 'voucher_no', 'item_code', 'serial_no as serial_nos', 'actual_qty',
|
||||
'posting_date', 'posting_time', 'company', 'warehouse', '(stock_value_difference / actual_qty) as valuation_rate']
|
||||
|
||||
filters = {'serial_no': ("is", "set")}
|
||||
filters = {'serial_no': ("is", "set"), "is_cancelled": 0}
|
||||
|
||||
if report_filters.get('item_code'):
|
||||
filters['item_code'] = report_filters.get('item_code')
|
||||
|
||||
@@ -48,6 +48,7 @@ def get_item_info(filters):
|
||||
conditions = [get_item_group_condition(filters.get("item_group"))]
|
||||
if filters.get("brand"):
|
||||
conditions.append("item.brand=%(brand)s")
|
||||
conditions.append("is_stock_item = 1")
|
||||
|
||||
return frappe.db.sql("""select name, item_name, description, brand, item_group,
|
||||
safety_stock, lead_time_days from `tabItem` item where {}"""
|
||||
|
||||
@@ -3,27 +3,37 @@
|
||||
|
||||
|
||||
from operator import itemgetter
|
||||
from typing import Dict, List, Tuple, Union
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import cint, date_diff, flt
|
||||
from six import iteritems
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
Filters = frappe._dict
|
||||
|
||||
def execute(filters=None):
|
||||
columns = get_columns(filters)
|
||||
item_details = get_fifo_queue(filters)
|
||||
def execute(filters: Filters = None) -> Tuple:
|
||||
to_date = filters["to_date"]
|
||||
_func = itemgetter(1)
|
||||
columns = get_columns(filters)
|
||||
|
||||
item_details = FIFOSlots(filters).generate()
|
||||
data = format_report_data(filters, item_details, to_date)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
|
||||
return columns, data, None, chart_data
|
||||
|
||||
def format_report_data(filters: Filters, item_details: Dict, to_date: str) -> List[Dict]:
|
||||
"Returns ordered, formatted data with ranges."
|
||||
_func = itemgetter(1)
|
||||
data = []
|
||||
for item, item_dict in iteritems(item_details):
|
||||
|
||||
for item, item_dict in item_details.items():
|
||||
earliest_age, latest_age = 0, 0
|
||||
details = item_dict["details"]
|
||||
|
||||
fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func)
|
||||
details = item_dict["details"]
|
||||
|
||||
if not fifo_queue: continue
|
||||
|
||||
@@ -32,23 +42,22 @@ def execute(filters=None):
|
||||
latest_age = date_diff(to_date, fifo_queue[-1][1])
|
||||
range1, range2, range3, above_range3 = get_range_age(filters, fifo_queue, to_date, item_dict)
|
||||
|
||||
row = [details.name, details.item_name,
|
||||
details.description, details.item_group, details.brand]
|
||||
row = [details.name, details.item_name, details.description,
|
||||
details.item_group, details.brand]
|
||||
|
||||
if filters.get("show_warehouse_wise_stock"):
|
||||
row.append(details.warehouse)
|
||||
|
||||
row.extend([item_dict.get("total_qty"), average_age,
|
||||
range1, range2, range3, above_range3,
|
||||
earliest_age, latest_age, details.stock_uom])
|
||||
earliest_age, latest_age,
|
||||
details.stock_uom])
|
||||
|
||||
data.append(row)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
return data
|
||||
|
||||
return columns, data, None, chart_data
|
||||
|
||||
def get_average_age(fifo_queue, to_date):
|
||||
def get_average_age(fifo_queue: List, to_date: str) -> float:
|
||||
batch_age = age_qty = total_qty = 0.0
|
||||
for batch in fifo_queue:
|
||||
batch_age = date_diff(to_date, batch[1])
|
||||
@@ -62,7 +71,7 @@ def get_average_age(fifo_queue, to_date):
|
||||
|
||||
return flt(age_qty / total_qty, 2) if total_qty else 0.0
|
||||
|
||||
def get_range_age(filters, fifo_queue, to_date, item_dict):
|
||||
def get_range_age(filters: Filters, fifo_queue: List, to_date: str, item_dict: Dict) -> Tuple:
|
||||
range1 = range2 = range3 = above_range3 = 0.0
|
||||
|
||||
for item in fifo_queue:
|
||||
@@ -80,7 +89,7 @@ def get_range_age(filters, fifo_queue, to_date, item_dict):
|
||||
|
||||
return range1, range2, range3, above_range3
|
||||
|
||||
def get_columns(filters):
|
||||
def get_columns(filters: Filters) -> List[Dict]:
|
||||
range_columns = []
|
||||
setup_ageing_columns(filters, range_columns)
|
||||
columns = [
|
||||
@@ -165,106 +174,7 @@ def get_columns(filters):
|
||||
|
||||
return columns
|
||||
|
||||
def get_fifo_queue(filters, sle=None):
|
||||
item_details = {}
|
||||
transferred_item_details = {}
|
||||
serial_no_batch_purchase_details = {}
|
||||
|
||||
if sle == None:
|
||||
sle = get_stock_ledger_entries(filters)
|
||||
|
||||
for d in sle:
|
||||
key = (d.name, d.warehouse) if filters.get('show_warehouse_wise_stock') else d.name
|
||||
item_details.setdefault(key, {"details": d, "fifo_queue": []})
|
||||
fifo_queue = item_details[key]["fifo_queue"]
|
||||
|
||||
transferred_item_key = (d.voucher_no, d.name, d.warehouse)
|
||||
transferred_item_details.setdefault(transferred_item_key, [])
|
||||
|
||||
if d.voucher_type == "Stock Reconciliation":
|
||||
d.actual_qty = flt(d.qty_after_transaction) - flt(item_details[key].get("qty_after_transaction", 0))
|
||||
|
||||
serial_no_list = get_serial_nos(d.serial_no) if d.serial_no else []
|
||||
|
||||
if d.actual_qty > 0:
|
||||
if transferred_item_details.get(transferred_item_key):
|
||||
batch = transferred_item_details[transferred_item_key][0]
|
||||
fifo_queue.append(batch)
|
||||
transferred_item_details[transferred_item_key].pop(0)
|
||||
else:
|
||||
if serial_no_list:
|
||||
for serial_no in serial_no_list:
|
||||
if serial_no_batch_purchase_details.get(serial_no):
|
||||
fifo_queue.append([serial_no, serial_no_batch_purchase_details.get(serial_no)])
|
||||
else:
|
||||
serial_no_batch_purchase_details.setdefault(serial_no, d.posting_date)
|
||||
fifo_queue.append([serial_no, d.posting_date])
|
||||
else:
|
||||
fifo_queue.append([d.actual_qty, d.posting_date])
|
||||
else:
|
||||
if serial_no_list:
|
||||
fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_no_list]
|
||||
else:
|
||||
qty_to_pop = abs(d.actual_qty)
|
||||
while qty_to_pop:
|
||||
batch = fifo_queue[0] if fifo_queue else [0, None]
|
||||
if 0 < flt(batch[0]) <= qty_to_pop:
|
||||
# if batch qty > 0
|
||||
# not enough or exactly same qty in current batch, clear batch
|
||||
qty_to_pop -= flt(batch[0])
|
||||
transferred_item_details[transferred_item_key].append(fifo_queue.pop(0))
|
||||
else:
|
||||
# all from current batch
|
||||
batch[0] = flt(batch[0]) - qty_to_pop
|
||||
transferred_item_details[transferred_item_key].append([qty_to_pop, batch[1]])
|
||||
qty_to_pop = 0
|
||||
|
||||
item_details[key]["qty_after_transaction"] = d.qty_after_transaction
|
||||
|
||||
if "total_qty" not in item_details[key]:
|
||||
item_details[key]["total_qty"] = d.actual_qty
|
||||
else:
|
||||
item_details[key]["total_qty"] += d.actual_qty
|
||||
|
||||
item_details[key]["has_serial_no"] = d.has_serial_no
|
||||
|
||||
return item_details
|
||||
|
||||
def get_stock_ledger_entries(filters):
|
||||
return frappe.db.sql("""select
|
||||
item.name, item.item_name, item_group, brand, description, item.stock_uom, item.has_serial_no,
|
||||
actual_qty, posting_date, voucher_type, voucher_no, serial_no, batch_no, qty_after_transaction, warehouse
|
||||
from `tabStock Ledger Entry` sle,
|
||||
(select name, item_name, description, stock_uom, brand, item_group, has_serial_no
|
||||
from `tabItem` {item_conditions}) item
|
||||
where item_code = item.name and
|
||||
company = %(company)s and
|
||||
posting_date <= %(to_date)s and
|
||||
is_cancelled != 1
|
||||
{sle_conditions}
|
||||
order by posting_date, posting_time, sle.creation, actual_qty""" #nosec
|
||||
.format(item_conditions=get_item_conditions(filters),
|
||||
sle_conditions=get_sle_conditions(filters)), filters, as_dict=True)
|
||||
|
||||
def get_item_conditions(filters):
|
||||
conditions = []
|
||||
if filters.get("item_code"):
|
||||
conditions.append("item_code=%(item_code)s")
|
||||
if filters.get("brand"):
|
||||
conditions.append("brand=%(brand)s")
|
||||
|
||||
return "where {}".format(" and ".join(conditions)) if conditions else ""
|
||||
|
||||
def get_sle_conditions(filters):
|
||||
conditions = []
|
||||
if filters.get("warehouse"):
|
||||
lft, rgt = frappe.db.get_value('Warehouse', filters.get("warehouse"), ['lft', 'rgt'])
|
||||
conditions.append("""warehouse in (select wh.name from `tabWarehouse` wh
|
||||
where wh.lft >= {0} and rgt <= {1})""".format(lft, rgt))
|
||||
|
||||
return "and {}".format(" and ".join(conditions)) if conditions else ""
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data: List, filters: Filters) -> Dict:
|
||||
if not data:
|
||||
return []
|
||||
|
||||
@@ -295,17 +205,201 @@ def get_chart_data(data, filters):
|
||||
"type" : "bar"
|
||||
}
|
||||
|
||||
def setup_ageing_columns(filters, range_columns):
|
||||
for i, label in enumerate(["0-{range1}".format(range1=filters["range1"]),
|
||||
"{range1}-{range2}".format(range1=cint(filters["range1"])+ 1, range2=filters["range2"]),
|
||||
"{range2}-{range3}".format(range2=cint(filters["range2"])+ 1, range3=filters["range3"]),
|
||||
"{range3}-{above}".format(range3=cint(filters["range3"])+ 1, above=_("Above"))]):
|
||||
add_column(range_columns, label="Age ("+ label +")", fieldname='range' + str(i+1))
|
||||
def setup_ageing_columns(filters: Filters, range_columns: List):
|
||||
ranges = [
|
||||
f"0 - {filters['range1']}",
|
||||
f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}",
|
||||
f"{cint(filters['range2']) + 1} - {cint(filters['range3'])}",
|
||||
f"{cint(filters['range3']) + 1} - {_('Above')}"
|
||||
]
|
||||
for i, label in enumerate(ranges):
|
||||
fieldname = 'range' + str(i+1)
|
||||
add_column(range_columns, label=f"Age ({label})",fieldname=fieldname)
|
||||
|
||||
def add_column(range_columns, label, fieldname, fieldtype='Float', width=140):
|
||||
def add_column(range_columns: List, label:str, fieldname: str, fieldtype: str = 'Float', width: int = 140):
|
||||
range_columns.append(dict(
|
||||
label=label,
|
||||
fieldname=fieldname,
|
||||
fieldtype=fieldtype,
|
||||
width=width
|
||||
))
|
||||
|
||||
|
||||
class FIFOSlots:
|
||||
"Returns FIFO computed slots of inwarded stock as per date."
|
||||
|
||||
def __init__(self, filters: Dict = None , sle: List = None):
|
||||
self.item_details = {}
|
||||
self.transferred_item_details = {}
|
||||
self.serial_no_batch_purchase_details = {}
|
||||
self.filters = filters
|
||||
self.sle = sle
|
||||
|
||||
def generate(self) -> Dict:
|
||||
"""
|
||||
Returns dict of the foll.g structure:
|
||||
Key = Item A / (Item A, Warehouse A)
|
||||
Key: {
|
||||
'details' -> Dict: ** item details **,
|
||||
'fifo_queue' -> List: ** list of lists containing entries/slots for existing stock,
|
||||
consumed/updated and maintained via FIFO. **
|
||||
}
|
||||
"""
|
||||
if self.sle is None:
|
||||
self.sle = self.__get_stock_ledger_entries()
|
||||
|
||||
for d in self.sle:
|
||||
key, fifo_queue, transferred_item_key = self.__init_key_stores(d)
|
||||
|
||||
if d.voucher_type == "Stock Reconciliation":
|
||||
prev_balance_qty = self.item_details[key].get("qty_after_transaction", 0)
|
||||
d.actual_qty = flt(d.qty_after_transaction) - flt(prev_balance_qty)
|
||||
|
||||
serial_nos = get_serial_nos(d.serial_no) if d.serial_no else []
|
||||
|
||||
if d.actual_qty > 0:
|
||||
self.__compute_incoming_stock(d, fifo_queue, transferred_item_key, serial_nos)
|
||||
else:
|
||||
self.__compute_outgoing_stock(d, fifo_queue, transferred_item_key, serial_nos)
|
||||
|
||||
self.__update_balances(d, key)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def __init_key_stores(self, row: Dict) -> Tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
key = (row.name, row.warehouse) if self.filters.get('show_warehouse_wise_stock') else row.name
|
||||
self.item_details.setdefault(key, {"details": row, "fifo_queue": []})
|
||||
fifo_queue = self.item_details[key]["fifo_queue"]
|
||||
|
||||
transferred_item_key = (row.voucher_no, row.name, row.warehouse)
|
||||
self.transferred_item_details.setdefault(transferred_item_key, [])
|
||||
|
||||
return key, fifo_queue, transferred_item_key
|
||||
|
||||
def __compute_incoming_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
|
||||
"Update FIFO Queue on inward stock."
|
||||
|
||||
if self.transferred_item_details.get(transfer_key):
|
||||
# inward/outward from same voucher, item & warehouse
|
||||
slot = self.transferred_item_details[transfer_key].pop(0)
|
||||
fifo_queue.append(slot)
|
||||
else:
|
||||
if not serial_nos:
|
||||
if fifo_queue and flt(fifo_queue[0][0]) < 0:
|
||||
# neutralize negative stock by adding positive stock
|
||||
fifo_queue[0][0] += flt(row.actual_qty)
|
||||
fifo_queue[0][1] = row.posting_date
|
||||
else:
|
||||
fifo_queue.append([flt(row.actual_qty), row.posting_date])
|
||||
return
|
||||
|
||||
for serial_no in serial_nos:
|
||||
if self.serial_no_batch_purchase_details.get(serial_no):
|
||||
fifo_queue.append([serial_no, self.serial_no_batch_purchase_details.get(serial_no)])
|
||||
else:
|
||||
self.serial_no_batch_purchase_details.setdefault(serial_no, row.posting_date)
|
||||
fifo_queue.append([serial_no, row.posting_date])
|
||||
|
||||
def __compute_outgoing_stock(self, row: Dict, fifo_queue: List, transfer_key: Tuple, serial_nos: List):
|
||||
"Update FIFO Queue on outward stock."
|
||||
if serial_nos:
|
||||
fifo_queue[:] = [serial_no for serial_no in fifo_queue if serial_no[0] not in serial_nos]
|
||||
return
|
||||
|
||||
qty_to_pop = abs(row.actual_qty)
|
||||
while qty_to_pop:
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None]
|
||||
if 0 < flt(slot[0]) <= qty_to_pop:
|
||||
# qty to pop >= slot qty
|
||||
# if +ve and not enough or exactly same balance in current slot, consume whole slot
|
||||
qty_to_pop -= flt(slot[0])
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
|
||||
elif not fifo_queue:
|
||||
# negative stock, no balance but qty yet to consume
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date])
|
||||
self.transferred_item_details[transfer_key].append([row.actual_qty, row.posting_date])
|
||||
qty_to_pop = 0
|
||||
else:
|
||||
# qty to pop < slot qty, ample balance
|
||||
# consume actual_qty from first slot
|
||||
slot[0] = flt(slot[0]) - qty_to_pop
|
||||
self.transferred_item_details[transfer_key].append([qty_to_pop, slot[1]])
|
||||
qty_to_pop = 0
|
||||
|
||||
def __update_balances(self, row: Dict, key: Union[Tuple, str]):
|
||||
self.item_details[key]["qty_after_transaction"] = row.qty_after_transaction
|
||||
|
||||
if "total_qty" not in self.item_details[key]:
|
||||
self.item_details[key]["total_qty"] = row.actual_qty
|
||||
else:
|
||||
self.item_details[key]["total_qty"] += row.actual_qty
|
||||
|
||||
self.item_details[key]["has_serial_no"] = row.has_serial_no
|
||||
|
||||
def __get_stock_ledger_entries(self) -> List[Dict]:
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = self.__get_item_query() # used as derived table in sle query
|
||||
|
||||
sle_query = (
|
||||
frappe.qb.from_(sle).from_(item)
|
||||
.select(
|
||||
item.name, item.item_name, item.item_group,
|
||||
item.brand, item.description,
|
||||
item.stock_uom, item.has_serial_no,
|
||||
sle.actual_qty, sle.posting_date,
|
||||
sle.voucher_type, sle.voucher_no,
|
||||
sle.serial_no, sle.batch_no,
|
||||
sle.qty_after_transaction, sle.warehouse
|
||||
).where(
|
||||
(sle.item_code == item.name)
|
||||
& (sle.company == self.filters.get("company"))
|
||||
& (sle.posting_date <= self.filters.get("to_date"))
|
||||
& (sle.is_cancelled != 1)
|
||||
)
|
||||
)
|
||||
|
||||
if self.filters.get("warehouse"):
|
||||
sle_query = self.__get_warehouse_conditions(sle, sle_query)
|
||||
|
||||
sle_query = sle_query.orderby(
|
||||
sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty
|
||||
)
|
||||
|
||||
return sle_query.run(as_dict=True)
|
||||
|
||||
def __get_item_query(self) -> str:
|
||||
item_table = frappe.qb.DocType("Item")
|
||||
|
||||
item = frappe.qb.from_("Item").select(
|
||||
"name", "item_name", "description", "stock_uom",
|
||||
"brand", "item_group", "has_serial_no"
|
||||
)
|
||||
|
||||
if self.filters.get("item_code"):
|
||||
item = item.where(item_table.item_code == self.filters.get("item_code"))
|
||||
|
||||
if self.filters.get("brand"):
|
||||
item = item.where(item_table.brand == self.filters.get("brand"))
|
||||
|
||||
return item
|
||||
|
||||
def __get_warehouse_conditions(self, sle, sle_query) -> str:
|
||||
warehouse = frappe.qb.DocType("Warehouse")
|
||||
lft, rgt = frappe.db.get_value(
|
||||
"Warehouse",
|
||||
self.filters.get("warehouse"),
|
||||
['lft', 'rgt']
|
||||
)
|
||||
|
||||
warehouse_results = (
|
||||
frappe.qb.from_(warehouse)
|
||||
.select("name").where(
|
||||
(warehouse.lft >= lft)
|
||||
& (warehouse.rgt <= rgt)
|
||||
).run()
|
||||
)
|
||||
warehouse_results = [x[0] for x in warehouse_results]
|
||||
|
||||
return sle_query.where(sle.warehouse.isin(warehouse_results))
|
||||
|
||||
73
erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
Normal file
73
erpnext/stock/report/stock_ageing/stock_ageing_fifo_logic.md
Normal file
@@ -0,0 +1,73 @@
|
||||
### Concept of FIFO Slots
|
||||
|
||||
Since we need to know age-wise remaining stock, we maintain all the inward entries as slots. So each time stock comes in, a slot is added for the same.
|
||||
|
||||
Eg. For Item A:
|
||||
----------------------
|
||||
Date | Qty | Queue
|
||||
----------------------
|
||||
1st | +50 | [[50, 1-12-2021]]
|
||||
2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
|
||||
----------------------
|
||||
|
||||
Now the queue can tell us the total stock and also how old the stock is.
|
||||
Here, the balance qty is 70.
|
||||
50 qty is (today-the 1st) days old
|
||||
20 qty is (today-the 2nd) days old
|
||||
|
||||
### Calculation of FIFO Slots
|
||||
|
||||
#### Case 1: Outward from sufficient balance qty
|
||||
----------------------
|
||||
Date | Qty | Queue
|
||||
----------------------
|
||||
1st | +50 | [[50, 1-12-2021]]
|
||||
2nd | -20 | [[30, 1-12-2021]]
|
||||
2nd | +20 | [[30, 1-12-2021], [20, 2-12-2021]]
|
||||
|
||||
Here after the first entry, while issuing 20 qty:
|
||||
- **since 20 is lesser than the balance**, **qty_to_pop (20)** is simply consumed from first slot (FIFO consumption)
|
||||
- Any inward entry after as usual will get its own slot added to the queue
|
||||
|
||||
#### Case 2: Outward from sufficient cumulative (slots) balance qty
|
||||
----------------------
|
||||
Date | Qty | Queue
|
||||
----------------------
|
||||
1st | +50 | [[50, 1-12-2021]]
|
||||
2nd | +20 | [[50, 1-12-2021], [20, 2-12-2021]]
|
||||
2nd | -60 | [[10, 2-12-2021]]
|
||||
|
||||
- Consumption happens slot wise. First slot 1 is consumed
|
||||
- Since **qty_to_pop (60) is greater than slot 1 qty (50)**, the entire slot is consumed and popped
|
||||
- Now the queue is [[20, 2-12-2021]], and **qty_to_pop=10** (remaining qty to pop)
|
||||
- It then goes ahead to the next slot and consumes 10 from it
|
||||
- Now the queue is [[10, 2-12-2021]]
|
||||
|
||||
#### Case 3: Outward from insufficient balance qty
|
||||
> This case is possible only if **Allow Negative Stock** was enabled at some point/is enabled.
|
||||
|
||||
----------------------
|
||||
Date | Qty | Queue
|
||||
----------------------
|
||||
1st | +50 | [[50, 1-12-2021]]
|
||||
2nd | -60 | [[-10, 1-12-2021]]
|
||||
|
||||
- Since **qty_to_pop (60)** is more than the balance in slot 1, the entire slot is consumed and popped
|
||||
- Now the queue is **empty**, and **qty_to_pop=10** (remaining qty to pop)
|
||||
- Since we still have more to consume, we append the balance since 60 is issued from 50 i.e. -10.
|
||||
- We register this negative value, since the stock issue has caused the balance to become negative
|
||||
|
||||
Now when stock is inwarded:
|
||||
- Instead of adding a slot we check if there are any negative balances.
|
||||
- If yes, we keep adding positive stock to it until we make the balance positive.
|
||||
- Once the balance is positive, the next inward entry will add a new slot in the queue
|
||||
|
||||
Eg:
|
||||
----------------------
|
||||
Date | Qty | Queue
|
||||
----------------------
|
||||
1st | +50 | [[50, 1-12-2021]]
|
||||
2nd | -60 | [[-10, 1-12-2021]]
|
||||
3rd | +5 | [[-5, 3-12-2021]]
|
||||
4th | +10 | [[5, 4-12-2021]]
|
||||
4th | +20 | [[5, 4-12-2021], [20, 4-12-2021]]
|
||||
126
erpnext/stock/report/stock_ageing/test_stock_ageing.py
Normal file
126
erpnext/stock/report/stock_ageing/test_stock_ageing.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots
|
||||
from erpnext.tests.utils import ERPNextTestCase
|
||||
|
||||
|
||||
class TestStockAgeing(ERPNextTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.filters = frappe._dict(
|
||||
company="_Test Company",
|
||||
to_date="2021-12-10"
|
||||
)
|
||||
|
||||
def test_normal_inward_outward_queue(self):
|
||||
"Reference: Case 1 in stock_ageing_fifo_logic.md"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=50,
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
self.assertTrue(slots["Flask Item"]["fifo_queue"])
|
||||
result = slots["Flask Item"]
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md"
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-30), qty_after_transaction=(-30),
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=(-10),
|
||||
posting_date="2021-12-02", voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=20, qty_after_transaction=10,
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=10, qty_after_transaction=20,
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
result = slots["Flask Item"]
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(queue[0][0], 10.0)
|
||||
self.assertEqual(queue[1][0], 10.0)
|
||||
|
||||
def test_stock_reconciliation(self):
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=30, qty_after_transaction=30,
|
||||
posting_date="2021-12-01", voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=0, qty_after_transaction=50,
|
||||
posting_date="2021-12-02", voucher_type="Stock Reconciliation",
|
||||
voucher_no="002",
|
||||
has_serial_no=False, serial_no=None
|
||||
),
|
||||
frappe._dict(
|
||||
name="Flask Item",
|
||||
actual_qty=(-10), qty_after_transaction=40,
|
||||
posting_date="2021-12-03", voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False, serial_no=None
|
||||
)
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
result = slots["Flask Item"]
|
||||
queue = result["fifo_queue"]
|
||||
|
||||
self.assertEqual(result["qty_after_transaction"], result["total_qty"])
|
||||
self.assertEqual(queue[0][0], 20.0)
|
||||
self.assertEqual(queue[1][0], 20.0)
|
||||
@@ -10,7 +10,7 @@ from frappe.utils import cint, date_diff, flt, getdate
|
||||
from six import iteritems
|
||||
|
||||
import erpnext
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
|
||||
from erpnext.stock.report.stock_ledger.stock_ledger import get_item_group_condition
|
||||
from erpnext.stock.utils import add_additional_uom_columns, is_reposting_item_valuation_in_progress
|
||||
|
||||
@@ -34,7 +34,7 @@ def execute(filters=None):
|
||||
|
||||
if filters.get('show_stock_ageing_data'):
|
||||
filters['show_warehouse_wise_stock'] = True
|
||||
item_wise_fifo_queue = get_fifo_queue(filters, sle)
|
||||
item_wise_fifo_queue = FIFOSlots(filters, sle).generate()
|
||||
|
||||
# if no stock ledger entry found return
|
||||
if not sle:
|
||||
@@ -168,7 +168,7 @@ def get_stock_ledger_entries(filters, items):
|
||||
sle.company, sle.voucher_type, sle.qty_after_transaction, sle.stock_value_difference,
|
||||
sle.item_code as name, sle.voucher_no, sle.stock_value, sle.batch_no
|
||||
from
|
||||
`tabStock Ledger Entry` sle force index (posting_sort_index)
|
||||
`tabStock Ledger Entry` sle
|
||||
where sle.docstatus < 2 %s %s
|
||||
and is_cancelled = 0
|
||||
order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty""" % #nosec
|
||||
|
||||
@@ -8,7 +8,8 @@ const DIFFERNCE_FIELD_NAMES = [
|
||||
"fifo_value_diff",
|
||||
"fifo_valuation_diff",
|
||||
"valuation_diff",
|
||||
"fifo_difference_diff"
|
||||
"fifo_difference_diff",
|
||||
"diff_value_diff"
|
||||
];
|
||||
|
||||
frappe.query_reports["Stock Ledger Invariant Check"] = {
|
||||
|
||||
@@ -50,6 +50,7 @@ def get_stock_ledger_entries(filters):
|
||||
|
||||
def add_invariant_check_fields(sles):
|
||||
balance_qty = 0.0
|
||||
balance_stock_value = 0.0
|
||||
for idx, sle in enumerate(sles):
|
||||
queue = json.loads(sle.stock_queue)
|
||||
|
||||
@@ -60,6 +61,7 @@ def add_invariant_check_fields(sles):
|
||||
fifo_value += qty * rate
|
||||
|
||||
balance_qty += sle.actual_qty
|
||||
balance_stock_value += sle.stock_value_difference
|
||||
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
|
||||
balance_qty = sle.qty_after_transaction
|
||||
|
||||
@@ -70,6 +72,7 @@ def add_invariant_check_fields(sles):
|
||||
sle.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
|
||||
)
|
||||
sle.expected_qty_after_transaction = balance_qty
|
||||
sle.stock_value_from_diff = balance_stock_value
|
||||
|
||||
# set difference fields
|
||||
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction
|
||||
@@ -81,6 +84,7 @@ def add_invariant_check_fields(sles):
|
||||
sle.valuation_diff = (
|
||||
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None
|
||||
)
|
||||
sle.diff_value_diff = sle.stock_value_from_diff - sle.stock_value
|
||||
|
||||
if idx > 0:
|
||||
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
|
||||
@@ -191,12 +195,21 @@ def get_columns():
|
||||
"fieldtype": "Float",
|
||||
"label": "D - E",
|
||||
},
|
||||
|
||||
{
|
||||
"fieldname": "stock_value_difference",
|
||||
"fieldtype": "Float",
|
||||
"label": "(F) Stock Value Difference",
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_value_from_diff",
|
||||
"fieldtype": "Float",
|
||||
"label": "Balance Stock Value using (F)",
|
||||
},
|
||||
{
|
||||
"fieldname": "diff_value_diff",
|
||||
"fieldtype": "Float",
|
||||
"label": "K - D",
|
||||
},
|
||||
{
|
||||
"fieldname": "fifo_stock_diff",
|
||||
"fieldtype": "Float",
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe import _
|
||||
from frappe.utils import flt
|
||||
from six import iteritems
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import get_average_age, get_fifo_queue
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, get_average_age
|
||||
from erpnext.stock.report.stock_balance.stock_balance import (
|
||||
get_item_details,
|
||||
get_item_warehouse_map,
|
||||
@@ -34,7 +34,7 @@ def execute(filters=None):
|
||||
item_map = get_item_details(items, sle, filters)
|
||||
iwb_map = get_item_warehouse_map(filters, sle)
|
||||
warehouse_list = get_warehouse_list(filters)
|
||||
item_ageing = get_fifo_queue(filters)
|
||||
item_ageing = FIFOSlots(filters).generate()
|
||||
data = []
|
||||
item_balance = {}
|
||||
item_value = {}
|
||||
|
||||
@@ -65,8 +65,8 @@ def make_sl_entries(sl_entries, allow_negative_stock=False, via_landed_cost_vouc
|
||||
is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
|
||||
if is_stock_item:
|
||||
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse"))
|
||||
update_bin_qty(bin_name, args)
|
||||
repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
|
||||
update_bin_qty(bin_name, args)
|
||||
else:
|
||||
frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))
|
||||
|
||||
|
||||
@@ -87,8 +87,8 @@ def get_stock_balance(item_code, warehouse, posting_date=None, posting_time=None
|
||||
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
if not posting_date: posting_date = nowdate()
|
||||
if not posting_time: posting_time = nowtime()
|
||||
if posting_date is None: posting_date = nowdate()
|
||||
if posting_time is None: posting_time = nowtime()
|
||||
|
||||
args = {
|
||||
"item_code": item_code,
|
||||
|
||||
@@ -10,13 +10,8 @@ test_records = frappe.get_test_records('Company')
|
||||
|
||||
class TestInit(unittest.TestCase):
|
||||
def test_encode_company_abbr(self):
|
||||
company = frappe.new_doc("Company")
|
||||
company.company_name = "New from Existing Company For Test"
|
||||
company.abbr = "NFECT"
|
||||
company.default_currency = "INR"
|
||||
company.save()
|
||||
|
||||
abbr = company.abbr
|
||||
abbr = "NFECT"
|
||||
|
||||
names = [
|
||||
"Warehouse Name", "ERPNext Foundation India", "Gold - Member - {a}".format(a=abbr),
|
||||
@@ -34,7 +29,7 @@ class TestInit(unittest.TestCase):
|
||||
]
|
||||
|
||||
for i in range(len(names)):
|
||||
enc_name = encode_company_abbr(names[i], company.name)
|
||||
enc_name = encode_company_abbr(names[i], abbr=abbr)
|
||||
self.assertTrue(
|
||||
enc_name == expected_names[i],
|
||||
"{enc} is not same as {exp}".format(enc=enc_name, exp=expected_names[i])
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import copy
|
||||
import signal
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Dict, NewType, Optional
|
||||
@@ -135,3 +136,23 @@ def execute_script_report(
|
||||
report_execute_fn(filter_with_optional_param)
|
||||
|
||||
return report_data
|
||||
|
||||
|
||||
def timeout(seconds=30, error_message="Test timed out."):
|
||||
""" Timeout decorator to ensure a test doesn't run for too long.
|
||||
|
||||
adapted from https://stackoverflow.com/a/2282656"""
|
||||
def decorator(func):
|
||||
def _handle_timeout(signum, frame):
|
||||
raise Exception(error_message)
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
signal.signal(signal.SIGALRM, _handle_timeout)
|
||||
signal.alarm(seconds)
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
finally:
|
||||
signal.alarm(0)
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
@@ -69,6 +69,8 @@ def qty_from_all_warehouses(batch_info):
|
||||
return qty
|
||||
|
||||
def get_price(item_code, price_list, customer_group, company, qty=1):
|
||||
from erpnext.e_commerce.shopping_cart.cart import get_party
|
||||
|
||||
template_item_code = frappe.db.get_value("Item", item_code, "variant_of")
|
||||
|
||||
if price_list:
|
||||
@@ -80,7 +82,8 @@ def get_price(item_code, price_list, customer_group, company, qty=1):
|
||||
filters={"price_list": price_list, "item_code": template_item_code})
|
||||
|
||||
if price:
|
||||
pricing_rule = get_pricing_rule_for_item(frappe._dict({
|
||||
party = get_party()
|
||||
pricing_rule_dict = frappe._dict({
|
||||
"item_code": item_code,
|
||||
"qty": qty,
|
||||
"stock_qty": qty,
|
||||
@@ -91,7 +94,12 @@ def get_price(item_code, price_list, customer_group, company, qty=1):
|
||||
"conversion_rate": 1,
|
||||
"for_shopping_cart": True,
|
||||
"currency": frappe.db.get_value("Price List", price_list, "currency")
|
||||
}))
|
||||
})
|
||||
|
||||
if party and party.doctype == "Customer":
|
||||
pricing_rule_dict.update({"customer": party.name})
|
||||
|
||||
pricing_rule = get_pricing_rule_for_item(pricing_rule_dict)
|
||||
price_obj = price[0]
|
||||
|
||||
if pricing_rule:
|
||||
|
||||
Reference in New Issue
Block a user