Merge pull request #35571 from frappe/version-14-hotfix

chore: release v14
This commit is contained in:
Deepesh Garg
2023-06-07 11:46:26 +05:30
committed by GitHub
24 changed files with 276 additions and 76 deletions

View File

@@ -12,6 +12,7 @@ from frappe.utils import flt, get_link_to_form
import erpnext
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
from erpnext.accounts.utils import get_currency_precision
from erpnext.setup.utils import get_exchange_rate
@@ -170,6 +171,15 @@ class ExchangeRateRevaluation(Document):
.run(as_dict=True)
)
# round off balance based on currency precision
currency_precision = get_currency_precision()
for acc in account_details:
acc.balance_in_account_currency = flt(acc.balance_in_account_currency, currency_precision)
acc.balance = flt(acc.balance, currency_precision)
acc.zero_balance = (
True if (acc.balance == 0 or acc.balance_in_account_currency == 0) else False
)
return account_details
@staticmethod

View File

@@ -169,21 +169,18 @@ class PeriodClosingVoucher(AccountsController):
return frappe.db.sql(
"""
select
t2.account_currency,
t1.account_currency,
{dimension_fields},
sum(t1.debit_in_account_currency) - sum(t1.credit_in_account_currency) as bal_in_account_currency,
sum(t1.debit) - sum(t1.credit) as bal_in_company_currency
from `tabGL Entry` t1, `tabAccount` t2
from `tabGL Entry` t1
where
t1.is_cancelled = 0
and t1.account = t2.name
and t2.report_type = 'Profit and Loss'
and t2.docstatus < 2
and t2.company = %s
and t1.account in (select name from `tabAccount` where report_type = 'Profit and Loss' and docstatus < 2 and company = %s)
and t1.posting_date between %s and %s
group by {dimension_fields}
""".format(
dimension_fields=", ".join(dimension_fields)
dimension_fields=", ".join(dimension_fields),
),
(self.company, self.get("year_start_date"), self.posting_date),
as_dict=1,

View File

@@ -469,7 +469,7 @@
"options": "UOM"
},
{
"description": "If rate is zero them item will be treated as \"Free Item\"",
"description": "If rate is zero then item will be treated as \"Free Item\"",
"fieldname": "free_item_rate",
"fieldtype": "Currency",
"label": "Free Item Rate"
@@ -670,4 +670,4 @@
"sort_order": "DESC",
"states": [],
"title_field": "title"
}
}

View File

@@ -647,12 +647,12 @@ def set_taxes(
else:
args.update(get_party_details(party, party_type))
if party_type in ("Customer", "Lead"):
if party_type in ("Customer", "Lead", "Prospect"):
args.update({"tax_type": "Sales"})
if party_type == "Lead":
if party_type in ["Lead", "Prospect"]:
args["customer"] = None
del args["lead"]
del args[frappe.scrub(party_type)]
else:
args.update({"tax_type": "Purchase"})

View File

@@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from collections import OrderedDict
import frappe
from frappe import _, qb, scrub
@@ -849,30 +850,30 @@ class GrossProfitGenerator(object):
Turns list of Sales Invoice Items to a tree of Sales Invoices with their Items as children.
"""
parents = []
grouped = OrderedDict()
for row in self.si_list:
if row.parent not in parents:
parents.append(row.parent)
# initialize list with a header row for each new parent
grouped.setdefault(row.parent, [self.get_invoice_row(row)]).append(
row.update(
{"indent": 1.0, "parent_invoice": row.parent, "invoice_or_item": row.item_code}
) # descendant rows will have indent: 1.0 or greater
)
parents_index = 0
for index, row in enumerate(self.si_list):
if parents_index < len(parents) and row.parent == parents[parents_index]:
invoice = self.get_invoice_row(row)
self.si_list.insert(index, invoice)
parents_index += 1
# if item is a bundle, add it's components as seperate rows
if frappe.db.exists("Product Bundle", row.item_code):
bundled_items = self.get_bundle_items(row)
for x in bundled_items:
bundle_item = self.get_bundle_item_row(row, x)
grouped.get(row.parent).append(bundle_item)
else:
# skipping the bundle items rows
if not row.indent:
row.indent = 1.0
row.parent_invoice = row.parent
row.invoice_or_item = row.item_code
self.si_list.clear()
if frappe.db.exists("Product Bundle", row.item_code):
self.add_bundle_items(row, index)
for items in grouped.values():
self.si_list.extend(items)
def get_invoice_row(self, row):
# header row format
return frappe._dict(
{
"parent_invoice": "",
@@ -901,13 +902,6 @@ class GrossProfitGenerator(object):
}
)
def add_bundle_items(self, product_bundle, index):
bundle_items = self.get_bundle_items(product_bundle)
for i, item in enumerate(bundle_items):
bundle_item = self.get_bundle_item_row(product_bundle, item)
self.si_list.insert((index + i + 1), bundle_item)
def get_bundle_items(self, product_bundle):
return frappe.get_all(
"Product Bundle Item", filters={"parent": product_bundle.item_code}, fields=["item_code", "qty"]

View File

@@ -96,7 +96,6 @@ class AssetCategory(Document):
frappe.throw(msg, title=_("Missing Account"))
@frappe.whitelist()
def get_asset_category_account(
fieldname, item=None, asset=None, account=None, asset_category=None, company=None
):

View File

@@ -758,6 +758,7 @@ class AccountsController(TransactionBase):
}
)
update_gl_dict_with_regional_fields(self, gl_dict)
accounting_dimensions = get_accounting_dimensions()
dimension_dict = frappe._dict()
@@ -2835,3 +2836,8 @@ def validate_regional(doc):
@erpnext.allow_regional
def validate_einvoice_fields(doc):
pass
@erpnext.allow_regional
def update_gl_dict_with_regional_fields(doc, gl_dict):
pass

View File

@@ -43,7 +43,6 @@ class SellingController(StockController):
self.validate_auto_repeat_subscription_dates()
def set_missing_values(self, for_validate=False):
super(SellingController, self).set_missing_values(for_validate)
# set contact and address details for customer, if they are not mentioned
@@ -62,7 +61,7 @@ class SellingController(StockController):
elif self.doctype == "Quotation" and self.party_name:
if self.quotation_to == "Customer":
customer = self.party_name
else:
elif self.quotation_to == "Lead":
lead = self.party_name
if customer:

View File

@@ -160,4 +160,3 @@ class TestLoanDisbursement(unittest.TestCase):
interest = per_day_interest * 15
self.assertEqual(amounts["pending_principal_amount"], 1500000)
self.assertEqual(amounts["interest_amount"], flt(interest + previous_interest, 2))

View File

@@ -22,7 +22,7 @@ class LoanInterestAccrual(AccountsController):
frappe.throw(_("Interest Amount or Principal Amount is mandatory"))
if not self.last_accrual_date:
self.last_accrual_date = get_last_accrual_date(self.loan)
self.last_accrual_date = get_last_accrual_date(self.loan, self.posting_date)
def on_submit(self):
self.make_gl_entries()
@@ -271,14 +271,14 @@ def make_loan_interest_accrual_entry(args):
def get_no_of_days_for_interest_accural(loan, posting_date):
last_interest_accrual_date = get_last_accrual_date(loan.name)
last_interest_accrual_date = get_last_accrual_date(loan.name, posting_date)
no_of_days = date_diff(posting_date or nowdate(), last_interest_accrual_date) + 1
return no_of_days
def get_last_accrual_date(loan):
def get_last_accrual_date(loan, posting_date):
last_posting_date = frappe.db.sql(
""" SELECT MAX(posting_date) from `tabLoan Interest Accrual`
WHERE loan = %s and docstatus = 1""",
@@ -286,12 +286,30 @@ def get_last_accrual_date(loan):
)
if last_posting_date[0][0]:
last_interest_accrual_date = last_posting_date[0][0]
# interest for last interest accrual date is already booked, so add 1 day
return add_days(last_posting_date[0][0], 1)
last_disbursement_date = get_last_disbursement_date(loan, posting_date)
if last_disbursement_date and getdate(last_disbursement_date) > getdate(
last_interest_accrual_date
):
last_interest_accrual_date = last_disbursement_date
return add_days(last_interest_accrual_date, 1)
else:
return frappe.db.get_value("Loan", loan, "disbursement_date")
def get_last_disbursement_date(loan, posting_date):
last_disbursement_date = frappe.db.get_value(
"Loan Disbursement",
{"docstatus": 1, "against_loan": loan, "posting_date": ("<", posting_date)},
"MAX(posting_date)",
)
return last_disbursement_date
def days_in_year(year):
days = 365

View File

@@ -101,7 +101,7 @@ class LoanRepayment(AccountsController):
if flt(self.total_interest_paid, precision) > flt(self.interest_payable, precision):
if not self.is_term_loan:
# get last loan interest accrual date
last_accrual_date = get_last_accrual_date(self.against_loan)
last_accrual_date = get_last_accrual_date(self.against_loan, self.posting_date)
# get posting date upto which interest has to be accrued
per_day_interest = get_per_day_interest(
@@ -722,7 +722,7 @@ def get_amounts(amounts, against_loan, posting_date):
if due_date:
pending_days = date_diff(posting_date, due_date) + 1
else:
last_accrual_date = get_last_accrual_date(against_loan_doc.name)
last_accrual_date = get_last_accrual_date(against_loan_doc.name, posting_date)
pending_days = date_diff(posting_date, last_accrual_date) + 1
if pending_days > 0:

View File

@@ -7,13 +7,14 @@ from typing import Literal
import frappe
from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import random_string
from frappe.utils.data import add_to_date, now
from frappe.utils.data import add_to_date, now, today
from erpnext.manufacturing.doctype.job_card.job_card import (
JobCardOverTransferError,
OperationMismatchError,
OverlapError,
make_corrective_job_card,
make_material_request,
)
from erpnext.manufacturing.doctype.job_card.job_card import (
make_stock_entry as make_stock_entry_from_jc,
@@ -449,6 +450,25 @@ class TestJobCard(FrappeTestCase):
jc.docstatus = 2
assertStatus("Cancelled")
def test_job_card_material_request_and_bom_details(self):
from erpnext.stock.doctype.material_request.material_request import make_stock_entry
create_bom_with_multiple_operations()
work_order = make_wo_with_transfer_against_jc()
job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
mr = make_material_request(job_card_name)
mr.schedule_date = today()
mr.submit()
ste = make_stock_entry(mr.name)
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
self.assertEqual(ste.work_order, work_order.name)
self.assertEqual(ste.job_card, job_card_name)
self.assertEqual(ste.from_bom, 1.0)
self.assertEqual(ste.bom_no, work_order.bom_no)
def create_bom_with_multiple_operations():
"Create a BOM with multiple operations and Material Transfer against Job Card"

View File

@@ -25,20 +25,38 @@ frappe.listview_settings['Task'] = {
}
return [__(doc.status), colors[doc.status], "status,=," + doc.status];
},
gantt_custom_popup_html: function(ganttobj, task) {
var html = `<h5><a style="text-decoration:underline"\
href="/app/task/${ganttobj.id}""> ${ganttobj.name} </a></h5>`;
gantt_custom_popup_html: function (ganttobj, task) {
let html = `
<a class="text-white mb-2 inline-block cursor-pointer"
href="/app/task/${ganttobj.id}"">
${ganttobj.name}
</a>
`;
if(task.project) html += `<p>Project: ${task.project}</p>`;
html += `<p>Progress: ${ganttobj.progress}</p>`;
if (task.project) {
html += `<p class="mb-1">${__("Project")}:
<a class="text-white inline-block"
href="/app/project/${task.project}"">
${task.project}
</a>
</p>`;
}
html += `<p class="mb-1">
${__("Progress")}:
<span class="text-white">${ganttobj.progress}%</span>
</p>`;
if(task._assign_list) {
html += task._assign_list.reduce(
(html, user) => html + frappe.avatar(user)
, '');
if (task._assign) {
const assign_list = JSON.parse(task._assign);
const assignment_wrapper = `
<span>Assigned to:</span>
<span class="text-white">
${assign_list.map((user) => frappe.user_info(user).fullname).join(", ")}
</span>
`;
html += assignment_wrapper;
}
return html;
}
return `<div class="p-3" style="min-width: 220px">${html}</div>`;
},
};

View File

@@ -16,8 +16,8 @@ erpnext.utils.get_party_details = function(frm, method, args, callback) {
|| (frm.doc.party_name && in_list(['Quotation', 'Opportunity'], frm.doc.doctype))) {
let party_type = "Customer";
if (frm.doc.quotation_to && frm.doc.quotation_to === "Lead") {
party_type = "Lead";
if (frm.doc.quotation_to && in_list(["Lead", "Prospect"], frm.doc.quotation_to)) {
party_type = frm.doc.quotation_to;
}
args = {

View File

@@ -13,7 +13,7 @@ frappe.ui.form.on('Quotation', {
frm.set_query("quotation_to", function() {
return{
"filters": {
"name": ["in", ["Customer", "Lead"]],
"name": ["in", ["Customer", "Lead", "Prospect"]],
}
}
});
@@ -160,19 +160,16 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
}
set_dynamic_field_label(){
if (this.frm.doc.quotation_to == "Customer")
{
if (this.frm.doc.quotation_to == "Customer") {
this.frm.set_df_property("party_name", "label", "Customer");
this.frm.fields_dict.party_name.get_query = null;
}
if (this.frm.doc.quotation_to == "Lead")
{
} else if (this.frm.doc.quotation_to == "Lead") {
this.frm.set_df_property("party_name", "label", "Lead");
this.frm.fields_dict.party_name.get_query = function() {
return{ query: "erpnext.controllers.queries.lead_query" }
}
} else if (this.frm.doc.quotation_to == "Prospect") {
this.frm.set_df_property("party_name", "label", "Prospect");
}
}

View File

@@ -398,10 +398,17 @@ class SalesOrder(SellingController):
def update_picking_status(self):
total_picked_qty = 0.0
total_qty = 0.0
per_picked = 0.0
for so_item in self.items:
total_picked_qty += flt(so_item.picked_qty)
total_qty += flt(so_item.stock_qty)
per_picked = total_picked_qty / total_qty * 100
if cint(
frappe.get_cached_value("Item", so_item.item_code, "is_stock_item")
) or self.has_product_bundle(so_item.item_code):
total_picked_qty += flt(so_item.picked_qty)
total_qty += flt(so_item.stock_qty)
if total_picked_qty and total_qty:
per_picked = total_picked_qty / total_qty * 100
self.db_set("per_picked", flt(per_picked), update_modified=False)

View File

@@ -27,6 +27,7 @@ def after_install():
create_default_success_action()
create_default_energy_point_rules()
create_incoterms()
create_default_role_profiles()
add_company_to_session_defaults()
add_standard_navbar_items()
add_app_name()
@@ -211,3 +212,16 @@ def setup_log_settings():
def hide_workspaces():
for ws in ["Integration", "Settings"]:
frappe.db.set_value("Workspace", ws, "public", 0)
def create_default_role_profiles():
for module in ["Accounts", "Stock", "Manufacturing"]:
create_role_profile(module)
def create_role_profile(module):
role_profile = frappe.new_doc("Role Profile")
role_profile.role_profile = _("{0} User").format(module)
role_profile.append("roles", {"role": module + " User"})
role_profile.append("roles", {"role": module + " Manager"})
role_profile.insert()

View File

@@ -622,6 +622,16 @@ def make_stock_entry(source_name, target_doc=None):
target.stock_entry_type = target.purpose
target.set_job_card_data()
if source.job_card:
job_card_details = frappe.get_all(
"Job Card", filters={"name": source.job_card}, fields=["bom_no", "for_quantity"]
)
if job_card_details and job_card_details[0]:
target.bom_no = job_card_details[0].bom_no
target.fg_completed_qty = job_card_details[0].for_quantity
target.from_bom = 1
doclist = get_mapped_doc(
"Material Request",
source_name,

View File

@@ -265,6 +265,10 @@ class PickList(Document):
for item in locations:
if not item.item_code:
frappe.throw("Row #{0}: Item Code is Mandatory".format(item.idx))
if not cint(
frappe.get_cached_value("Item", item.item_code, "is_stock_item")
) and not frappe.db.exists("Product Bundle", {"new_item_code": item.item_code}):
continue
item_code = item.item_code
reference = item.sales_order_item or item.material_request_item
key = (item_code, item.uom, item.warehouse, item.batch_no, reference)

View File

@@ -304,6 +304,9 @@ def validate_serial_no(sle, item_det):
_("Duplicate Serial No entered for Item {0}").format(sle.item_code), SerialNoDuplicateError
)
allow_existing_serial_no = cint(
frappe.get_cached_value("Stock Settings", "None", "allow_existing_serial_no")
)
for serial_no in serial_nos:
if frappe.db.exists("Serial No", serial_no):
sr = frappe.db.get_value(
@@ -332,6 +335,23 @@ def validate_serial_no(sle, item_det):
SerialNoItemError,
)
if not allow_existing_serial_no and sle.voucher_type in [
"Stock Entry",
"Purchase Receipt",
"Purchase Invoice",
]:
msg = ""
if sle.voucher_type == "Stock Entry":
se_purpose = frappe.db.get_value("Stock Entry", sle.voucher_no, "purpose")
if se_purpose in ["Manufacture", "Material Receipt"]:
msg = f"Cannot create a {sle.voucher_type} ({se_purpose}) for the Item {frappe.bold(sle.item_code)} with the existing Serial No {frappe.bold(serial_no)}."
else:
msg = f"Cannot create a {sle.voucher_type} for the Item {frappe.bold(sle.item_code)} with the existing Serial No {frappe.bold(serial_no)}."
if msg:
frappe.throw(_(msg), SerialNoDuplicateError)
if cint(sle.actual_qty) > 0 and has_serial_no_exists(sr, sle):
doc_name = frappe.bold(get_link_to_form(sr.purchase_document_type, sr.purchase_document_no))
frappe.throw(

View File

@@ -751,6 +751,50 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0))
def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
item_code = self.make_item().name
warehouse = "_Test Warehouse - _TC"
# Stock Value => 100 * 100 = 10000
se = make_stock_entry(
item_code=item_code,
target=warehouse,
qty=100,
basic_rate=100,
posting_time="10:00:00",
)
# Stock Value => 100 * 200 = 20000
# Value Change => 20000 - 10000 = 10000
sr1 = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=100,
rate=200,
posting_time="12:00:00",
)
self.assertEqual(sr1.difference_amount, 10000)
# Stock Value => 50 * 50 = 2500
# Value Change => 2500 - 10000 = -7500
sr2 = create_stock_reconciliation(
item_code=item_code,
warehouse=warehouse,
qty=50,
rate=50,
posting_time="11:00:00",
)
self.assertEqual(sr2.difference_amount, -7500)
sr1.load_from_db()
self.assertEqual(sr1.difference_amount, 17500)
sr2.cancel()
sr1.load_from_db()
self.assertEqual(sr1.difference_amount, 10000)
def create_batch_item_with_batch(item_name, batch_id):
batch_item_doc = create_item(item_name, is_stock_item=1)

View File

@@ -35,6 +35,7 @@
"section_break_7",
"automatically_set_serial_nos_based_on_fifo",
"set_qty_in_transactions_based_on_serial_no_input",
"allow_existing_serial_no",
"column_break_10",
"disable_serial_no_and_batch_selector",
"use_naming_series",
@@ -340,6 +341,12 @@
{
"fieldname": "column_break_121",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "allow_existing_serial_no",
"fieldtype": "Check",
"label": "Allow existing Serial No to be Manufactured/Received again"
}
],
"icon": "icon-cog",
@@ -347,7 +354,7 @@
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2023-05-29 15:09:54.959411",
"modified": "2023-05-31 14:15:14.145048",
"modified_by": "Administrator",
"module": "Stock",
"name": "Stock Settings",

View File

@@ -53,11 +53,14 @@ frappe.query_reports["Stock and Account Value Comparison"] = {
<p>Are you sure you want to create Reposting Entries?</p>
</div>
`;
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes.map(i => frappe.query_report.data[i]);
if (!selected_rows.length) {
frappe.throw(__("Please select rows to create Reposting Entries"));
}
frappe.confirm(__(message), () => {
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
let selected_rows = indexes.map(i => frappe.query_report.data[i]);
frappe.call({
method: "erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison.create_reposting_entries",
args: {

View File

@@ -728,6 +728,8 @@ class update_entries_after(object):
self.update_rate_on_purchase_receipt(sle, outgoing_rate)
elif flt(sle.actual_qty) < 0 and sle.voucher_type == "Subcontracting Receipt":
self.update_rate_on_subcontracting_receipt(sle, outgoing_rate)
elif sle.voucher_type == "Stock Reconciliation":
self.update_rate_on_stock_reconciliation(sle)
def update_rate_on_stock_entry(self, sle, outgoing_rate):
frappe.db.set_value("Stock Entry Detail", sle.voucher_detail_no, "basic_rate", outgoing_rate)
@@ -795,6 +797,38 @@ class update_entries_after(object):
for d in scr.items:
d.db_update()
def update_rate_on_stock_reconciliation(self, sle):
if not sle.serial_no and not sle.batch_no:
sr = frappe.get_doc("Stock Reconciliation", sle.voucher_no, for_update=True)
for item in sr.items:
# Skip for Serial and Batch Items
if item.serial_no or item.batch_no:
continue
previous_sle = get_previous_sle(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": sr.posting_date,
"posting_time": sr.posting_time,
"sle": sle.name,
}
)
item.current_qty = previous_sle.get("qty_after_transaction") or 0.0
item.current_valuation_rate = previous_sle.get("valuation_rate") or 0.0
item.current_amount = flt(item.current_qty) * flt(item.current_valuation_rate)
item.amount = flt(item.qty) * flt(item.valuation_rate)
item.amount_difference = item.amount - item.current_amount
else:
sr.difference_amount = sum([item.amount_difference for item in sr.items])
sr.db_update()
for item in sr.items:
item.db_update()
def get_serialized_values(self, sle):
incoming_rate = flt(sle.incoming_rate)
actual_qty = flt(sle.actual_qty)