Merge pull request #29073 from rohitwaghchaure/merge-hotfix-to-pre-release-for-13-18

chore: Merge branch 'version-13-hotfix' into 'version-13-pre-release'
This commit is contained in:
rohitwaghchaure
2021-12-29 23:29:28 +05:30
committed by GitHub
90 changed files with 2373 additions and 895 deletions

View File

@@ -56,9 +56,9 @@ def set_perpetual_inventory(enable=1, company=None):
company.enable_perpetual_inventory = enable company.enable_perpetual_inventory = enable
company.save() company.save()
def encode_company_abbr(name, company): def encode_company_abbr(name, company=None, abbr=None):
'''Returns name encoded with company abbreviation''' '''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) parts = name.rsplit(" - ", 1)
if parts[-1].lower() != company_abbr.lower(): if parts[-1].lower() != company_abbr.lower():

View File

@@ -218,6 +218,8 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
# updated clear date of all the vouchers based on the bank transaction # updated clear date of all the vouchers based on the bank transaction
vouchers = json.loads(vouchers) vouchers = json.loads(vouchers)
transaction = frappe.get_doc("Bank Transaction", bank_transaction_name) 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: if transaction.unallocated_amount == 0:
frappe.throw(_("This bank transaction is already fully reconciled")) frappe.throw(_("This bank transaction is already fully reconciled"))
total_amount = 0 total_amount = 0
@@ -226,7 +228,7 @@ def reconcile_vouchers(bank_transaction_name, vouchers):
total_amount += get_paid_amount(frappe._dict({ total_amount += get_paid_amount(frappe._dict({
'payment_document': voucher['payment_doctype'], 'payment_document': voucher['payment_doctype'],
'payment_entry': voucher['payment_name'], 'payment_entry': voucher['payment_name'],
}), transaction.currency) }), transaction.currency, company_account)
if total_amount > transaction.unallocated_amount: 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")) 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 return matching
def check_matching(bank_account, company, transaction, document_types): 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) subquery = get_queries(bank_account, company, transaction, document_types)
filters = { filters = {
"amount": transaction.unallocated_amount, "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): def get_je_matching_query(amount_condition, transaction):
# get matching journal entry query # 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") company_account = frappe.get_value("Bank Account", transaction.bank_account, "account")
root_type = frappe.get_value("Account", company_account, "root_type") cr_or_dr = "credit" if transaction.withdrawal > 0 else "debit"
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"
return f""" return f"""

View File

@@ -102,7 +102,7 @@ def get_total_allocated_amount(payment_entry):
AND AND
bt.docstatus = 1""", (payment_entry.payment_document, payment_entry.payment_entry), as_dict=True) 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"]: if payment_entry.payment_document in ["Payment Entry", "Sales Invoice", "Purchase Invoice"]:
paid_amount_field = "paid_amount" paid_amount_field = "paid_amount"
@@ -115,7 +115,7 @@ def get_paid_amount(payment_entry, currency):
payment_entry.payment_entry, paid_amount_field) payment_entry.payment_entry, paid_amount_field)
elif payment_entry.payment_document == "Journal Entry": elif payment_entry.payment_document == "Journal Entry":
return frappe.db.get_value(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": elif payment_entry.payment_document == "Expense Claim":
return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed") return frappe.db.get_value(payment_entry.payment_document, payment_entry.payment_entry, "total_amount_reimbursed")

View File

@@ -132,7 +132,7 @@ def make_company():
company.company_name = "_Test Opening Invoice Company" company.company_name = "_Test Opening Invoice Company"
company.abbr = "_TOIC" company.abbr = "_TOIC"
company.default_currency = "INR" company.default_currency = "INR"
company.country = "India" company.country = "Pakistan"
company.insert() company.insert()
return company return company

View File

@@ -103,10 +103,18 @@
{ {
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column 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, "istable": 1,
"modified": "2019-07-25 15:00:00.460695", "links": [],
"modified": "2021-12-17 19:25:06.053187",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Opening Invoice Creation Tool Item", "name": "Opening Invoice Creation Tool Item",
@@ -116,4 +124,4 @@
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -963,7 +963,7 @@ class TestPurchaseInvoice(unittest.TestCase):
pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True) pi = make_purchase_invoice(item=item.name, qty=1, rate=100, do_not_save=True)
pi.set_posting_time = 1 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].enable_deferred_expense = 1
pi.items[0].service_start_date = "2019-01-10" pi.items[0].service_start_date = "2019-01-10"
pi.items[0].service_end_date = "2019-03-15" pi.items[0].service_end_date = "2019-03-15"

View File

@@ -24,6 +24,7 @@ from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
get_accounting_dimensions, get_accounting_dimensions,
) )
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.accounts.party import get_party_account_currency
class Subscription(Document): class Subscription(Document):
@@ -356,7 +357,10 @@ class Subscription(Document):
if frappe.db.get_value('Supplier', self.party, 'tax_withholding_category'): if frappe.db.get_value('Supplier', self.party, 'tax_withholding_category'):
invoice.apply_tds = 1 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() accounting_dimensions = get_accounting_dimensions()
for dimension in accounting_dimensions: for dimension in accounting_dimensions:

View File

@@ -60,15 +60,38 @@ def create_plan():
plan.billing_interval_count = 3 plan.billing_interval_count = 3
plan.insert() 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'): if not frappe.db.exists('Supplier', '_Test Supplier'):
supplier = frappe.new_doc('Supplier') supplier = frappe.new_doc('Supplier')
supplier.supplier_name = '_Test Supplier' supplier.supplier_name = '_Test Supplier'
supplier.supplier_group = 'All Supplier Groups' supplier.supplier_group = 'All Supplier Groups'
supplier.insert() 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): class TestSubscription(unittest.TestCase):
def setUp(self): def setUp(self):
create_plan() create_plan()
create_parties()
def test_create_subscription_with_trial_with_correct_period(self): def test_create_subscription_with_trial_with_correct_period(self):
subscription = frappe.new_doc('Subscription') subscription = frappe.new_doc('Subscription')
@@ -637,3 +660,22 @@ class TestSubscription(unittest.TestCase):
subscription.process() subscription.process()
self.assertEqual(len(subscription.invoices), 1) 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')

View File

@@ -75,7 +75,8 @@
"fieldname": "cost", "fieldname": "cost",
"fieldtype": "Currency", "fieldtype": "Currency",
"in_list_view": 1, "in_list_view": 1,
"label": "Cost" "label": "Cost",
"options": "currency"
}, },
{ {
"depends_on": "eval:doc.price_determination==\"Based On Price List\"", "depends_on": "eval:doc.price_determination==\"Based On Price List\"",
@@ -147,7 +148,7 @@
} }
], ],
"links": [], "links": [],
"modified": "2021-08-13 10:53:44.205774", "modified": "2021-12-10 15:24:15.794477",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Subscription Plan", "name": "Subscription Plan",

View File

@@ -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
});
});
}
};

View File

@@ -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"
}
]
}

View File

@@ -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

View File

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

View File

@@ -124,6 +124,14 @@ frappe.ui.form.on("Request for Quotation",{
dialog.show() 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) => { preview: (frm) => {
let dialog = new frappe.ui.Dialog({ let dialog = new frappe.ui.Dialog({
title: __('Preview Email'), title: __('Preview Email'),
@@ -184,7 +192,13 @@ frappe.ui.form.on("Request for Quotation",{
dialog.show(); 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",{ frappe.ui.form.on("Request for Quotation Supplier",{
supplier: function(frm, cdt, cdn) { supplier: function(frm, cdt, cdn) {
var d = locals[cdt][cdn] var d = locals[cdt][cdn]

View File

@@ -12,6 +12,7 @@
"vendor", "vendor",
"column_break1", "column_break1",
"transaction_date", "transaction_date",
"schedule_date",
"status", "status",
"amended_from", "amended_from",
"suppliers_section", "suppliers_section",
@@ -246,16 +247,22 @@
"fieldname": "sec_break_email_2", "fieldname": "sec_break_email_2",
"fieldtype": "Section Break", "fieldtype": "Section Break",
"hide_border": 1 "hide_border": 1
},
{
"fieldname": "schedule_date",
"fieldtype": "Date",
"label": "Required Date"
} }
], ],
"icon": "fa fa-shopping-cart", "icon": "fa fa-shopping-cart",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2020-11-05 22:04:29.017134", "modified": "2021-11-24 17:47:49.909000",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Buying", "module": "Buying",
"name": "Request for Quotation", "name": "Request for Quotation",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {

View File

@@ -190,6 +190,8 @@ class AccountsController(TransactionBase):
frappe.throw(_("Row #{0}: Service Start Date cannot be greater than Service End Date").format(d.idx)) 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): 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)) 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): def validate_invoice_documents_schedule(self):
self.validate_payment_schedule_dates() self.validate_payment_schedule_dates()

View File

@@ -46,12 +46,26 @@ class TestWebsiteItem(unittest.TestCase):
] ]
}) })
elif self._testMethodName in WEBITEM_PRICE_TESTS: 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() create_regular_web_item()
make_web_item_price(item_code="Test Mobile Phone") 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( make_web_pricing_rule(
title="Test Pricing Rule for Test Mobile Phone", title="Test Pricing Rule for Test Mobile Phone",
item_code="Test Mobile Phone", item_code="Test Mobile Phone",
selling=1) 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): def test_index_creation(self):
"Check if index is getting created in db." "Check if index is getting created in db."
@@ -188,22 +202,27 @@ class TestWebsiteItem(unittest.TestCase):
# price and pricing rule added via setUp # 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 # check if price and slashed price is fetched correctly
frappe.local.shopping_cart_settings = None frappe.local.shopping_cart_settings = None
data = get_product_info_for_website(item_code, skip_quotation_creation=True) data = get_product_info_for_website(item_code, skip_quotation_creation=True)
self.assertTrue(bool(data.product_info["price"])) self.assertTrue(bool(data.product_info["price"]))
price_object = data.product_info["price"] price_object = data.product_info["price"]
self.assertEqual(price_object.get("discount_percent"), 10) self.assertEqual(price_object.get("discount_percent"), 25)
self.assertEqual(price_object.get("price_list_rate"), 900) 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_mrp"), "₹ 1,000.00")
self.assertEqual(price_object.get("formatted_price"), "900.00") self.assertEqual(price_object.get("formatted_price"), "750.00")
self.assertEqual(price_object.get("formatted_discount_percent"), "10%") 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}) 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 frappe.local.shopping_cart_settings = None
data = get_product_info_for_website(item_code, skip_quotation_creation=True) data = get_product_info_for_website(item_code, skip_quotation_creation=True)
self.assertFalse(bool(data.product_info["price"])) 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, "discount_percentage": kwargs.get("discount_percentage") or 10,
"company": kwargs.get("company") or "_Test Company", "company": kwargs.get("company") or "_Test Company",
"currency": kwargs.get("currency") or "INR", "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() pricing_rule.insert()
else: else:
pricing_rule = frappe.get_doc("Pricing Rule", {"title": title}) 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"]

View File

@@ -23,9 +23,9 @@
{%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%} {%- for index in ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'] -%}
{%- set item = values['card_' + index + '_item'] -%} {%- set item = values['card_' + index + '_item'] -%}
{%- if item -%} {%- if item -%}
{%- set item = frappe.get_doc("Item", item) -%} {%- set web_item = frappe.get_doc("Website Item", item) -%}
{{ item_card( {{ item_card(
item, is_featured=values['card_' + index + '_featured'], web_item, is_featured=values['card_' + index + '_featured'],
is_full_width=True, align="Center" is_full_width=True, align="Center"
) }} ) }}
{%- endif -%} {%- endif -%}

View File

@@ -37,8 +37,8 @@
{ {
"fieldname": "card_1_item", "fieldname": "card_1_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -56,8 +56,8 @@
{ {
"fieldname": "card_2_item", "fieldname": "card_2_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -76,8 +76,8 @@
{ {
"fieldname": "card_3_item", "fieldname": "card_3_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -95,8 +95,8 @@
{ {
"fieldname": "card_4_item", "fieldname": "card_4_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -114,8 +114,8 @@
{ {
"fieldname": "card_5_item", "fieldname": "card_5_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -133,8 +133,8 @@
{ {
"fieldname": "card_6_item", "fieldname": "card_6_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -152,8 +152,8 @@
{ {
"fieldname": "card_7_item", "fieldname": "card_7_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -171,8 +171,8 @@
{ {
"fieldname": "card_8_item", "fieldname": "card_8_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -190,8 +190,8 @@
{ {
"fieldname": "card_9_item", "fieldname": "card_9_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -209,8 +209,8 @@
{ {
"fieldname": "card_10_item", "fieldname": "card_10_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -228,8 +228,8 @@
{ {
"fieldname": "card_11_item", "fieldname": "card_11_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -247,8 +247,8 @@
{ {
"fieldname": "card_12_item", "fieldname": "card_12_item",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Item", "label": "Website Item",
"options": "Item", "options": "Website Item",
"reqd": 0 "reqd": 0
}, },
{ {
@@ -259,7 +259,7 @@
} }
], ],
"idx": 0, "idx": 0,
"modified": "2021-02-24 16:05:31.242342", "modified": "2021-12-21 14:44:59.821335",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "E-commerce", "module": "E-commerce",
"name": "Item Card Group", "name": "Item Card Group",

View File

@@ -388,7 +388,7 @@ scheduler_events = {
"erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_appointment_status", "erpnext.healthcare.doctype.patient_appointment.patient_appointment.update_appointment_status",
"erpnext.buying.doctype.supplier_quotation.supplier_quotation.set_expired_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.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" "erpnext.hr.doctype.interview.interview.send_daily_feedback_reminder"
], ],
"daily_long": [ "daily_long": [

View File

@@ -12,14 +12,15 @@ class AppointmentLetter(Document):
@frappe.whitelist() @frappe.whitelist()
def get_appointment_letter_details(template): def get_appointment_letter_details(template):
body = [] body = []
intro= frappe.get_list("Appointment Letter Template", intro = frappe.get_list('Appointment Letter Template',
fields = ['introduction', 'closing_notes'], fields=['introduction', 'closing_notes'],
filters={'name': template filters={'name': template}
})[0] )[0]
content = frappe.get_list("Appointment Letter content", content = frappe.get_all('Appointment Letter content',
fields = ['title', 'description'], fields=['title', 'description'],
filters={'parent': template filters={'parent': template},
}) order_by='idx'
)
body.append(intro) body.append(intro)
body.append({'description': content}) body.append({'description': content})
return body return body

View File

@@ -171,7 +171,7 @@ frappe.ui.form.on("Expense Claim", {
['docstatus', '=', 1], ['docstatus', '=', 1],
['employee', '=', frm.doc.employee], ['employee', '=', frm.doc.employee],
['paid_amount', '>', 0], ['paid_amount', '>', 0],
['paid_amount', '>', 'claimed_amount'] ['status', '!=', 'Claimed']
] ]
}; };
}); });

View File

@@ -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.employee.test_employee import make_employee
from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry from erpnext.hr.doctype.expense_claim.expense_claim import make_bank_entry
test_records = frappe.get_test_records('Expense Claim')
test_dependencies = ['Employee'] test_dependencies = ['Employee']
company_name = '_Test Company 4' company_name = '_Test Company 3'
class TestExpenseClaim(unittest.TestCase): class TestExpenseClaim(unittest.TestCase):
def tearDown(self):
frappe.db.rollback()
def test_total_expense_claim_for_project(self): def test_total_expense_claim_for_project(self):
frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """) frappe.db.sql("""delete from `tabTask`""")
frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """) frappe.db.sql("""delete from `tabProject`""")
frappe.db.sql("update `tabExpense Claim` set project = '', task = ''") frappe.db.sql("update `tabExpense Claim` set project = '', task = ''")
project = frappe.get_doc({ project = frappe.get_doc({
@@ -37,12 +39,12 @@ class TestExpenseClaim(unittest.TestCase):
task_name = task.name task_name = task.name
payable_account = get_payable_account(company_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("Task", task_name, "total_expense_claim"), 200)
self.assertEqual(frappe.db.get_value("Project", project.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("Task", task_name, "total_expense_claim"), 700)
self.assertEqual(frappe.db.get_value("Project", project.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): def test_expense_claim_status(self):
payable_account = get_payable_account(company_name) 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_dict = make_bank_entry("Expense Claim", expense_claim.name)
je = frappe.get_doc(je_dict) je = frappe.get_doc(je_dict)
@@ -73,7 +75,7 @@ class TestExpenseClaim(unittest.TestCase):
def test_expense_claim_gl_entry(self): def test_expense_claim_gl_entry(self):
payable_account = get_payable_account(company_name) payable_account = get_payable_account(company_name)
taxes = generate_taxes() 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) do_not_submit=True, taxes=taxes)
expense_claim.submit() expense_claim.submit()
@@ -84,9 +86,9 @@ class TestExpenseClaim(unittest.TestCase):
self.assertTrue(gl_entries) self.assertTrue(gl_entries)
expected_values = dict((d[0], d) for d in [ 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], [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: for gle in gl_entries:
@@ -102,7 +104,7 @@ class TestExpenseClaim(unittest.TestCase):
"payable_account": payable_account, "payable_account": payable_account,
"approval_status": "Rejected", "approval_status": "Rejected",
"expenses": "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() expense_claim.submit()

View File

@@ -5,6 +5,7 @@ import frappe
from frappe.utils import add_days, add_months, getdate, nowdate from frappe.utils import add_days, add_months, getdate, nowdate
import erpnext 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_ledger_entry.leave_ledger_entry import process_expired_allocation
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
@@ -14,16 +15,19 @@ class TestLeaveAllocation(unittest.TestCase):
def setUpClass(cls): def setUpClass(cls):
frappe.db.sql("delete from `tabLeave Period`") frappe.db.sql("delete from `tabLeave Period`")
def test_overlapping_allocation(self): emp_id = make_employee("test_emp_leave_allocation@salary.com")
frappe.db.sql("delete from `tabLeave Allocation`") 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 = [ leaves = [
{ {
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"__islocal": 1, "__islocal": 1,
"employee": employee.name, "employee": self.employee.name,
"employee_name": employee.employee_name, "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type", "leave_type": "_Test Leave Type",
"from_date": getdate("2015-10-01"), "from_date": getdate("2015-10-01"),
"to_date": getdate("2015-10-31"), "to_date": getdate("2015-10-31"),
@@ -33,8 +37,8 @@ class TestLeaveAllocation(unittest.TestCase):
{ {
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"__islocal": 1, "__islocal": 1,
"employee": employee.name, "employee": self.employee.name,
"employee_name": employee.employee_name, "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type", "leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-01"), "from_date": getdate("2015-09-01"),
"to_date": getdate("2015-11-30"), "to_date": getdate("2015-11-30"),
@@ -46,40 +50,36 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save) self.assertRaises(frappe.ValidationError, frappe.get_doc(leaves[1]).save)
def test_invalid_period(self): 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({ doc = frappe.get_doc({
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"__islocal": 1, "__islocal": 1,
"employee": employee.name, "employee": self.employee.name,
"employee_name": employee.employee_name, "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type", "leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-30"), "from_date": getdate("2015-09-30"),
"to_date": getdate("2015-09-1"), "to_date": getdate("2015-09-1"),
"new_leaves_allocated": 5 "new_leaves_allocated": 5
}) })
#invalid period # invalid period
self.assertRaises(frappe.ValidationError, doc.save) self.assertRaises(frappe.ValidationError, doc.save)
def test_allocated_leave_days_over_period(self): 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({ doc = frappe.get_doc({
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"__islocal": 1, "__islocal": 1,
"employee": employee.name, "employee": self.employee.name,
"employee_name": employee.employee_name, "employee_name": self.employee.employee_name,
"leave_type": "_Test Leave Type", "leave_type": "_Test Leave Type",
"from_date": getdate("2015-09-1"), "from_date": getdate("2015-09-1"),
"to_date": getdate("2015-09-30"), "to_date": getdate("2015-09-30"),
"new_leaves_allocated": 35 "new_leaves_allocated": 35
}) })
#allocated leave more than period
# allocated leave more than period
self.assertRaises(frappe.ValidationError, doc.save) self.assertRaises(frappe.ValidationError, doc.save)
def test_carry_forward_calculation(self): 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 = create_leave_type(leave_type_name="_Test_CF_leave", is_carry_forward=1)
leave_type.maximum_carry_forwarded_leaves = 10 leave_type.maximum_carry_forwarded_leaves = 10
leave_type.max_leaves_allowed = 30 leave_type.max_leaves_allowed = 30
@@ -87,6 +87,8 @@ class TestLeaveAllocation(unittest.TestCase):
# initial leave allocation = 15 # initial leave allocation = 15
leave_allocation = create_leave_allocation( leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave", leave_type="_Test_CF_leave",
from_date=add_months(nowdate(), -12), from_date=add_months(nowdate(), -12),
to_date=add_months(nowdate(), -1), to_date=add_months(nowdate(), -1),
@@ -96,6 +98,8 @@ class TestLeaveAllocation(unittest.TestCase):
# carry forwarded leaves considering maximum_carry_forwarded_leaves # carry forwarded leaves considering maximum_carry_forwarded_leaves
# new_leaves = 15, carry_forwarded = 10 # new_leaves = 15, carry_forwarded = 10
leave_allocation_1 = create_leave_allocation( leave_allocation_1 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave", leave_type="_Test_CF_leave",
carry_forward=1) carry_forward=1)
leave_allocation_1.submit() leave_allocation_1.submit()
@@ -107,6 +111,8 @@ class TestLeaveAllocation(unittest.TestCase):
# carry forwarded leaves considering max_leave_allowed # carry forwarded leaves considering max_leave_allowed
# max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5 # max_leave_allowed = 30, new_leaves = 25, carry_forwarded = 5
leave_allocation_2 = create_leave_allocation( leave_allocation_2 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave", leave_type="_Test_CF_leave",
carry_forward=1, carry_forward=1,
new_leaves_allocated=25) new_leaves_allocated=25)
@@ -115,8 +121,6 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertEqual(leave_allocation_2.unused_leaves, 5) self.assertEqual(leave_allocation_2.unused_leaves, 5)
def test_carry_forward_leaves_expiry(self): 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 = create_leave_type(
leave_type_name="_Test_CF_leave_expiry", leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1, is_carry_forward=1,
@@ -125,6 +129,8 @@ class TestLeaveAllocation(unittest.TestCase):
# initial leave allocation # initial leave allocation
leave_allocation = create_leave_allocation( leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry", leave_type="_Test_CF_leave_expiry",
from_date=add_months(nowdate(), -24), from_date=add_months(nowdate(), -24),
to_date=add_months(nowdate(), -12), to_date=add_months(nowdate(), -12),
@@ -132,6 +138,8 @@ class TestLeaveAllocation(unittest.TestCase):
leave_allocation.submit() leave_allocation.submit()
leave_allocation = create_leave_allocation( leave_allocation = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry", leave_type="_Test_CF_leave_expiry",
from_date=add_days(nowdate(), -90), from_date=add_days(nowdate(), -90),
to_date=add_days(nowdate(), 100), 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 with carry forward of only new leaves allocated
leave_allocation_1 = create_leave_allocation( leave_allocation_1 = create_leave_allocation(
employee=self.employee.name,
employee_name=self.employee.employee_name,
leave_type="_Test_CF_leave_expiry", leave_type="_Test_CF_leave_expiry",
carry_forward=1, carry_forward=1,
from_date=add_months(nowdate(), 6), 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) self.assertEqual(leave_allocation_1.unused_leaves, leave_allocation.new_leaves_allocated)
def test_creation_of_leave_ledger_entry_on_submit(self): def test_creation_of_leave_ledger_entry_on_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`") leave_allocation = create_leave_allocation(
employee=self.employee.name,
leave_allocation = create_leave_allocation() employee_name=self.employee.employee_name
)
leave_allocation.submit() leave_allocation.submit()
leave_ledger_entry = frappe.get_all('Leave Ledger Entry', fields='*', filters=dict(transaction_name=leave_allocation.name)) 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})) self.assertFalse(frappe.db.exists("Leave Ledger Entry", {'transaction_name':leave_allocation.name}))
def test_leave_addition_after_submit(self): def test_leave_addition_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`") leave_allocation = create_leave_allocation(
frappe.db.sql("delete from `tabLeave Ledger Entry`") employee=self.employee.name,
employee_name=self.employee.employee_name
leave_allocation = create_leave_allocation() )
leave_allocation.submit() leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15) self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 40 leave_allocation.new_leaves_allocated = 40
@@ -180,44 +191,55 @@ class TestLeaveAllocation(unittest.TestCase):
self.assertTrue(leave_allocation.total_leaves_allocated, 40) self.assertTrue(leave_allocation.total_leaves_allocated, 40)
def test_leave_subtraction_after_submit(self): def test_leave_subtraction_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`") leave_allocation = create_leave_allocation(
frappe.db.sql("delete from `tabLeave Ledger Entry`") employee=self.employee.name,
leave_allocation = create_leave_allocation() employee_name=self.employee.employee_name
)
leave_allocation.submit() leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15) self.assertTrue(leave_allocation.total_leaves_allocated, 15)
leave_allocation.new_leaves_allocated = 10 leave_allocation.new_leaves_allocated = 10
leave_allocation.submit() leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 10) self.assertTrue(leave_allocation.total_leaves_allocated, 10)
def test_against_leave_application_validation_after_submit(self): def test_validation_against_leave_application_after_submit(self):
frappe.db.sql("delete from `tabLeave Allocation`") from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
frappe.db.sql("delete from `tabLeave Ledger Entry`")
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() leave_allocation.submit()
self.assertTrue(leave_allocation.total_leaves_allocated, 15) 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({ leave_application = frappe.get_doc({
"doctype": 'Leave Application', "doctype": 'Leave Application',
"employee": employee.name, "employee": self.employee.name,
"leave_type": "_Test Leave Type", "leave_type": "_Test Leave Type",
"from_date": add_months(nowdate(), 2), "from_date": add_months(nowdate(), 2),
"to_date": add_months(add_days(nowdate(), 10), 2), "to_date": add_months(add_days(nowdate(), 10), 2),
"company": erpnext.get_default_company() or "_Test Company", "company": self.employee.company,
"docstatus": 1, "docstatus": 1,
"status": "Approved", "status": "Approved",
"leave_approver": 'test@example.com' "leave_approver": 'test@example.com'
}) })
leave_application.submit() leave_application.submit()
leave_allocation.new_leaves_allocated = 8 leave_application.reload()
leave_allocation.total_leaves_allocated = 8
# 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) self.assertRaises(frappe.ValidationError, leave_allocation.submit)
def create_leave_allocation(**args): def create_leave_allocation(**args):
args = frappe._dict(args) args = frappe._dict(args)
employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) emp_id = make_employee("test_emp_leave_allocation@salary.com")
leave_allocation = frappe.get_doc({ employee = frappe.get_doc("Employee", emp_id)
return frappe.get_doc({
"doctype": "Leave Allocation", "doctype": "Leave Allocation",
"__islocal": 1, "__islocal": 1,
"employee": args.employee or employee.name, "employee": args.employee or employee.name,
@@ -228,6 +250,5 @@ def create_leave_allocation(**args):
"carry_forward": args.carry_forward or 0, "carry_forward": args.carry_forward or 0,
"to_date": args.to_date or add_months(nowdate(), 12) "to_date": args.to_date or add_months(nowdate(), 12)
}) })
return leave_allocation
test_dependencies = ["Employee", "Leave Type"] test_dependencies = ["Employee", "Leave Type"]

View File

@@ -240,12 +240,14 @@
"label": "Repayment Schedule" "label": "Repayment Schedule"
}, },
{ {
"allow_on_submit": 1,
"depends_on": "eval:doc.is_term_loan == 1", "depends_on": "eval:doc.is_term_loan == 1",
"fieldname": "repayment_schedule", "fieldname": "repayment_schedule",
"fieldtype": "Table", "fieldtype": "Table",
"label": "Repayment Schedule", "label": "Repayment Schedule",
"no_copy": 1, "no_copy": 1,
"options": "Repayment Schedule" "options": "Repayment Schedule",
"read_only": 1
}, },
{ {
"fieldname": "section_break_17", "fieldname": "section_break_17",
@@ -359,10 +361,11 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2021-10-12 18:10:32.360818", "modified": "2021-10-20 08:28:16.796105",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Loan Management", "module": "Loan Management",
"name": "Loan", "name": "Loan",
"naming_rule": "Expression (old style)",
"owner": "Administrator", "owner": "Administrator",
"permissions": [ "permissions": [
{ {
@@ -389,4 +392,4 @@
"sort_field": "creation", "sort_field": "creation",
"sort_order": "DESC", "sort_order": "DESC",
"track_changes": 1 "track_changes": 1
} }

View File

@@ -7,7 +7,7 @@ import math
import frappe import frappe
from frappe import _ 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 from six import string_types
import erpnext 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") 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": 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): def check_sanctioned_amount_limit(self):
sanctioned_amount_limit = get_sanctioned_amount_limit(self.applicant_type, self.applicant, self.company) 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, "total_payment": total_payment,
"balance_loan_amount": balance_amount "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 payment_date = next_payment_date
def set_repayment_period(self): 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: if monthly_repayment_amount > loan_amount:
frappe.throw(_("Monthly Repayment Amount cannot be greater than 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: if rate_of_interest:
monthly_interest_rate = flt(rate_of_interest) / (12 *100) monthly_interest_rate = flt(rate_of_interest) / (12 *100)
monthly_repayment_amount = math.ceil((loan_amount * monthly_interest_rate * monthly_repayment_amount = math.ceil((loan_amount * monthly_interest_rate *
@@ -396,3 +396,9 @@ def get_shortfall_applicants():
"value": len(applicants), "value": len(applicants),
"fieldtype": "Int" "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)

View File

@@ -218,6 +218,14 @@ class TestLoan(unittest.TestCase):
self.assertEqual(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid - self.assertEqual(flt(loan.total_principal_paid, 0), flt(repayment_entry.amount_paid -
penalty_amount - total_interest_paid, 0)) 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): def test_loan_closure(self):
pledge = [{ pledge = [{
"loan_security": "Test Security 1", "loan_security": "Test Security 1",
@@ -295,6 +303,27 @@ class TestLoan(unittest.TestCase):
self.assertEqual(amounts[0], 11250.00) self.assertEqual(amounts[0], 11250.00)
self.assertEqual(amounts[1], 78303.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): def test_security_shortfall(self):
pledges = [{ pledges = [{
"loan_security": "Test Security 2", "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, 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({ loan = frappe.get_doc({
"doctype": "Loan", "doctype": "Loan",
"applicant_type": "Employee", "applicant_type": applicant_type or "Employee",
"company": "_Test Company", "company": "_Test Company",
"applicant": applicant, "applicant": applicant,
"loan_type": loan_type, "loan_type": loan_type,
"loan_amount": loan_amount, "loan_amount": loan_amount,
"repayment_method": repayment_method, "repayment_method": repayment_method,
"repayment_periods": repayment_periods, "repayment_periods": repayment_periods,
"repayment_start_date": nowdate(), "repayment_start_date": repayment_start_date or nowdate(),
"is_term_loan": 1, "is_term_loan": 1,
"posting_date": posting_date or nowdate() "posting_date": posting_date or nowdate()
}) })

View File

@@ -81,7 +81,7 @@ class LoanApplication(Document):
if self.is_term_loan: if self.is_term_loan:
if self.repayment_method == "Repay Over Number of Periods": 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": if self.repayment_method == "Repay Fixed Amount per Period":
monthly_interest_rate = flt(self.rate_of_interest) / (12 *100) monthly_interest_rate = flt(self.rate_of_interest) / (12 *100)

View File

@@ -176,20 +176,19 @@ def get_total_pledged_security_value(loan):
@frappe.whitelist() @frappe.whitelist()
def get_disbursal_amount(loan, on_current_security_price=0): 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", 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", "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, if loan_details.is_secured_loan and frappe.get_all('Loan Security Shortfall', filters={'loan': loan,
'status': 'Pending'}): 'status': 'Pending'}):
return 0 return 0
if loan_details.status == 'Disbursed': pending_principal_amount = get_pending_principal_amount(loan_details)
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)
security_value = 0.0 security_value = 0.0
if loan_details.is_secured_loan and on_current_security_price: if loan_details.is_secured_loan and on_current_security_price:

View File

@@ -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: if gle_map:
make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj) make_gl_entries(gle_map, cancel=cancel, adv_adj=adv_adj)
@@ -82,7 +115,10 @@ class LoanInterestAccrual(AccountsController):
# rate of interest is 13.5 then first loan interest accural will be on '01-10-2019' # 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 # 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): 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) no_of_days = get_no_of_days_for_interest_accural(loan, posting_date)
precision = cint(frappe.db.get_default("currency_precision")) or 2 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: if no_of_days <= 0:
return return
if loan.status == 'Disbursed': pending_principal_amount = get_pending_principal_amount(loan)
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)
interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date) interest_per_day = get_per_day_interest(pending_principal_amount, loan.rate_of_interest, posting_date)
payable_interest = interest_per_day * no_of_days 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: if not open_loans:
open_loans = frappe.get_all("Loan", 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", "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"], "rate_of_interest", "total_interest_payable", "written_off_amount", "total_principal_paid", "repayment_start_date"],
filters=query_filters) 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 l.is_term_loan =1
AND rs.payment_date <= %s AND rs.payment_date <= %s
AND rs.is_accrued=0 {0} 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 return term_loans

View File

@@ -36,9 +36,12 @@ class LoanRepayment(AccountsController):
def on_submit(self): def on_submit(self):
self.update_paid_amount() self.update_paid_amount()
self.update_repayment_schedule()
self.make_gl_entries() self.make_gl_entries()
def on_cancel(self): def on_cancel(self):
self.check_future_accruals()
self.update_repayment_schedule(cancel=1)
self.mark_as_unpaid() self.mark_as_unpaid()
self.ignore_linked_doctypes = ['GL Entry'] self.ignore_linked_doctypes = ['GL Entry']
self.make_gl_entries(cancel=1) self.make_gl_entries(cancel=1)
@@ -91,7 +94,7 @@ class LoanRepayment(AccountsController):
def book_unaccrued_interest(self): def book_unaccrued_interest(self):
precision = cint(frappe.db.get_default("currency_precision")) or 2 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: if not self.is_term_loan:
# get last loan interest accrual date # get last loan interest accrual date
last_accrual_date = get_last_accrual_date(self.against_loan) last_accrual_date = get_last_accrual_date(self.against_loan)
@@ -122,7 +125,18 @@ class LoanRepayment(AccountsController):
}) })
def update_paid_amount(self): 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: for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual` frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
@@ -131,17 +145,31 @@ class LoanRepayment(AccountsController):
WHERE name = %s""", WHERE name = %s""",
(flt(payment.paid_principal_amount), flt(payment.paid_interest_amount), payment.loan_interest_accrual)) (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 frappe.db.sql(""" UPDATE `tabLoan`
WHERE name = %s """, (loan.total_amount_paid + self.amount_paid, SET total_amount_paid = %s, total_principal_paid = %s, status = %s
loan.total_principal_paid + self.principal_amount_paid, self.against_loan)) 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) update_shortfall_status(self.against_loan, self.principal_amount_paid)
def mark_as_unpaid(self): 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) 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: for payment in self.repayment_details:
frappe.db.sql(""" UPDATE `tabLoan Interest Accrual` frappe.db.sql(""" UPDATE `tabLoan Interest Accrual`
SET paid_principal_amount = `paid_principal_amount` - %s, 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 = frappe.get_doc('Loan Interest Accrual', payment.loan_interest_accrual)
lia_doc.cancel() lia_doc.cancel()
frappe.db.sql(""" UPDATE `tabLoan` SET total_amount_paid = %s, total_principal_paid = %s frappe.db.sql(""" UPDATE `tabLoan`
WHERE name = %s """, (loan.total_amount_paid - self.amount_paid, SET total_amount_paid = %s, total_principal_paid = %s, status = %s
loan.total_principal_paid - self.principal_amount_paid, self.against_loan)) WHERE name = %s """, (loan.total_amount_paid, loan.total_principal_paid, loan.status, self.against_loan))
if loan.status == "Loan Closure Requested": def check_future_accruals(self):
frappe.db.set_value("Loan", self.against_loan, "status", "Disbursed") 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): def allocate_amounts(self, repayment_details):
self.set('repayment_details', []) self.set('repayment_details', [])
@@ -183,50 +219,93 @@ class LoanRepayment(AccountsController):
interest_paid -= self.total_penalty_paid interest_paid -= self.total_penalty_paid
total_interest_paid = 0 if self.is_term_loan:
# interest_paid = self.amount_paid - self.principal_amount_paid - self.penalty_amount 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: if interest_paid > 0:
for lia, amounts in iteritems(repayment_details.get('pending_accrual_entries', [])): 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'] interest_amount = amounts['interest_amount']
paid_principal = amounts['payable_principal_amount'] self.total_interest_paid += interest_amount
self.principal_amount_paid += paid_principal interest_paid -= interest_amount
interest_paid -= (interest_amount + paid_principal)
elif interest_paid: elif interest_paid:
if interest_paid >= amounts['interest_amount']: if interest_paid >= amounts['interest_amount']:
interest_amount = amounts['interest_amount'] interest_amount = amounts['interest_amount']
paid_principal = interest_paid - interest_amount self.total_interest_paid += interest_amount
self.principal_amount_paid += paid_principal
interest_paid = 0 interest_paid = 0
else: else:
interest_amount = interest_paid interest_amount = interest_paid
self.total_interest_paid += interest_amount
interest_paid = 0 interest_paid = 0
paid_principal=0
total_interest_paid += interest_amount if interest_amount:
self.append('repayment_details', { self.append('repayment_details', {
'loan_interest_accrual': lia, 'loan_interest_accrual': lia,
'paid_interest_amount': interest_amount, 'paid_interest_amount': interest_amount,
'paid_principal_amount': paid_principal '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: if repayment_details['unaccrued_interest'] and interest_paid > 0:
# no of days for which to accrue interest # no of days for which to accrue interest
# Interest can only be accrued for an entire day and not partial # Interest can only be accrued for an entire day and not partial
if interest_paid > repayment_details['unaccrued_interest']: if interest_paid > repayment_details['unaccrued_interest']:
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: else:
# get no of days for which interest can be paid # get no of days for which interest can be paid
per_day_interest = get_per_day_interest(self.pending_principal_amount, per_day_interest = get_per_day_interest(self.pending_principal_amount,
self.rate_of_interest, self.posting_date) self.rate_of_interest, self.posting_date)
no_of_days = cint(interest_paid/per_day_interest) 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 interest_paid -= no_of_days * per_day_interest
self.total_interest_paid = total_interest_paid
if interest_paid > 0: if interest_paid > 0:
self.principal_amount_paid += interest_paid self.principal_amount_paid += interest_paid
@@ -362,6 +441,76 @@ def get_penalty_details(against_loan):
else: else:
return None, 0 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 # 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 # 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: if due_date and not final_due_date:
final_due_date = add_days(due_date, loan_type_details.grace_period_in_days) 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 = get_pending_principal_amount(against_loan_doc)
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
unaccrued_interest = 0 unaccrued_interest = 0
if due_date: if due_date:

View File

@@ -28,6 +28,9 @@ class LoanSecurityUnpledge(Document):
d.idx, frappe.bold(d.loan_security))) d.idx, frappe.bold(d.loan_security)))
def validate_unpledge_qty(self): 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 ( from erpnext.loan_management.doctype.loan_security_shortfall.loan_security_shortfall import (
get_ltv_ratio, get_ltv_ratio,
) )
@@ -44,15 +47,10 @@ class LoanSecurityUnpledge(Document):
"valid_upto": (">=", get_datetime()) "valid_upto": (">=", get_datetime())
}, as_list=1)) }, 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) 'total_interest_payable', 'written_off_amount', 'disbursed_amount', 'status'], as_dict=1)
if loan_details.status == 'Disbursed': pending_principal_amount = get_pending_principal_amount(loan_details)
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)
security_value = 0 security_value = 0
unpledge_qty_map = {} unpledge_qty_map = {}

View File

@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.utils import add_months, today from frappe.utils import add_months, today
from erpnext import get_company_currency from erpnext import get_company_currency
from erpnext.tests.utils import ERPNextTestCase
from .blanket_order import make_order from .blanket_order import make_order
class TestBlanketOrder(unittest.TestCase): class TestBlanketOrder(ERPNextTestCase):
def setUp(self): def setUp(self):
frappe.flags.args = frappe._dict() frappe.flags.args = frappe._dict()

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import unittest
from collections import deque from collections import deque
from functools import partial from functools import partial
@@ -18,10 +17,11 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
create_stock_reconciliation, create_stock_reconciliation,
) )
from erpnext.tests.test_subcontracting import set_backflush_based_on from erpnext.tests.test_subcontracting import set_backflush_based_on
from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM') test_records = frappe.get_test_records('BOM')
class TestBOM(unittest.TestCase): class TestBOM(ERPNextTestCase):
def setUp(self): def setUp(self):
if not frappe.get_value('Item', '_Test Item'): if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item') make_test_records('Item')

View File

@@ -1,19 +1,16 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import unittest
import frappe import frappe
from erpnext.manufacturing.doctype.bom_update_tool.bom_update_tool import update_cost 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.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.tests.utils import ERPNextTestCase
test_records = frappe.get_test_records('BOM') test_records = frappe.get_test_records('BOM')
class TestBOMUpdateTool(unittest.TestCase): class TestBOMUpdateTool(ERPNextTestCase):
def test_replace_bom(self): def test_replace_bom(self):
current_bom = "BOM-_Test Item Home Desktop Manufactured-001" current_bom = "BOM-_Test Item Home Desktop Manufactured-001"

View File

@@ -76,6 +76,15 @@ frappe.ui.form.on('Job Card', {
frm.trigger("prepare_timer_buttons"); frm.trigger("prepare_timer_buttons");
} }
frm.trigger("setup_quality_inspection"); 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) { setup_quality_inspection: function(frm) {

View File

@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.utils import random_string 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.work_order.test_work_order import make_wo_order_test_record
from erpnext.manufacturing.doctype.workstation.test_workstation import make_workstation 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.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): def setUp(self):
make_bom_for_jc_tests() make_bom_for_jc_tests()
@@ -329,4 +329,4 @@ def make_bom_for_jc_tests():
bom.rm_cost_as_per = "Valuation Rate" bom.rm_cost_as_per = "Valuation Rate"
bom.items[0].uom = "_Test UOM 1" bom.items[0].uom = "_Test UOM 1"
bom.items[0].conversion_factor = 5 bom.items[0].conversion_factor = 5
bom.insert() bom.insert()

View File

@@ -1,8 +1,5 @@
# Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2017, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.utils import add_to_date, flt, now_datetime, nowdate 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 ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation, create_stock_reconciliation,
) )
from erpnext.tests.utils import ERPNextTestCase
class TestProductionPlan(unittest.TestCase): class TestProductionPlan(ERPNextTestCase):
def setUp(self): def setUp(self):
for item in ['Test Production Item 1', 'Subassembly Item 1', for item in ['Test Production Item 1', 'Subassembly Item 1',
'Raw Material Item 1', 'Raw Material Item 2']: 'Raw Material Item 1', 'Raw Material Item 2']:

View File

@@ -1,17 +1,15 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
from erpnext.manufacturing.doctype.job_card.job_card import OperationSequenceError 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.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.stock.doctype.item.test_item import make_item
from erpnext.tests.utils import ERPNextTestCase
class TestRouting(unittest.TestCase): class TestRouting(ERPNextTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
cls.item_code = "Test Routing Item - A" cls.item_code = "Test Routing Item - A"

View File

@@ -1,6 +1,5 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import unittest
import frappe import frappe
from frappe.utils import add_months, cint, flt, now, today 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.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.utils import get_bin from erpnext.stock.utils import get_bin
from erpnext.tests.utils import ERPNextTestCase, timeout
class TestWorkOrder(unittest.TestCase): class TestWorkOrder(ERPNextTestCase):
def setUp(self): def setUp(self):
self.warehouse = '_Test Warehouse 2 - _TC' self.warehouse = '_Test Warehouse 2 - _TC'
self.item = '_Test Item' self.item = '_Test Item'
@@ -91,7 +91,7 @@ class TestWorkOrder(unittest.TestCase):
def test_reserved_qty_for_partial_completion(self): def test_reserved_qty_for_partial_completion(self):
item = "_Test Item" item = "_Test Item"
warehouse = create_warehouse("Test Warehouse for reserved_qty - _TC") warehouse = "_Test Warehouse - _TC"
bin1_at_start = get_bin(item, warehouse) bin1_at_start = get_bin(item, warehouse)
@@ -196,8 +196,6 @@ class TestWorkOrder(unittest.TestCase):
# no change in reserved / projected # no change in reserved / projected
self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production), self.assertEqual(cint(bin1_on_end_production.reserved_qty_for_production),
cint(bin1_on_start_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): def test_backflush_qty_for_overpduction_manufacture(self):
cancel_stock_entry = [] cancel_stock_entry = []
@@ -376,6 +374,7 @@ class TestWorkOrder(unittest.TestCase):
self.assertEqual(len(ste.additional_costs), 1) self.assertEqual(len(ste.additional_costs), 1)
self.assertEqual(ste.total_additional_costs, 1000) self.assertEqual(ste.total_additional_costs, 1000)
@timeout(seconds=60)
def test_job_card(self): def test_job_card(self):
stock_entries = [] stock_entries = []
bom = frappe.get_doc('BOM', { bom = frappe.get_doc('BOM', {
@@ -769,6 +768,7 @@ class TestWorkOrder(unittest.TestCase):
total_pl_qty total_pl_qty
) )
@timeout(seconds=60)
def test_job_card_scrap_item(self): def test_job_card_scrap_item(self):
items = ['Test FG Item for Scrap Item Test', 'Test RM Item 1 for Scrap Item Test', items = ['Test FG Item for Scrap Item Test', 'Test RM Item 1 for Scrap Item Test',
'Test RM Item 2 for Scrap Item Test'] 'Test RM Item 2 for Scrap Item Test']

View File

@@ -1,8 +1,5 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# See license.txt # See license.txt
import unittest
import frappe import frappe
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
@@ -13,12 +10,13 @@ from erpnext.manufacturing.doctype.workstation.workstation import (
WorkstationHolidayError, WorkstationHolidayError,
check_if_within_operating_hours, check_if_within_operating_hours,
) )
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Warehouse"] test_dependencies = ["Warehouse"]
test_records = frappe.get_test_records('Workstation') test_records = frappe.get_test_records('Workstation')
make_test_records('Workstation') make_test_records('Workstation')
class TestWorkstation(unittest.TestCase): class TestWorkstation(ERPNextTestCase):
def test_validate_timings(self): 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 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") check_if_within_operating_hours("_Test Workstation 1", "Operation 1", "2013-02-02 10:00:00", "2013-02-02 20:00:00")

View File

@@ -410,7 +410,7 @@ def get_plan_from_razorpay_id(plan_id):
def set_expired_status(): def set_expired_status():
frappe.db.sql(""" frappe.db.sql("""
UPDATE UPDATE
`tabMembership` SET `status` = 'Expired' `tabMembership` SET `membership_status` = 'Expired'
WHERE WHERE
`status` not in ('Cancelled') AND `to_date` < %s `membership_status` not in ('Cancelled') AND `to_date` < %s
""", (nowdate())) """, (nowdate()))

View File

@@ -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.update_category_in_ltds_certificate
erpnext.patches.v13_0.create_ksa_vat_custom_fields erpnext.patches.v13_0.create_ksa_vat_custom_fields
erpnext.patches.v13_0.rename_ksa_qr_field 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

View File

@@ -23,4 +23,5 @@ def execute():
delivery_settings = frappe.get_doc("Delivery Settings") delivery_settings = frappe.get_doc("Delivery Settings")
delivery_settings.dispatch_template = _("Dispatch Notification") delivery_settings.dispatch_template = _("Dispatch Notification")
delivery_settings.flags.ignore_links = True
delivery_settings.save() delivery_settings.save()

View File

@@ -98,6 +98,8 @@ def execute():
'itc_central_tax': 0, 'itc_central_tax': 0,
'itc_cess_amount': 0 'itc_cess_amount': 0
}) })
if not gst_accounts:
continue
if d.account_head in gst_accounts.get('igst_account'): if d.account_head in gst_accounts.get('igst_account'):
amount_map[d.parent]['itc_integrated_tax'] += d.amount amount_map[d.parent]['itc_integrated_tax'] += d.amount

View File

@@ -7,7 +7,7 @@ def execute():
frappe.reload_doc("selling", "doctype", "sales_order_item") frappe.reload_doc("selling", "doctype", "sales_order_item")
for doctype in ["Sales Order", "Material Request"]: 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": if doctype == "Material Request":
condition = " and doc.per_ordered < 100 and doc.material_request_type = 'Manufacture'" 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 child_doc.bom_no = item.default_bom
WHERE WHERE
child_doc.item_code = item.name and child_doc.docstatus < 2 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} and item.default_bom is not null and item.default_bom != '' {cond}
""".format(doc = doctype, cond = condition)) """.format(doc = doctype, cond = condition))

View File

@@ -33,4 +33,5 @@ def execute():
hr_settings = frappe.get_doc('HR Settings') hr_settings = frappe.get_doc('HR Settings')
hr_settings.interview_reminder_template = _('Interview Reminder') hr_settings.interview_reminder_template = _('Interview Reminder')
hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder') hr_settings.feedback_reminder_notification_template = _('Interview Feedback Reminder')
hr_settings.flags.ignore_links = True
hr_settings.save() hr_settings.save()

View File

@@ -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

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

View File

@@ -944,10 +944,12 @@ class SalarySlip(TransactionBase):
def get_amount_based_on_payment_days(self, row, joining_date, relieving_date): def get_amount_based_on_payment_days(self, row, joining_date, relieving_date):
amount, additional_amount = row.amount, row.additional_amount 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 if (self.salary_structure and
cint(row.depends_on_payment_days) and cint(self.total_working_days) 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 (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 getdate(self.start_date) < joining_date or
(relieving_date and getdate(self.end_date) > relieving_date) (relieving_date and getdate(self.end_date) > relieving_date)
)): )):
@@ -956,7 +958,7 @@ class SalarySlip(TransactionBase):
amount = flt((flt(row.default_amount) * flt(self.payment_days) amount = flt((flt(row.default_amount) * flt(self.payment_days)
/ cint(self.total_working_days)), row.precision("amount")) + additional_amount / 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 amount, additional_amount = 0, 0
elif not row.amount: elif not row.amount:
amount = flt(row.default_amount) + flt(row.additional_amount) amount = flt(row.default_amount) + flt(row.additional_amount)

View File

@@ -134,6 +134,58 @@ class TestSalarySlip(unittest.TestCase):
frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave") 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): def test_component_amount_dependent_on_another_payment_days_based_component(self):
from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.hr.doctype.attendance.attendance import mark_attendance
from erpnext.payroll.doctype.salary_structure.test_salary_structure import ( from erpnext.payroll.doctype.salary_structure.test_salary_structure import (

View File

@@ -34,10 +34,6 @@ class TestTimesheet(unittest.TestCase):
for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]: for dt in ["Salary Slip", "Salary Structure", "Salary Structure Assignment", "Timesheet"]:
frappe.db.sql("delete from `tab%s`" % dt) 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): def test_timesheet_billing_amount(self):
emp = make_employee("test_employee_6@salary.com") 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" salary_structure_name = "Timesheet Salary Structure Test"
frequency = "Monthly" 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 = make_salary_structure(salary_structure_name, frequency, company=company, dont_submit=True)
salary_structure.salary_component = "Timesheet Component" salary_structure.salary_component = "Timesheet Component"
salary_structure.salary_slip_based_on_timesheet = 1 salary_structure.salary_slip_based_on_timesheet = 1

View File

@@ -26,6 +26,7 @@ class TestProjectProfitability(unittest.TestCase):
self.timesheet = make_timesheet(emp, is_billable=1) self.timesheet = make_timesheet(emp, is_billable=1)
self.salary_slip = make_salary_slip(self.timesheet.name) 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) holidays = self.salary_slip.get_holidays_for_employee(date, date)
if holidays: if holidays:
@@ -42,8 +43,8 @@ class TestProjectProfitability(unittest.TestCase):
def test_project_profitability(self): def test_project_profitability(self):
filters = { filters = {
'company': '_Test Company', 'company': '_Test Company',
'start_date': add_days(getdate(), -3), 'start_date': add_days(self.timesheet.start_date, -3),
'end_date': getdate() 'end_date': self.timesheet.start_date
} }
report = execute(filters) report = execute(filters)

View File

@@ -680,7 +680,7 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
var item = frappe.get_doc(cdt, cdn); var item = frappe.get_doc(cdt, cdn);
frappe.model.round_floats_in(item, ["price_list_rate", "discount_percentage"]); 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) 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); this.apply_pricing_rule_on_item(item);
else else
@@ -1562,25 +1562,27 @@ erpnext.TransactionController = erpnext.taxes_and_totals.extend({
_set_values_for_item_list: function(children) { _set_values_for_item_list: function(children) {
var me = this; var me = this;
var price_list_rate_changed = false;
var items_rule_dict = {}; var items_rule_dict = {};
for(var i=0, l=children.length; i<l; i++) { 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"); var existing_pricing_rule = frappe.model.get_value(d.doctype, d.name, "pricing_rules");
for(var k in d) { for(var k in d) {
var v = d[k]; var v = d[k];
if (["doctype", "name"].indexOf(k)===-1) { if (["doctype", "name"].indexOf(k)===-1) {
if(k=="price_list_rate") { 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') { 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 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) { if(!me.frm.doc.ignore_pricing_rule && existing_pricing_rule && !d.pricing_rules) {
me.apply_price_list(frappe.get_doc(d.doctype, d.name)); 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); 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) { apply_rule_on_other_items: function(args) {

View File

@@ -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) { supplier_filter: function(doc) {
if(!doc.supplier) { if(!doc.supplier) {
frappe.throw(__("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "supplier", doc.name))])); frappe.throw(__("Please set {0}", [__(frappe.meta.get_label(doc.doctype, "supplier", doc.name))]));

View File

@@ -430,12 +430,9 @@ erpnext.utils.select_alternate_items = function(opts) {
qty = row.qty; qty = row.qty;
} }
row[item_field] = d.alternate_item; row[item_field] = d.alternate_item;
frm.script_manager.trigger(item_field, row.doctype, row.name) frappe.model.set_value(row.doctype, row.name, 'qty', qty);
.then(() => { 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); frm.trigger(item_field, row.doctype, row.name);
frappe.model.set_value(row.doctype, row.name,
opts.original_item_field, d.item_code);
});
}); });
refresh_field(opts.child_docname); refresh_field(opts.child_docname);

View File

@@ -284,8 +284,10 @@ def get_custom_fields():
inter_state_gst_field = [ inter_state_gst_field = [
dict(fieldname='is_inter_state', label='Is Inter State', dict(fieldname='is_inter_state', label='Is Inter State',
fieldtype='Check', insert_after='disabled', print_hide=1), 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', 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', dict(fieldname='gst_state', label='Source State', fieldtype='Select',
options='\n'.join(states), insert_after='company') options='\n'.join(states), insert_after='company')
] ]

View File

@@ -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.")) frappe.throw(_("Invalid PAN No. The input you've entered doesn't match the format of PAN."))
def validate_tax_category(doc, method): 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: if doc.is_inter_state:
frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state)) frappe.throw(_("Inter State tax category for GST State {0} already exists").format(doc.gst_state))
else: else:
@@ -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): 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'], 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 = '' default_tax = ''

View File

@@ -2,8 +2,6 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import unittest
import frappe import frappe
from frappe.test_runner import make_test_records from frappe.test_runner import make_test_records
from frappe.utils import flt from frappe.utils import flt
@@ -11,7 +9,7 @@ from frappe.utils import flt
from erpnext.accounts.party import get_due_date from erpnext.accounts.party import get_due_date
from erpnext.exceptions import PartyDisabled, PartyFrozen from erpnext.exceptions import PartyDisabled, PartyFrozen
from erpnext.selling.doctype.customer.customer import get_credit_limit, get_customer_outstanding 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_ignore = ["Price List"]
test_dependencies = ['Payment Term', 'Payment Terms Template'] test_dependencies = ['Payment Term', 'Payment Terms Template']
@@ -20,7 +18,7 @@ test_records = frappe.get_test_records('Customer')
from six import iteritems from six import iteritems
class TestCustomer(unittest.TestCase): class TestCustomer(ERPNextTestCase):
def setUp(self): def setUp(self):
if not frappe.get_value('Item', '_Test Item'): if not frappe.get_value('Item', '_Test Item'):
make_test_records('Item') make_test_records('Item')

View File

@@ -6,6 +6,7 @@ import unittest
import frappe import frappe
from erpnext.controllers.queries import item_query from erpnext.controllers.queries import item_query
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ['Item', 'Customer', 'Supplier'] 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.based_on_value = args.get('based_on_value')
psi.insert() psi.insert()
class TestPartySpecificItem(unittest.TestCase): class TestPartySpecificItem(ERPNextTestCase):
def setUp(self): def setUp(self):
self.customer = frappe.get_last_doc("Customer") self.customer = frappe.get_last_doc("Customer")
self.supplier = frappe.get_last_doc("Supplier") self.supplier = frappe.get_last_doc("Supplier")

View File

@@ -1,15 +1,15 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import unittest
import frappe import frappe
from frappe.utils import add_days, add_months, flt, getdate, nowdate from frappe.utils import add_days, add_months, flt, getdate, nowdate
from erpnext.tests.utils import ERPNextTestCase
test_dependencies = ["Product Bundle"] test_dependencies = ["Product Bundle"]
class TestQuotation(unittest.TestCase): class TestQuotation(ERPNextTestCase):
def test_make_quotation_without_terms(self): def test_make_quotation_without_terms(self):
quotation = make_quotation(do_not_save=1) quotation = make_quotation(do_not_save=1)
self.assertFalse(quotation.get('payment_schedule')) self.assertFalse(quotation.get('payment_schedule'))

View File

@@ -2,7 +2,6 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import json import json
import unittest
import frappe import frappe
import frappe.permissions 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.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.tests.utils import ERPNextTestCase
class TestSalesOrder(unittest.TestCase): class TestSalesOrder(ERPNextTestCase):
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super().setUpClass()
cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings", cls.unlink_setting = int(frappe.db.get_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order")) "unlink_advance_payment_on_cancelation_of_order"))
@@ -36,6 +37,7 @@ class TestSalesOrder(unittest.TestCase):
# reset config to previous state # reset config to previous state
frappe.db.set_value("Accounts Settings", "Accounts Settings", frappe.db.set_value("Accounts Settings", "Accounts Settings",
"unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting) "unlink_advance_payment_on_cancelation_of_order", cls.unlink_setting)
super().tearDownClass()
def tearDown(self): def tearDown(self):
frappe.set_user("Administrator") frappe.set_user("Administrator")

View File

@@ -2,8 +2,6 @@
# For license information, please see license.txt # For license information, please see license.txt
import unittest
from frappe.utils import add_months, nowdate from frappe.utils import add_months, nowdate
from erpnext.selling.doctype.sales_order.sales_order import make_material_request 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 ( from erpnext.selling.report.pending_so_items_for_purchase_request.pending_so_items_for_purchase_request import (
execute, execute,
) )
from erpnext.tests.utils import ERPNextTestCase
class TestPendingSOItemsForPurchaseRequest(unittest.TestCase): class TestPendingSOItemsForPurchaseRequest(ERPNextTestCase):
def test_result_for_partial_material_request(self): def test_result_for_partial_material_request(self):
so = make_sales_order() so = make_sales_order()
mr=make_material_request(so.name) mr=make_material_request(so.name)

View File

@@ -2,15 +2,14 @@
# For license information, please see license.txt # For license information, please see license.txt
import unittest
import frappe import frappe
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order 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.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): def test_sales_analytics(self):
frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'") frappe.db.sql("delete from `tabSales Order` where company='_Test Company 2'")

View File

@@ -41,6 +41,7 @@ erpnext.selling.SellingController = erpnext.TransactionController.extend({
me.frm.set_query('contact_person', erpnext.queries.contact_query); me.frm.set_query('contact_person', erpnext.queries.contact_query);
me.frm.set_query('customer_address', erpnext.queries.address_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('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) { if(this.frm.fields_dict.selling_price_list) {

View File

@@ -36,7 +36,7 @@
"abbr": "_TC3", "abbr": "_TC3",
"company_name": "_Test Company 3", "company_name": "_Test Company 3",
"is_group": 1, "is_group": 1,
"country": "India", "country": "Pakistan",
"default_currency": "INR", "default_currency": "INR",
"doctype": "Company", "doctype": "Company",
"domain": "Manufacturing", "domain": "Manufacturing",
@@ -49,7 +49,7 @@
"company_name": "_Test Company 4", "company_name": "_Test Company 4",
"parent_company": "_Test Company 3", "parent_company": "_Test Company 3",
"is_group": 1, "is_group": 1,
"country": "India", "country": "Pakistan",
"default_currency": "INR", "default_currency": "INR",
"doctype": "Company", "doctype": "Company",
"domain": "Manufacturing", "domain": "Manufacturing",
@@ -61,7 +61,7 @@
"abbr": "_TC5", "abbr": "_TC5",
"company_name": "_Test Company 5", "company_name": "_Test Company 5",
"parent_company": "_Test Company 4", "parent_company": "_Test Company 4",
"country": "India", "country": "Pakistan",
"default_currency": "INR", "default_currency": "INR",
"doctype": "Company", "doctype": "Company",
"domain": "Manufacturing", "domain": "Manufacturing",

View File

@@ -1178,11 +1178,13 @@
{ {
"title": "Reverse Charge In-State", "title": "Reverse Charge In-State",
"is_inter_state": 0, "is_inter_state": 0,
"is_reverse_charge": 1,
"gst_state": "" "gst_state": ""
}, },
{ {
"title": "Reverse Charge Out-State", "title": "Reverse Charge Out-State",
"is_inter_state": 1, "is_inter_state": 1,
"is_reverse_charge": 1,
"gst_state": "" "gst_state": ""
}, },
{ {

View File

@@ -33,10 +33,10 @@ class Bin(Document):
in open work orders''' in open work orders'''
self.reserved_qty_for_production = frappe.db.sql(''' self.reserved_qty_for_production = frappe.db.sql('''
SELECT SELECT
CASE WHEN ifnull(skip_transfer, 0) = 0 THEN SUM(CASE WHEN ifnull(skip_transfer, 0) = 0 THEN
SUM(item.required_qty - item.transferred_qty) item.required_qty - item.transferred_qty
ELSE ELSE
SUM(item.required_qty - item.consumed_qty) item.required_qty - item.consumed_qty END)
END END
FROM `tabWork Order` pro, `tabWork Order Item` item FROM `tabWork Order` pro, `tabWork Order Item` item
WHERE 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.""" """WARNING: This function is deprecated. Inline this function instead of using it."""
from erpnext.stock.stock_ledger import repost_current_voucher 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) repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
update_qty(bin_name, args)
def get_bin_details(bin_name): def get_bin_details(bin_name):
return frappe.db.get_value('Bin', bin_name, ['actual_qty', 'ordered_qty', 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) 'reserved_qty_for_sub_contract'], as_dict=1)
def update_qty(bin_name, args): 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) bin_details = get_bin_details(bin_name)
if args.get("voucher_type")=="Stock Reconciliation": # actual qty is already updated by processing current voucher
actual_qty = args.get('qty_after_transaction') actual_qty = bin_details.actual_qty
else:
actual_qty = bin_details.actual_qty + flt(args.get("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")) ordered_qty = flt(bin_details.ordered_qty) + flt(args.get("ordered_qty"))
reserved_qty = flt(bin_details.reserved_qty) + flt(args.get("reserved_qty")) reserved_qty = flt(bin_details.reserved_qty) + flt(args.get("reserved_qty"))

View File

@@ -359,8 +359,7 @@
"fieldname": "valuation_method", "fieldname": "valuation_method",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Valuation Method", "label": "Valuation Method",
"options": "\nFIFO\nMoving Average", "options": "\nFIFO\nMoving Average"
"set_only_once": 1
}, },
{ {
"depends_on": "is_stock_item", "depends_on": "is_stock_item",
@@ -956,7 +955,7 @@
"image_field": "image", "image_field": "image",
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-12-03 08:32:03.869294", "modified": "2021-12-14 04:13:16.857534",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Item", "name": "Item",

View File

@@ -1,451 +1,140 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 0, "autoname": "hash",
"allow_rename": 0, "creation": "2013-04-08 13:10:16",
"autoname": "hash", "doctype": "DocType",
"beta": 0, "document_type": "Document",
"creation": "2013-04-08 13:10:16", "editable_grid": 1,
"custom": 0, "engine": "InnoDB",
"docstatus": 0, "field_order": [
"doctype": "DocType", "item_code",
"document_type": "Document", "column_break_2",
"editable_grid": 1, "item_name",
"engine": "InnoDB", "batch_no",
"desc_section",
"description",
"quantity_section",
"qty",
"net_weight",
"column_break_10",
"stock_uom",
"weight_uom",
"page_break",
"dn_detail"
],
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "item_code",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "in_global_search": 1,
"columns": 0, "in_list_view": 1,
"fieldname": "item_code", "label": "Item Code",
"fieldtype": "Link", "options": "Item",
"hidden": 0, "print_width": "100px",
"ignore_user_permissions": 0, "reqd": 1,
"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,
"width": "100px" "width": "100px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "column_break_2",
"bold": 0, "fieldtype": "Column Break"
"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
},
{ {
"allow_on_submit": 0, "fetch_from": "item_code.item_name",
"bold": 0, "fieldname": "item_name",
"collapsible": 0, "fieldtype": "Data",
"columns": 0, "in_list_view": 1,
"fieldname": "item_name", "label": "Item Name",
"fieldtype": "Data", "print_width": "200px",
"hidden": 0, "read_only": 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": "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,
"width": "200px" "width": "200px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "batch_no",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "label": "Batch No",
"columns": 0, "options": "Batch"
"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
},
{ {
"allow_on_submit": 0, "collapsible": 1,
"bold": 0, "fieldname": "desc_section",
"collapsible": 1, "fieldtype": "Section Break",
"columns": 0, "label": "Description"
"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
},
{ {
"allow_on_submit": 0, "fieldname": "description",
"bold": 0, "fieldtype": "Text Editor",
"collapsible": 0, "label": "Description"
"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
},
{ {
"allow_on_submit": 0, "fieldname": "quantity_section",
"bold": 0, "fieldtype": "Section Break",
"collapsible": 0, "label": "Quantity"
"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
},
{ {
"allow_on_submit": 0, "fieldname": "qty",
"bold": 0, "fieldtype": "Float",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Quantity",
"fieldname": "qty", "print_width": "100px",
"fieldtype": "Float", "reqd": 1,
"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,
"width": "100px" "width": "100px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "net_weight",
"bold": 0, "fieldtype": "Float",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Net Weight",
"fieldname": "net_weight", "print_width": "100px",
"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,
"width": "100px" "width": "100px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "column_break_10",
"bold": 0, "fieldtype": "Column Break"
"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
},
{ {
"allow_on_submit": 0, "fieldname": "stock_uom",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "label": "UOM",
"columns": 0, "options": "UOM",
"fieldname": "stock_uom", "print_width": "100px",
"fieldtype": "Link", "read_only": 1,
"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,
"width": "100px" "width": "100px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "weight_uom",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "label": "Weight UOM",
"columns": 0, "options": "UOM",
"fieldname": "weight_uom", "print_width": "100px",
"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,
"width": "100px" "width": "100px"
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
"bold": 0, "default": "0",
"collapsible": 0, "fieldname": "page_break",
"columns": 0, "fieldtype": "Check",
"fieldname": "page_break", "in_list_view": 1,
"fieldtype": "Check", "label": "Page Break"
"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": 0, "fieldname": "dn_detail",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "hidden": 1,
"columns": 0, "in_list_view": 1,
"fieldname": "dn_detail", "label": "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
} }
], ],
"hide_heading": 0, "idx": 1,
"hide_toolbar": 0, "istable": 1,
"idx": 1, "links": [],
"image_view": 0, "modified": "2021-12-14 01:22:00.715935",
"in_create": 0, "modified_by": "Administrator",
"module": "Stock",
"is_submittable": 0, "name": "Packing Slip Item",
"issingle": 0, "naming_rule": "Random",
"istable": 1, "owner": "Administrator",
"max_attachments": 0, "permissions": [],
"modified": "2018-06-01 07:21:58.220980", "sort_field": "modified",
"modified_by": "Administrator", "sort_order": "DESC",
"module": "Stock", "track_changes": 1
"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
} }

View File

@@ -46,7 +46,7 @@ class RepostItemValuation(Document):
self.db_set('status', self.status) self.db_set('status', self.status)
def on_submit(self): 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 return
frappe.enqueue(repost, timeout=1800, queue='long', frappe.enqueue(repost, timeout=1800, queue='long',
@@ -97,7 +97,8 @@ def repost(doc):
return return
doc.set_status('In Progress') doc.set_status('In Progress')
frappe.db.commit() if not frappe.flags.in_test:
frappe.db.commit()
repost_sl_entries(doc) repost_sl_entries(doc)
repost_gl_entries(doc) repost_gl_entries(doc)

View File

@@ -39,9 +39,9 @@ def create_test_delivery_note():
"description": 'Test delivery note for shipment', "description": 'Test delivery note for shipment',
"qty": 5, "qty": 5,
"uom": 'Nos', "uom": 'Nos',
"warehouse": 'Stores - SC', "warehouse": 'Stores - _TC',
"rate": item.standard_rate, "rate": item.standard_rate,
"cost_center": 'Main - SC' "cost_center": 'Main - _TC'
} }
) )
delivery_note.insert() delivery_note.insert()
@@ -127,13 +127,7 @@ def get_shipment_company_address(company_name):
return create_shipment_address(address_title, company_name, 80331) return create_shipment_address(address_title, company_name, 80331)
def get_shipment_company(): def get_shipment_company():
company_name = 'Shipment Company' return frappe.get_doc("Company", "_Test 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)
def get_shipment_item(company_name): def get_shipment_item(company_name):
item_name = 'Testing Shipment item' item_name = 'Testing Shipment item'
@@ -182,17 +176,6 @@ def create_customer_contact(fname, lname):
customer.insert() customer.insert()
return customer 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): def create_shipment_customer(customer_name):
customer = frappe.new_doc("Customer") customer = frappe.new_doc("Customer")
customer.customer_name = customer_name 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.posting_date = posting_date.strftime("%Y-%m-%d")
stock.append('items', stock.append('items',
{ {
"t_warehouse": 'Stores - SC', "t_warehouse": 'Stores - _TC',
"item_code": item.name, "item_code": item.name,
"qty": 5, "qty": 5,
"uom": 'Nos', "uom": 'Nos',
"basic_rate": item.standard_rate, "basic_rate": item.standard_rate,
"cost_center": 'Main - SC' "cost_center": 'Main - _TC'
} }
) )
stock.insert() stock.insert()
@@ -233,7 +216,7 @@ def create_shipment_item(item_name, company_name):
item.append('item_defaults', item.append('item_defaults',
{ {
"company": company_name, "company": company_name,
"default_warehouse": 'Stores - SC' "default_warehouse": 'Stores - _TC'
} }
) )
item.insert() item.insert()

View File

@@ -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 from erpnext.stock.utils import get_bin, get_incoming_rate
class IncorrectValuationRateError(frappe.ValidationError): pass class FinishedGoodError(frappe.ValidationError):
class DuplicateEntryForWorkOrderError(frappe.ValidationError): pass pass
class OperationsNotCompleteError(frappe.ValidationError): pass class IncorrectValuationRateError(frappe.ValidationError):
class MaxSampleAlreadyRetainedError(frappe.ValidationError): pass pass
class DuplicateEntryForWorkOrderError(frappe.ValidationError):
pass
class OperationsNotCompleteError(frappe.ValidationError):
pass
class MaxSampleAlreadyRetainedError(frappe.ValidationError):
pass
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
@@ -702,6 +708,11 @@ class StockEntry(StockController):
finished_item = self.get_finished_item() 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: for d in self.items:
if d.t_warehouse and not d.s_warehouse: if d.t_warehouse and not d.s_warehouse:
if self.purpose=="Repack" or d.item_code == finished_item: if self.purpose=="Repack" or d.item_code == finished_item:
@@ -722,38 +733,64 @@ class StockEntry(StockController):
return finished_item return finished_item
def validate_finished_goods(self): 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", wo_details = frappe.db.get_value(
self.work_order, ["production_item", "qty"]) "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'): for d in self.get('items'):
if d.is_finished_item: 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: if d.item_code != production_item:
frappe.throw(_("Finished Item {0} does not match with Work Order {1}") 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): elif flt(d.transfer_qty) > flt(self.fg_completed_qty):
frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}"). \ frappe.throw(_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}")
format(d.idx, d.transfer_qty, self.fg_completed_qty)) .format(d.idx, d.transfer_qty, self.fg_completed_qty)
)
finished_items.append(d.item_code) finished_items.append(d.item_code)
if len(set(finished_items)) > 1: 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 self.purpose == "Manufacture":
if not finished_items: if not finished_items:
frappe.throw(_('Finished Good has not set in the stock entry {0}') frappe.throw(
.format(self.name)) 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", allowance_percentage = flt(
"overproduction_percentage_for_work_order")) 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) # No work order could mean independent Manufacture entry, if so skip validation
if self.fg_completed_qty > allowed_qty: 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}") frappe.throw(
.format(flt(self.fg_completed_qty), wo_qty)) _("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): def update_stock_ledger(self):
sl_entries = [] sl_entries = []

View File

@@ -16,7 +16,10 @@ from erpnext.stock.doctype.item.test_item import (
set_item_variant_settings, set_item_variant_settings,
) )
from erpnext.stock.doctype.serial_no.serial_no import * # noqa 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_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_ledger_entry.stock_ledger_entry import StockFreezeError
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( 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 posting_date='2021-09-02', # backdated consumption of 2nd batch
purpose='Material Issue') 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): def make_serialized_item(**args):
args = frappe._dict(args) args = frappe._dict(args)
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])

View File

@@ -24,11 +24,15 @@ from erpnext.tests.utils import ERPNextTestCase, change_settings
class TestStockReconciliation(ERPNextTestCase): class TestStockReconciliation(ERPNextTestCase):
@classmethod @classmethod
def setUpClass(self): def setUpClass(cls):
super().setUpClass() super().setUpClass()
create_batch_or_serial_no_items() create_batch_or_serial_no_items()
frappe.db.set_value("Stock Settings", None, "allow_negative_stock", 1) 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): def test_reco_for_fifo(self):
self._test_reco_sle_gle("FIFO") 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})) 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") 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): def test_valid_batch(self):
create_batch_item_with_batch("Testing Batch Item 1", "001") create_batch_item_with_batch("Testing Batch Item 1", "001")
create_batch_item_with_batch("Testing Batch Item 2", "002") create_batch_item_with_batch("Testing Batch Item 2", "002")

View File

@@ -1098,7 +1098,7 @@ def apply_price_list(args, as_doc=False):
} }
def apply_price_list_on_item(args): 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 = get_price_list_rate(args, item_doc)
item_details.update(get_pricing_rule_for_item(args, item_details.price_list_rate)) item_details.update(get_pricing_rule_for_item(args, item_details.price_list_rate))

View File

@@ -48,6 +48,7 @@ def get_item_info(filters):
conditions = [get_item_group_condition(filters.get("item_group"))] conditions = [get_item_group_condition(filters.get("item_group"))]
if filters.get("brand"): if filters.get("brand"):
conditions.append("item.brand=%(brand)s") conditions.append("item.brand=%(brand)s")
conditions.append("is_stock_item = 1")
return frappe.db.sql("""select name, item_name, description, brand, item_group, return frappe.db.sql("""select name, item_name, description, brand, item_group,
safety_stock, lead_time_days from `tabItem` item where {}""" safety_stock, lead_time_days from `tabItem` item where {}"""

View File

@@ -3,27 +3,37 @@
from operator import itemgetter from operator import itemgetter
from typing import Dict, List, Tuple, Union
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cint, date_diff, flt from frappe.utils import cint, date_diff, flt
from six import iteritems
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
Filters = frappe._dict
def execute(filters=None): def execute(filters: Filters = None) -> Tuple:
columns = get_columns(filters)
item_details = get_fifo_queue(filters)
to_date = filters["to_date"] 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 = [] data = []
for item, item_dict in iteritems(item_details):
for item, item_dict in item_details.items():
earliest_age, latest_age = 0, 0 earliest_age, latest_age = 0, 0
details = item_dict["details"]
fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func) fifo_queue = sorted(filter(_func, item_dict["fifo_queue"]), key=_func)
details = item_dict["details"]
if not fifo_queue: continue if not fifo_queue: continue
@@ -32,23 +42,22 @@ def execute(filters=None):
latest_age = date_diff(to_date, fifo_queue[-1][1]) 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) range1, range2, range3, above_range3 = get_range_age(filters, fifo_queue, to_date, item_dict)
row = [details.name, details.item_name, row = [details.name, details.item_name, details.description,
details.description, details.item_group, details.brand] details.item_group, details.brand]
if filters.get("show_warehouse_wise_stock"): if filters.get("show_warehouse_wise_stock"):
row.append(details.warehouse) row.append(details.warehouse)
row.extend([item_dict.get("total_qty"), average_age, row.extend([item_dict.get("total_qty"), average_age,
range1, range2, range3, above_range3, range1, range2, range3, above_range3,
earliest_age, latest_age, details.stock_uom]) earliest_age, latest_age,
details.stock_uom])
data.append(row) data.append(row)
chart_data = get_chart_data(data, filters) return data
return columns, data, None, chart_data def get_average_age(fifo_queue: List, to_date: str) -> float:
def get_average_age(fifo_queue, to_date):
batch_age = age_qty = total_qty = 0.0 batch_age = age_qty = total_qty = 0.0
for batch in fifo_queue: for batch in fifo_queue:
batch_age = date_diff(to_date, batch[1]) 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 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 range1 = range2 = range3 = above_range3 = 0.0
for item in fifo_queue: 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 return range1, range2, range3, above_range3
def get_columns(filters): def get_columns(filters: Filters) -> List[Dict]:
range_columns = [] range_columns = []
setup_ageing_columns(filters, range_columns) setup_ageing_columns(filters, range_columns)
columns = [ columns = [
@@ -165,106 +174,7 @@ def get_columns(filters):
return columns return columns
def get_fifo_queue(filters, sle=None): def get_chart_data(data: List, filters: Filters) -> Dict:
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):
if not data: if not data:
return [] return []
@@ -295,17 +205,201 @@ def get_chart_data(data, filters):
"type" : "bar" "type" : "bar"
} }
def setup_ageing_columns(filters, range_columns): def setup_ageing_columns(filters: Filters, range_columns: List):
for i, label in enumerate(["0-{range1}".format(range1=filters["range1"]), ranges = [
"{range1}-{range2}".format(range1=cint(filters["range1"])+ 1, range2=filters["range2"]), f"0 - {filters['range1']}",
"{range2}-{range3}".format(range2=cint(filters["range2"])+ 1, range3=filters["range3"]), f"{cint(filters['range1']) + 1} - {cint(filters['range2'])}",
"{range3}-{above}".format(range3=cint(filters["range3"])+ 1, above=_("Above"))]): f"{cint(filters['range2']) + 1} - {cint(filters['range3'])}",
add_column(range_columns, label="Age ("+ label +")", fieldname='range' + str(i+1)) 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( range_columns.append(dict(
label=label, label=label,
fieldname=fieldname, fieldname=fieldname,
fieldtype=fieldtype, fieldtype=fieldtype,
width=width 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))

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

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

View File

@@ -10,7 +10,7 @@ from frappe.utils import cint, date_diff, flt, getdate
from six import iteritems from six import iteritems
import erpnext 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.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 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'): if filters.get('show_stock_ageing_data'):
filters['show_warehouse_wise_stock'] = True 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 no stock ledger entry found return
if not sle: 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.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 sle.item_code as name, sle.voucher_no, sle.stock_value, sle.batch_no
from from
`tabStock Ledger Entry` sle force index (posting_sort_index) `tabStock Ledger Entry` sle
where sle.docstatus < 2 %s %s where sle.docstatus < 2 %s %s
and is_cancelled = 0 and is_cancelled = 0
order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty""" % #nosec order by sle.posting_date, sle.posting_time, sle.creation, sle.actual_qty""" % #nosec

View File

@@ -8,7 +8,8 @@ const DIFFERNCE_FIELD_NAMES = [
"fifo_value_diff", "fifo_value_diff",
"fifo_valuation_diff", "fifo_valuation_diff",
"valuation_diff", "valuation_diff",
"fifo_difference_diff" "fifo_difference_diff",
"diff_value_diff"
]; ];
frappe.query_reports["Stock Ledger Invariant Check"] = { frappe.query_reports["Stock Ledger Invariant Check"] = {

View File

@@ -50,6 +50,7 @@ def get_stock_ledger_entries(filters):
def add_invariant_check_fields(sles): def add_invariant_check_fields(sles):
balance_qty = 0.0 balance_qty = 0.0
balance_stock_value = 0.0
for idx, sle in enumerate(sles): for idx, sle in enumerate(sles):
queue = json.loads(sle.stock_queue) queue = json.loads(sle.stock_queue)
@@ -60,6 +61,7 @@ def add_invariant_check_fields(sles):
fifo_value += qty * rate fifo_value += qty * rate
balance_qty += sle.actual_qty balance_qty += sle.actual_qty
balance_stock_value += sle.stock_value_difference
if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no: if sle.voucher_type == "Stock Reconciliation" and not sle.batch_no:
balance_qty = sle.qty_after_transaction 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.stock_value / sle.qty_after_transaction if sle.qty_after_transaction else None
) )
sle.expected_qty_after_transaction = balance_qty sle.expected_qty_after_transaction = balance_qty
sle.stock_value_from_diff = balance_stock_value
# set difference fields # set difference fields
sle.difference_in_qty = sle.qty_after_transaction - sle.expected_qty_after_transaction 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_diff = (
sle.valuation_rate - sle.balance_value_by_qty if sle.balance_value_by_qty else None 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: if idx > 0:
sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value sle.fifo_stock_diff = sle.fifo_stock_value - sles[idx - 1].fifo_stock_value
@@ -191,12 +195,21 @@ def get_columns():
"fieldtype": "Float", "fieldtype": "Float",
"label": "D - E", "label": "D - E",
}, },
{ {
"fieldname": "stock_value_difference", "fieldname": "stock_value_difference",
"fieldtype": "Float", "fieldtype": "Float",
"label": "(F) Stock Value Difference", "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", "fieldname": "fifo_stock_diff",
"fieldtype": "Float", "fieldtype": "Float",

View File

@@ -10,7 +10,7 @@ from frappe import _
from frappe.utils import flt from frappe.utils import flt
from six import iteritems 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 ( from erpnext.stock.report.stock_balance.stock_balance import (
get_item_details, get_item_details,
get_item_warehouse_map, get_item_warehouse_map,
@@ -34,7 +34,7 @@ def execute(filters=None):
item_map = get_item_details(items, sle, filters) item_map = get_item_details(items, sle, filters)
iwb_map = get_item_warehouse_map(filters, sle) iwb_map = get_item_warehouse_map(filters, sle)
warehouse_list = get_warehouse_list(filters) warehouse_list = get_warehouse_list(filters)
item_ageing = get_fifo_queue(filters) item_ageing = FIFOSlots(filters).generate()
data = [] data = []
item_balance = {} item_balance = {}
item_value = {} item_value = {}

View File

@@ -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') is_stock_item = frappe.get_cached_value('Item', args.get("item_code"), 'is_stock_item')
if is_stock_item: if is_stock_item:
bin_name = get_or_make_bin(args.get("item_code"), args.get("warehouse")) 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) repost_current_voucher(args, allow_negative_stock, via_landed_cost_voucher)
update_bin_qty(bin_name, args)
else: else:
frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code"))) frappe.msgprint(_("Item {0} ignored since it is not a stock item").format(args.get("item_code")))

View File

@@ -10,13 +10,8 @@ test_records = frappe.get_test_records('Company')
class TestInit(unittest.TestCase): class TestInit(unittest.TestCase):
def test_encode_company_abbr(self): 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 = [ names = [
"Warehouse Name", "ERPNext Foundation India", "Gold - Member - {a}".format(a=abbr), "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)): 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( self.assertTrue(
enc_name == expected_names[i], enc_name == expected_names[i],
"{enc} is not same as {exp}".format(enc=enc_name, exp=expected_names[i]) "{enc} is not same as {exp}".format(enc=enc_name, exp=expected_names[i])

View File

@@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
import copy import copy
import signal
import unittest import unittest
from contextlib import contextmanager from contextlib import contextmanager
from typing import Any, Dict, NewType, Optional from typing import Any, Dict, NewType, Optional
@@ -135,3 +136,23 @@ def execute_script_report(
report_execute_fn(filter_with_optional_param) report_execute_fn(filter_with_optional_param)
return report_data 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

View File

@@ -69,6 +69,8 @@ def qty_from_all_warehouses(batch_info):
return qty return qty
def get_price(item_code, price_list, customer_group, company, qty=1): 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") template_item_code = frappe.db.get_value("Item", item_code, "variant_of")
if price_list: 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}) filters={"price_list": price_list, "item_code": template_item_code})
if price: if price:
pricing_rule = get_pricing_rule_for_item(frappe._dict({ party = get_party()
pricing_rule_dict = frappe._dict({
"item_code": item_code, "item_code": item_code,
"qty": qty, "qty": qty,
"stock_qty": qty, "stock_qty": qty,
@@ -91,7 +94,12 @@ def get_price(item_code, price_list, customer_group, company, qty=1):
"conversion_rate": 1, "conversion_rate": 1,
"for_shopping_cart": True, "for_shopping_cart": True,
"currency": frappe.db.get_value("Price List", price_list, "currency") "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] price_obj = price[0]
if pricing_rule: if pricing_rule: