Merge pull request #49942 from frappe/version-15-hotfix

chore: release v15
This commit is contained in:
ruthra kumar
2025-10-07 18:51:39 +05:30
committed by GitHub
54 changed files with 826 additions and 242 deletions

View File

@@ -10,14 +10,19 @@
"description", "description",
"section_break_4", "section_break_4",
"due_date", "due_date",
"invoice_portion",
"mode_of_payment", "mode_of_payment",
"column_break_5", "column_break_5",
"invoice_portion", "due_date_based_on",
"credit_days",
"credit_months",
"section_break_6", "section_break_6",
"discount_type",
"discount_date", "discount_date",
"column_break_9",
"discount", "discount",
"discount_type",
"column_break_9",
"discount_validity_based_on",
"discount_validity",
"section_break_9", "section_break_9",
"payment_amount", "payment_amount",
"outstanding", "outstanding",
@@ -172,12 +177,50 @@
"label": "Paid Amount (Company Currency)", "label": "Paid Amount (Company Currency)",
"options": "Company:company:default_currency", "options": "Company:company:default_currency",
"read_only": 1 "read_only": 1
},
{
"fieldname": "due_date_based_on",
"fieldtype": "Select",
"label": "Due Date Based On",
"options": "\nDay(s) after invoice date\nDay(s) after the end of the invoice month\nMonth(s) after the end of the invoice month",
"read_only": 1
},
{
"depends_on": "eval:in_list(['Day(s) after invoice date', 'Day(s) after the end of the invoice month'], doc.due_date_based_on)",
"fieldname": "credit_days",
"fieldtype": "Int",
"label": "Credit Days",
"non_negative": 1,
"read_only": 1
},
{
"depends_on": "eval:doc.due_date_based_on=='Month(s) after the end of the invoice month'",
"fieldname": "credit_months",
"fieldtype": "Int",
"label": "Credit Months",
"non_negative": 1,
"read_only": 1
},
{
"depends_on": "discount",
"fieldname": "discount_validity_based_on",
"fieldtype": "Select",
"label": "Discount Validity Based On",
"options": "\nDay(s) after invoice date\nDay(s) after the end of the invoice month\nMonth(s) after the end of the invoice month",
"read_only": 1
},
{
"depends_on": "discount_validity_based_on",
"fieldname": "discount_validity",
"fieldtype": "Int",
"label": "Discount Validity",
"read_only": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2025-03-11 11:06:51.792982", "modified": "2025-07-31 08:38:25.820701",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Payment Schedule", "name": "Payment Schedule",

View File

@@ -17,12 +17,27 @@ class PaymentSchedule(Document):
base_outstanding: DF.Currency base_outstanding: DF.Currency
base_paid_amount: DF.Currency base_paid_amount: DF.Currency
base_payment_amount: DF.Currency base_payment_amount: DF.Currency
credit_days: DF.Int
credit_months: DF.Int
description: DF.SmallText | None description: DF.SmallText | None
discount: DF.Float discount: DF.Float
discount_date: DF.Date | None discount_date: DF.Date | None
discount_type: DF.Literal["Percentage", "Amount"] discount_type: DF.Literal["Percentage", "Amount"]
discount_validity: DF.Int
discount_validity_based_on: DF.Literal[
"",
"Day(s) after invoice date",
"Day(s) after the end of the invoice month",
"Month(s) after the end of the invoice month",
]
discounted_amount: DF.Currency discounted_amount: DF.Currency
due_date: DF.Date due_date: DF.Date
due_date_based_on: DF.Literal[
"",
"Day(s) after invoice date",
"Day(s) after the end of the invoice month",
"Month(s) after the end of the invoice month",
]
invoice_portion: DF.Percent invoice_portion: DF.Percent
mode_of_payment: DF.Link | None mode_of_payment: DF.Link | None
outstanding: DF.Currency outstanding: DF.Currency

View File

@@ -2150,19 +2150,16 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
rate = flt(sle.stock_value_difference) / flt(sle.actual_qty) rate = flt(sle.stock_value_difference) / flt(sle.actual_qty)
self.assertAlmostEqual(rate, 500) self.assertAlmostEqual(rate, 500)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_payment_allocation_for_payment_terms(self): def test_payment_allocation_for_payment_terms(self):
from erpnext.buying.doctype.purchase_order.test_purchase_order import ( from erpnext.buying.doctype.purchase_order.test_purchase_order import (
create_pr_against_po, create_pr_against_po,
create_purchase_order, create_purchase_order,
) )
from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
)
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import ( from erpnext.stock.doctype.purchase_receipt.purchase_receipt import (
make_purchase_invoice as make_pi_from_pr, make_purchase_invoice as make_pi_from_pr,
) )
automatically_fetch_payment_terms()
frappe.db.set_value( frappe.db.set_value(
"Payment Terms Template", "Payment Terms Template",
"_Test Payment Term Template", "_Test Payment Term Template",
@@ -2188,7 +2185,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
pi = make_pi_from_pr(pr.name) pi = make_pi_from_pr(pr.name)
self.assertEqual(pi.payment_schedule[0].payment_amount, 1000) self.assertEqual(pi.payment_schedule[0].payment_amount, 1000)
automatically_fetch_payment_terms(enable=0)
frappe.db.set_value( frappe.db.set_value(
"Payment Terms Template", "Payment Terms Template",
"_Test Payment Term Template", "_Test Payment Term Template",

View File

@@ -483,18 +483,23 @@ class Subscription(Document):
return invoice return invoice
def get_items_from_plans(self, plans: list[dict[str, str]], prorate: bool | None = None) -> list[dict]: def get_items_from_plans(self, plans: list[dict[str, str]], prorate: int = 0) -> list[dict]:
""" """
Returns the `Item`s linked to `Subscription Plan` Returns the `Item`s linked to `Subscription Plan`
""" """
if prorate is None:
prorate = False
prorate_factor = 1
if prorate: if prorate:
prorate_factor = get_prorata_factor( prorate_factor = get_prorata_factor(
self.current_invoice_end, self.current_invoice_end,
self.current_invoice_start, self.current_invoice_start,
cint(self.generate_invoice_at == "Beginning of the current subscription period"), cint(
self.generate_invoice_at
in [
"Beginning of the current subscription period",
"Days before the current subscription period",
]
),
) )
items = [] items = []
@@ -511,20 +516,6 @@ class Subscription(Document):
deferred = frappe.db.get_value("Item", item_code, deferred_field) deferred = frappe.db.get_value("Item", item_code, deferred_field)
if not prorate:
item = {
"item_code": item_code,
"qty": plan.qty,
"rate": get_plan_rate(
plan.plan,
plan.qty,
party,
self.current_invoice_start,
self.current_invoice_end,
),
"cost_center": plan_doc.cost_center,
}
else:
item = { item = {
"item_code": item_code, "item_code": item_code,
"qty": plan.qty, "qty": plan.qty,

View File

@@ -8,6 +8,7 @@ from frappe.utils.data import (
add_days, add_days,
add_months, add_months,
add_to_date, add_to_date,
add_years,
cint, cint,
date_diff, date_diff,
flt, flt,
@@ -555,6 +556,33 @@ class TestSubscription(FrappeTestCase):
subscription.reload() subscription.reload()
self.assertEqual(len(subscription.invoices), 0) self.assertEqual(len(subscription.invoices), 0)
def test_invoice_generation_days_before_subscription_period_with_prorate(self):
settings = frappe.get_single("Subscription Settings")
settings.prorate = 1
settings.save()
create_plan(
plan_name="_Test Plan Name 5",
cost=1000,
billing_interval="Year",
billing_interval_count=1,
currency="INR",
)
start_date = add_days(nowdate(), 2)
subscription = create_subscription(
start_date=start_date,
party_type="Supplier",
party="_Test Supplier",
generate_invoice_at="Days before the current subscription period",
generate_new_invoices_past_due_date=1,
number_of_days=2,
plans=[{"plan": "_Test Plan Name 5", "qty": 1}],
)
subscription.process(nowdate())
self.assertEqual(len(subscription.invoices), 1)
def make_plans(): def make_plans():
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR") create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")

View File

@@ -1270,7 +1270,7 @@ class ReceivablePayableReport:
def setup_ageing_columns(self): def setup_ageing_columns(self):
# for charts # for charts
self.ageing_column_labels = [] self.ageing_column_labels = []
ranges = [*self.ranges, "Above"] ranges = [*self.ranges, _("Above")]
prev_range_value = 0 prev_range_value = 0
for idx, curr_range_value in enumerate(ranges): for idx, curr_range_value in enumerate(ranges):

View File

@@ -171,7 +171,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
self.add_column(_("Difference"), fieldname="diff") self.add_column(_("Difference"), fieldname="diff")
self.setup_ageing_columns() self.setup_ageing_columns()
self.add_column(label="Total Amount Due", fieldname="total_due") self.add_column(label=_("Total Amount Due"), fieldname="total_due")
if self.filters.show_future_payments: if self.filters.show_future_payments:
self.add_column(label=_("Future Payment Amount"), fieldname="future_amount") self.add_column(label=_("Future Payment Amount"), fieldname="future_amount")

View File

@@ -52,7 +52,7 @@ frappe.query_reports["Financial Ratios"] = {
}, },
], ],
formatter: function (value, row, column, data, default_formatter) { formatter: function (value, row, column, data, default_formatter) {
let heading_ratios = ["Liquidity Ratios", "Solvency Ratios", "Turnover Ratios"]; let heading_ratios = [__("Liquidity Ratios"), __("Solvency Ratios"), __("Turnover Ratios")];
if (heading_ratios.includes(value)) { if (heading_ratios.includes(value)) {
value = $(`<span>${value}</span>`); value = $(`<span>${value}</span>`);
@@ -60,7 +60,7 @@ frappe.query_reports["Financial Ratios"] = {
value = $value.wrap("<p></p>").parent().html(); value = $value.wrap("<p></p>").parent().html();
} }
if (heading_ratios.includes(row[1].content) && column.fieldtype == "Float") { if (heading_ratios.includes(row[1]?.content) && column.fieldtype == "Float") {
column.fieldtype = "Data"; column.fieldtype = "Data";
} }

View File

@@ -147,9 +147,9 @@ def get_gl_data(filters, period_list, years):
def add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset): def add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset):
precision = frappe.db.get_single_value("System Settings", "float_precision") precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Liquidity Ratios"}) data.append({"ratio": _("Liquidity Ratios")})
ratio_data = [["Current Ratio", current_asset], ["Quick Ratio", quick_asset]] ratio_data = [[_("Current Ratio"), current_asset], [_("Quick Ratio"), quick_asset]]
for d in ratio_data: for d in ratio_data:
row = { row = {
@@ -165,13 +165,13 @@ def add_solvency_ratios(
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
): ):
precision = frappe.db.get_single_value("System Settings", "float_precision") precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Solvency Ratios"}) data.append({"ratio": _("Solvency Ratios")})
debt_equity_ratio = {"ratio": "Debt Equity Ratio"} debt_equity_ratio = {"ratio": _("Debt Equity Ratio")}
gross_profit_ratio = {"ratio": "Gross Profit Ratio"} gross_profit_ratio = {"ratio": _("Gross Profit Ratio")}
net_profit_ratio = {"ratio": "Net Profit Ratio"} net_profit_ratio = {"ratio": _("Net Profit Ratio")}
return_on_asset_ratio = {"ratio": "Return on Asset Ratio"} return_on_asset_ratio = {"ratio": _("Return on Asset Ratio")}
return_on_equity_ratio = {"ratio": "Return on Equity Ratio"} return_on_equity_ratio = {"ratio": _("Return on Equity Ratio")}
for year in years: for year in years:
profit_after_tax = flt(total_income.get(year)) + flt(total_expense.get(year)) profit_after_tax = flt(total_income.get(year)) + flt(total_expense.get(year))
@@ -195,7 +195,7 @@ def add_solvency_ratios(
def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense): def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sales, cogs, direct_expense):
precision = frappe.db.get_single_value("System Settings", "float_precision") precision = frappe.db.get_single_value("System Settings", "float_precision")
data.append({"ratio": "Turnover Ratios"}) data.append({"ratio": _("Turnover Ratios")})
avg_data = {} avg_data = {}
for d in ["Receivable", "Payable", "Stock"]: for d in ["Receivable", "Payable", "Stock"]:
@@ -208,10 +208,10 @@ def add_turnover_ratios(data, years, period_list, filters, total_asset, net_sale
) )
ratio_data = [ ratio_data = [
["Fixed Asset Turnover Ratio", net_sales, total_asset], [_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
["Debtor Turnover Ratio", net_sales, avg_debtors], [_("Debtor Turnover Ratio"), net_sales, avg_debtors],
["Creditor Turnover Ratio", direct_expense, avg_creditors], [_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
["Inventory Turnover Ratio", cogs, avg_stock], [_("Inventory Turnover Ratio"), cogs, avg_stock],
] ]
for ratio in ratio_data: for ratio in ratio_data:
row = { row = {

View File

@@ -323,18 +323,24 @@ def prepare_data(accounts, balance_must_be, period_list, company_currency, accum
def filter_out_zero_value_rows(data, parent_children_map, show_zero_values=False): def filter_out_zero_value_rows(data, parent_children_map, show_zero_values=False):
def get_all_parents(account, parent_children_map):
for parent, children in parent_children_map.items():
for child in children:
if child["name"] == account and parent:
accounts_to_show.add(parent)
get_all_parents(parent, parent_children_map)
data_with_value = [] data_with_value = []
accounts_to_show = set()
for d in data: for d in data:
if show_zero_values or d.get("has_value"): if show_zero_values or d.get("has_value"):
accounts_to_show.add(d.get("account"))
get_all_parents(d.get("account"), parent_children_map)
for d in data:
if d.get("account") in accounts_to_show:
data_with_value.append(d) data_with_value.append(d)
else:
# show group with zero balance, if there are balances against child
children = [child.name for child in parent_children_map.get(d.get("account")) or []]
if children:
for row in data:
if row.get("account") in children and row.get("has_value"):
data_with_value.append(d)
break
return data_with_value return data_with_value

View File

@@ -178,7 +178,12 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
# to display item as Item Code: Item Name # to display item as Item Code: Item Name
columns[0] = "Sales Invoice:Link/Item:300" columns[0] = "Sales Invoice:Link/Item:300"
# removing Item Code and Item Name columns # removing Item Code and Item Name columns
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
if supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name":
del columns[4:6] del columns[4:6]
else:
del columns[5:7]
total_base_amount = 0 total_base_amount = 0
total_buying_amount = 0 total_buying_amount = 0
@@ -275,7 +280,7 @@ def get_columns(group_wise_columns, filters):
"label": _("Posting Date"), "label": _("Posting Date"),
"fieldname": "posting_date", "fieldname": "posting_date",
"fieldtype": "Date", "fieldtype": "Date",
"width": 100, "width": 120,
}, },
"posting_time": { "posting_time": {
"label": _("Posting Time"), "label": _("Posting Time"),

View File

@@ -947,19 +947,28 @@ def update_accounting_ledgers_after_reference_removal(
adv_ple.run() adv_ple.run()
def remove_ref_from_advance_section(ref_doc: object = None): def remove_ref_from_advance_section(ref_doc: object = None, payment_name: str | None = None):
# TODO: this might need some testing # TODO: this might need some testing
if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"): if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
ref_doc.set("advances", []) row_names = []
adv_type = qb.DocType(f"{ref_doc.doctype} Advance") for adv in ref_doc.get("advances") or []:
qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run() if adv.get("reference_name", None) == payment_name:
row_names.append(adv.name)
if not row_names:
return
child_table = (
"Sales Invoice Advance" if ref_doc.doctype == "Sales Invoice" else "Purchase Invoice Advance"
)
frappe.db.delete(child_table, {"name": ("in", row_names)})
def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str | None = None): def unlink_ref_doc_from_payment_entries(ref_doc: object = None, payment_name: str | None = None):
remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name) remove_ref_doc_link_from_jv(ref_doc.doctype, ref_doc.name, payment_name)
remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name) remove_ref_doc_link_from_pe(ref_doc.doctype, ref_doc.name, payment_name)
update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name) update_accounting_ledgers_after_reference_removal(ref_doc.doctype, ref_doc.name, payment_name)
remove_ref_from_advance_section(ref_doc) remove_ref_from_advance_section(ref_doc, payment_name)
def remove_ref_doc_link_from_jv( def remove_ref_doc_link_from_jv(
@@ -1026,7 +1035,6 @@ def remove_ref_doc_link_from_pe(
query = query.where(per.parent == payment_name) query = query.where(per.parent == payment_name)
reference_rows = query.run(as_dict=True) reference_rows = query.run(as_dict=True)
if not reference_rows: if not reference_rows:
return return

View File

@@ -790,17 +790,33 @@ frappe.ui.form.on("Asset Finance Book", {
}); });
erpnext.asset.scrap_asset = function (frm) { erpnext.asset.scrap_asset = function (frm) {
frappe.confirm(__("Do you really want to scrap this asset?"), function () { var scrap_dialog = new frappe.ui.Dialog({
title: __("Enter date to scrap asset"),
fields: [
{
label: __("Select the date"),
fieldname: "scrap_date",
fieldtype: "Date",
reqd: 1,
},
],
size: "medium",
primary_action_label: "Submit",
primary_action(values) {
frappe.call({ frappe.call({
args: { args: {
asset_name: frm.doc.name, asset_name: frm.doc.name,
scrap_date: values.scrap_date,
}, },
method: "erpnext.assets.doctype.asset.depreciation.scrap_asset", method: "erpnext.assets.doctype.asset.depreciation.scrap_asset",
callback: function (r) { callback: function (r) {
cur_frm.reload_doc(); frm.reload_doc();
scrap_dialog.hide();
}, },
}); });
},
}); });
scrap_dialog.show();
}; };
erpnext.asset.restore_asset = function (frm) { erpnext.asset.restore_asset = function (frm) {

View File

@@ -394,7 +394,7 @@ def get_comma_separated_links(names, doctype):
@frappe.whitelist() @frappe.whitelist()
def scrap_asset(asset_name): def scrap_asset(asset_name, scrap_date=None):
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)
if asset.docstatus != 1: if asset.docstatus != 1:
@@ -402,7 +402,11 @@ def scrap_asset(asset_name):
elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized"): elif asset.status in ("Cancelled", "Sold", "Scrapped", "Capitalized"):
frappe.throw(_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status)) frappe.throw(_("Asset {0} cannot be scrapped, as it is already {1}").format(asset.name, asset.status))
date = today() today_date = getdate(today())
date = getdate(scrap_date) or today_date
purchase_date = getdate(asset.purchase_date)
validate_scrap_date(date, today_date, purchase_date, asset.calculate_depreciation, asset_name)
notes = _("This schedule was created when Asset {0} was scrapped.").format( notes = _("This schedule was created when Asset {0} was scrapped.").format(
get_link_to_form(asset.doctype, asset.name) get_link_to_form(asset.doctype, asset.name)
@@ -436,6 +440,36 @@ def scrap_asset(asset_name):
frappe.msgprint(_("Asset scrapped via Journal Entry {0}").format(je.name)) frappe.msgprint(_("Asset scrapped via Journal Entry {0}").format(je.name))
def validate_scrap_date(scrap_date, today_date, purchase_date, calculate_depreciation, asset_name):
if scrap_date > today_date:
frappe.throw(_("Future date is not allowed"))
elif scrap_date < purchase_date:
frappe.throw(_("Scrap date cannot be before purchase date"))
if calculate_depreciation:
asset_depreciation_schedules = frappe.db.get_all(
"Asset Depreciation Schedule", filters={"asset": asset_name, "docstatus": 1}, fields=["name"]
)
for depreciation_schedule in asset_depreciation_schedules:
last_booked_depreciation_date = frappe.db.get_value(
"Depreciation Schedule",
{
"parent": depreciation_schedule["name"],
"docstatus": 1,
"journal_entry": ["!=", ""],
},
"schedule_date",
order_by="schedule_date desc",
)
if (
last_booked_depreciation_date
and scrap_date < last_booked_depreciation_date
and scrap_date > purchase_date
):
frappe.throw(_("Asset cannot be scrapped before the last depreciation entry."))
@frappe.whitelist() @frappe.whitelist()
def restore_asset(asset_name): def restore_asset(asset_name):
asset = frappe.get_doc("Asset", asset_name) asset = frappe.get_doc("Asset", asset_name)

View File

@@ -15,6 +15,7 @@ from frappe.utils import (
is_last_day_of_the_month, is_last_day_of_the_month,
nowdate, nowdate,
) )
from frappe.utils.data import add_to_date
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@@ -219,6 +220,31 @@ class TestAsset(AssetSetup):
) )
self.assertEqual(accumulated_depr_amount, 18000.0) self.assertEqual(accumulated_depr_amount, 18000.0)
asset_depreciation = frappe.db.get_value(
"Asset Depreciation Schedule", {"asset": asset.name, "docstatus": 1}, "name"
)
last_booked_depreciation_date = frappe.db.get_value(
"Depreciation Schedule",
{
"parent": asset_depreciation,
"docstatus": 1,
"journal_entry": ["!=", ""],
},
"schedule_date",
order_by="schedule_date desc",
)
before_purchase_date = add_to_date(asset.purchase_date, days=-1)
future_date = add_to_date(nowdate(), days=1)
if last_booked_depreciation_date:
before_last_booked_depreciation_date = add_to_date(last_booked_depreciation_date, days=-1)
self.assertRaises(frappe.ValidationError, scrap_asset, asset.name, scrap_date=before_purchase_date)
self.assertRaises(frappe.ValidationError, scrap_asset, asset.name, scrap_date=future_date)
self.assertRaises(
frappe.ValidationError, scrap_asset, asset.name, scrap_date=before_last_booked_depreciation_date
)
scrap_asset(asset.name) scrap_asset(asset.name)
asset.load_from_db() asset.load_from_db()
first_asset_depr_schedule.load_from_db() first_asset_depr_schedule.load_from_db()

View File

@@ -142,10 +142,18 @@ class AssetMovement(Document):
def update_asset_location_and_custodian(self, asset_id, location, employee): def update_asset_location_and_custodian(self, asset_id, location, employee):
asset = frappe.get_doc("Asset", asset_id) asset = frappe.get_doc("Asset", asset_id)
updates = {}
if employee and employee != asset.custodian: if employee and employee != asset.custodian:
frappe.db.set_value("Asset", asset_id, "custodian", employee) updates["custodian"] = employee
elif not employee and asset.custodian:
updates["custodian"] = ""
if location and location != asset.location: if location and location != asset.location:
frappe.db.set_value("Asset", asset_id, "location", location) updates["location"] = location
if updates:
frappe.db.set_value("Asset", asset_id, updates)
def log_asset_activity(self, asset_id, location, employee): def log_asset_activity(self, asset_id, location, employee):
if location and employee: if location and employee:

View File

@@ -268,6 +268,7 @@ def get_asset_depreciation_amount_map(filters, finance_book):
.where(gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account)) .where(gle.account == IfNull(aca.depreciation_expense_account, company.depreciation_expense_account))
.where(gle.debit != 0) .where(gle.debit != 0)
.where(gle.is_cancelled == 0) .where(gle.is_cancelled == 0)
.where(gle.is_opening == "No")
.where(company.name == filters.company) .where(company.name == filters.company)
.where(asset.docstatus == 1) .where(asset.docstatus == 1)
) )

View File

@@ -541,12 +541,8 @@ class TestPurchaseOrder(FrappeTestCase):
self.assertRaises(frappe.ValidationError, pr.submit) self.assertRaises(frappe.ValidationError, pr.submit)
self.assertRaises(frappe.ValidationError, pi.submit) self.assertRaises(frappe.ValidationError, pi.submit)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_make_purchase_invoice_with_terms(self): def test_make_purchase_invoice_with_terms(self):
from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
)
automatically_fetch_payment_terms()
po = create_purchase_order(do_not_save=True) po = create_purchase_order(do_not_save=True)
self.assertRaises(frappe.ValidationError, make_pi_from_po, po.name) self.assertRaises(frappe.ValidationError, make_pi_from_po, po.name)
@@ -570,7 +566,6 @@ class TestPurchaseOrder(FrappeTestCase):
self.assertEqual(getdate(pi.payment_schedule[0].due_date), getdate(po.transaction_date)) self.assertEqual(getdate(pi.payment_schedule[0].due_date), getdate(po.transaction_date))
self.assertEqual(pi.payment_schedule[1].payment_amount, 2500.0) self.assertEqual(pi.payment_schedule[1].payment_amount, 2500.0)
self.assertEqual(getdate(pi.payment_schedule[1].due_date), add_days(getdate(po.transaction_date), 30)) self.assertEqual(getdate(pi.payment_schedule[1].due_date), add_days(getdate(po.transaction_date), 30))
automatically_fetch_payment_terms(enable=0)
def test_warehouse_company_validation(self): def test_warehouse_company_validation(self):
from erpnext.stock.utils import InvalidWarehouseCompany from erpnext.stock.utils import InvalidWarehouseCompany
@@ -718,6 +713,7 @@ class TestPurchaseOrder(FrappeTestCase):
) )
self.assertEqual(due_date, "2023-03-31") self.assertEqual(due_date, "2023-03-31")
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 0})
def test_terms_are_not_copied_if_automatically_fetch_payment_terms_is_unchecked(self): def test_terms_are_not_copied_if_automatically_fetch_payment_terms_is_unchecked(self):
po = create_purchase_order(do_not_save=1) po = create_purchase_order(do_not_save=1)
po.payment_terms_template = "_Test Payment Term Template" po.payment_terms_template = "_Test Payment Term Template"
@@ -905,18 +901,16 @@ class TestPurchaseOrder(FrappeTestCase):
bo.load_from_db() bo.load_from_db()
self.assertEqual(bo.items[0].ordered_qty, 5) self.assertEqual(bo.items[0].ordered_qty, 5)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_payment_terms_are_fetched_when_creating_purchase_invoice(self): def test_payment_terms_are_fetched_when_creating_purchase_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template, create_payment_terms_template,
) )
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.selling.doctype.sales_order.test_sales_order import ( from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
compare_payment_schedules, compare_payment_schedules,
) )
automatically_fetch_payment_terms()
po = create_purchase_order(qty=10, rate=100, do_not_save=1) po = create_purchase_order(qty=10, rate=100, do_not_save=1)
create_payment_terms_template() create_payment_terms_template()
po.payment_terms_template = "Test Receivable Template" po.payment_terms_template = "Test Receivable Template"
@@ -930,8 +924,6 @@ class TestPurchaseOrder(FrappeTestCase):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template) # self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi) compare_payment_schedules(self, po, pi)
automatically_fetch_payment_terms(enable=0)
def test_internal_transfer_flow(self): def test_internal_transfer_flow(self):
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
from erpnext.accounts.doctype.sales_invoice.sales_invoice import ( from erpnext.accounts.doctype.sales_invoice.sales_invoice import (

View File

@@ -109,21 +109,9 @@ frappe.ui.form.on("Supplier", {
__("View") __("View")
); );
frm.add_custom_button( frm.add_custom_button(__("Bank Account"), () => frm.make_methods["Bank Account"](), __("Create"));
__("Bank Account"),
function () {
erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name);
},
__("Create")
);
frm.add_custom_button( frm.add_custom_button(__("Pricing Rule"), () => frm.make_methods["Pricing Rule"](), __("Create"));
__("Pricing Rule"),
function () {
erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name);
},
__("Create")
);
frm.add_custom_button( frm.add_custom_button(
__("Get Supplier Group Details"), __("Get Supplier Group Details"),

View File

@@ -231,6 +231,11 @@ class AccountsController(TransactionBase):
self.validate_date_with_fiscal_year() self.validate_date_with_fiscal_year()
self.validate_party_accounts() self.validate_party_accounts()
if self.doctype in ["Sales Invoice", "Purchase Invoice"]:
if self.is_return:
self.validate_qty()
else:
self.validate_deferred_start_and_end_date()
self.validate_inter_company_reference() self.validate_inter_company_reference()
# validate inter company transaction rate # validate inter company transaction rate
@@ -282,11 +287,6 @@ class AccountsController(TransactionBase):
self.set_advance_gain_or_loss() self.set_advance_gain_or_loss()
if self.is_return:
self.validate_qty()
else:
self.validate_deferred_start_and_end_date()
self.validate_deferred_income_expense_account() self.validate_deferred_income_expense_account()
self.set_inter_company_account() self.set_inter_company_account()
@@ -2558,6 +2558,7 @@ class AccountsController(TransactionBase):
self.payment_schedule = [] self.payment_schedule = []
self.payment_terms_template = po_or_so.payment_terms_template self.payment_terms_template = po_or_so.payment_terms_template
posting_date = self.get("bill_date") or self.get("posting_date") or self.get("transaction_date")
for schedule in po_or_so.payment_schedule: for schedule in po_or_so.payment_schedule:
payment_schedule = { payment_schedule = {
@@ -2570,6 +2571,17 @@ class AccountsController(TransactionBase):
} }
if automatically_fetch_payment_terms: if automatically_fetch_payment_terms:
if schedule.due_date_based_on:
payment_schedule["due_date"] = get_due_date(schedule, posting_date)
payment_schedule["due_date_based_on"] = schedule.due_date_based_on
payment_schedule["credit_days"] = cint(schedule.credit_days)
payment_schedule["credit_months"] = cint(schedule.credit_months)
if schedule.discount_validity_based_on:
payment_schedule["discount_date"] = get_discount_date(schedule, posting_date)
payment_schedule["discount_validity_based_on"] = schedule.discount_validity_based_on
payment_schedule["discount_validity"] = cint(schedule.discount_validity)
payment_schedule["payment_amount"] = flt( payment_schedule["payment_amount"] = flt(
grand_total * flt(payment_schedule["invoice_portion"]) / 100, grand_total * flt(payment_schedule["invoice_portion"]) / 100,
schedule.precision("payment_amount"), schedule.precision("payment_amount"),
@@ -3369,14 +3381,27 @@ def get_payment_term_details(
term = frappe.get_doc("Payment Term", term) term = frappe.get_doc("Payment Term", term)
else: else:
term_details.payment_term = term.payment_term term_details.payment_term = term.payment_term
term_details.description = term.description
term_details.invoice_portion = term.invoice_portion fields_to_copy = [
"description",
"invoice_portion",
"discount_type",
"discount",
"mode_of_payment",
"due_date_based_on",
"credit_days",
"credit_months",
"discount_validity_based_on",
"discount_validity",
]
for field in fields_to_copy:
term_details[field] = term.get(field)
term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100 term_details.payment_amount = flt(term.invoice_portion) * flt(grand_total) / 100
term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100 term_details.base_payment_amount = flt(term.invoice_portion) * flt(base_grand_total) / 100
term_details.discount_type = term.discount_type
term_details.discount = term.discount
term_details.outstanding = term_details.payment_amount term_details.outstanding = term_details.payment_amount
term_details.mode_of_payment = term.mode_of_payment term_details.base_outstanding = term_details.base_payment_amount
if bill_date: if bill_date:
term_details.due_date = get_due_date(term, bill_date) term_details.due_date = get_due_date(term, bill_date)
@@ -3395,11 +3420,11 @@ def get_due_date(term, posting_date=None, bill_date=None):
due_date = None due_date = None
date = bill_date or posting_date date = bill_date or posting_date
if term.due_date_based_on == "Day(s) after invoice date": if term.due_date_based_on == "Day(s) after invoice date":
due_date = add_days(date, term.credit_days) due_date = add_days(date, cint(term.credit_days))
elif term.due_date_based_on == "Day(s) after the end of the invoice month": elif term.due_date_based_on == "Day(s) after the end of the invoice month":
due_date = add_days(get_last_day(date), term.credit_days) due_date = add_days(get_last_day(date), cint(term.credit_days))
elif term.due_date_based_on == "Month(s) after the end of the invoice month": elif term.due_date_based_on == "Month(s) after the end of the invoice month":
due_date = get_last_day(add_months(date, term.credit_months)) due_date = get_last_day(add_months(date, cint(term.credit_months)))
return due_date return due_date
@@ -3407,11 +3432,11 @@ def get_discount_date(term, posting_date=None, bill_date=None):
discount_validity = None discount_validity = None
date = bill_date or posting_date date = bill_date or posting_date
if term.discount_validity_based_on == "Day(s) after invoice date": if term.discount_validity_based_on == "Day(s) after invoice date":
discount_validity = add_days(date, term.discount_validity) discount_validity = add_days(date, cint(term.discount_validity))
elif term.discount_validity_based_on == "Day(s) after the end of the invoice month": elif term.discount_validity_based_on == "Day(s) after the end of the invoice month":
discount_validity = add_days(get_last_day(date), term.discount_validity) discount_validity = add_days(get_last_day(date), cint(term.discount_validity))
elif term.discount_validity_based_on == "Month(s) after the end of the invoice month": elif term.discount_validity_based_on == "Month(s) after the end of the invoice month":
discount_validity = get_last_day(add_months(date, term.discount_validity)) discount_validity = get_last_day(add_months(date, cint(term.discount_validity)))
return discount_validity return discount_validity

View File

@@ -313,7 +313,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
if filters: if filters:
if filters.get("customer"): if filters.get("customer"):
qb_filter_and_conditions.append( qb_filter_and_conditions.append(
(proj.customer == filters.get("customer")) | proj.customer.isnull() | proj.customer == "" (proj.customer == filters.get("customer")) | (proj.customer.isnull()) | (proj.customer == "")
) )
if filters.get("company"): if filters.get("company"):

View File

@@ -6,6 +6,7 @@
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"code_list", "code_list",
"canonical_uri",
"title", "title",
"common_code", "common_code",
"description", "description",
@@ -71,10 +72,17 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Description", "label": "Description",
"max_height": "60px" "max_height": "60px"
},
{
"fetch_from": "code_list.canonical_uri",
"fieldname": "canonical_uri",
"fieldtype": "Data",
"label": "Canonical URI"
} }
], ],
"grid_page_length": 50,
"links": [], "links": [],
"modified": "2024-11-06 07:46:17.175687", "modified": "2025-10-04 17:22:28.176155",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "EDI", "module": "EDI",
"name": "Common Code", "name": "Common Code",
@@ -94,6 +102,7 @@
"write": 1 "write": 1
} }
], ],
"row_format": "Dynamic",
"search_fields": "common_code,description", "search_fields": "common_code,description",
"show_title_field_in_link": 1, "show_title_field_in_link": 1,
"sort_field": "creation", "sort_field": "creation",

View File

@@ -22,6 +22,7 @@ class CommonCode(Document):
additional_data: DF.Code | None additional_data: DF.Code | None
applies_to: DF.Table[DynamicLink] applies_to: DF.Table[DynamicLink]
canonical_uri: DF.Data | None
code_list: DF.Link code_list: DF.Link
common_code: DF.Data common_code: DF.Data
description: DF.SmallText | None description: DF.SmallText | None

View File

@@ -253,6 +253,13 @@ class BOMCreator(Document):
if not row.fg_reference_id and production_item_wise_rm.get((row.fg_item, row.fg_reference_id)): if not row.fg_reference_id and production_item_wise_rm.get((row.fg_item, row.fg_reference_id)):
frappe.throw(_("Please set Parent Row No for item {0}").format(row.fg_item)) frappe.throw(_("Please set Parent Row No for item {0}").format(row.fg_item))
key = (row.fg_item, row.fg_reference_id)
if key not in production_item_wise_rm:
production_item_wise_rm.setdefault(
key,
frappe._dict({"items": [], "bom_no": "", "fg_item_data": row}),
)
production_item_wise_rm[(row.fg_item, row.fg_reference_id)]["items"].append(row) production_item_wise_rm[(row.fg_item, row.fg_reference_id)]["items"].append(row)
reverse_tree = OrderedDict(reversed(list(production_item_wise_rm.items()))) reverse_tree = OrderedDict(reversed(list(production_item_wise_rm.items())))

View File

@@ -748,6 +748,7 @@ class ProductionPlan(Document):
work_order_data = { work_order_data = {
"wip_warehouse": default_warehouses.get("wip_warehouse"), "wip_warehouse": default_warehouses.get("wip_warehouse"),
"fg_warehouse": default_warehouses.get("fg_warehouse"), "fg_warehouse": default_warehouses.get("fg_warehouse"),
"scrap_warehouse": default_warehouses.get("scrap_warehouse"),
"company": self.get("company"), "company": self.get("company"),
} }
@@ -1821,7 +1822,7 @@ def get_sub_assembly_items(
def set_default_warehouses(row, default_warehouses): def set_default_warehouses(row, default_warehouses):
for field in ["wip_warehouse", "fg_warehouse"]: for field in ["wip_warehouse", "fg_warehouse", "scrap_warehouse"]:
if not row.get(field): if not row.get(field):
row[field] = default_warehouses.get(field) row[field] = default_warehouses.get(field)

View File

@@ -2828,6 +2828,111 @@ class TestWorkOrder(FrappeTestCase):
wo.operations[3].planned_start_time, add_to_date(wo.operations[1].planned_end_time, minutes=10) wo.operations[3].planned_start_time, add_to_date(wo.operations[1].planned_end_time, minutes=10)
) )
def test_req_qty_clamping_in_manufacture_entry(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
make_stock_entry as make_stock_entry_test_record,
)
fg_item = "Test Unconsumed RM FG Item"
rm_item_1 = "Test Unconsumed RM Item 1"
rm_item_2 = "Test Unconsumed RM Item 2"
source_warehouse = "_Test Warehouse - _TC"
wip_warehouse = "Stores - _TC"
fg_warehouse = create_warehouse("_Test Finished Goods Warehouse", company="_Test Company")
make_item(fg_item, {"is_stock_item": 1})
make_item(rm_item_1, {"is_stock_item": 1})
make_item(rm_item_2, {"is_stock_item": 1})
# create a BOM: 1 FG = 1 RM1 + 1 RM2
bom = make_bom(
item=fg_item,
source_warehouse=source_warehouse,
raw_materials=[rm_item_1, rm_item_2],
operating_cost_per_bom_quantity=1,
do_not_submit=True,
)
for row in bom.exploded_items:
make_stock_entry_test_record(
item_code=row.item_code,
target=source_warehouse,
qty=100,
basic_rate=100,
)
wo = make_wo_order_test_record(
item=fg_item,
qty=50,
source_warehouse=source_warehouse,
wip_warehouse=wip_warehouse,
)
wo.submit()
# first partial transfer & manufacture (6 units)
se_transfer_1 = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", 6, wip_warehouse)
)
se_transfer_1.insert()
se_transfer_1.submit()
stock_entry_1 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 6, fg_warehouse))
# remove rm_2 from the items to simulate unconsumed RM scenario
stock_entry_1.items = [row for row in stock_entry_1.items if row.item_code != rm_item_2]
stock_entry_1.save()
stock_entry_1.submit()
wo.reload()
se_transfer_2 = frappe.get_doc(
make_stock_entry(wo.name, "Material Transfer for Manufacture", 20, wip_warehouse)
)
se_transfer_2.insert()
se_transfer_2.submit()
stock_entry_2 = frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 20, fg_warehouse))
# validate rm_item_2 quantity is clamped correctly (per-unit BOM = 1 → max 20)
for row in stock_entry_2.items:
if row.item_code == rm_item_2:
self.assertLessEqual(row.qty, 20)
self.assertGreaterEqual(row.qty, 0)
def test_overproduction_allowed_qty(self):
"""Test overproduction allowed qty in work order"""
allow_overproduction("overproduction_percentage_for_work_order", 50)
wo_order = make_wo_order_test_record(planned_start_date=now(), qty=10)
test_stock_entry.make_stock_entry(
item_code="_Test Item", target="Stores - _TC", qty=100, basic_rate=100
)
test_stock_entry.make_stock_entry(
item_code="_Test Item Home Desktop 100",
target="_Test Warehouse - _TC",
qty=100,
basic_rate=1000.0,
)
mt_stock_entry = frappe.get_doc(
make_stock_entry(wo_order.name, "Material Transfer for Manufacture", 10)
)
mt_stock_entry.submit()
fg_stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
fg_stock_entry.items[2].qty = 15
fg_stock_entry.fg_completed_qty = 15
fg_stock_entry.submit()
wo_order.reload()
self.assertEqual(wo_order.produced_qty, 15)
self.assertEqual(wo_order.status, "Completed")
allow_overproduction("overproduction_percentage_for_work_order", 0)
def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse): def make_stock_in_entries_and_get_batches(rm_item, source_warehouse, wip_warehouse):
from erpnext.stock.doctype.stock_entry.test_stock_entry import ( from erpnext.stock.doctype.stock_entry.test_stock_entry import (

View File

@@ -263,6 +263,7 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
erpnext.patches.v14_0.update_proprietorship_to_individual erpnext.patches.v14_0.update_proprietorship_to_individual
erpnext.patches.v15_0.rename_subcontracting_fields erpnext.patches.v15_0.rename_subcontracting_fields
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
erpnext.patches.v16_0.create_company_custom_fields
[post_model_sync] [post_model_sync]
erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets

View File

@@ -0,0 +1,6 @@
from erpnext.setup.install import create_custom_company_links
def execute():
"""Add link fields to Company in Email Account and Communication."""
create_custom_company_links()

View File

@@ -174,7 +174,10 @@ erpnext.buying = {
this.frm.set_value("billing_address", r.message.primary_address || ""); this.frm.set_value("billing_address", r.message.primary_address || "");
if (!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) return; if (!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) return;
this.frm.set_value("shipping_address", r.message.shipping_address || ""); this.frm.set_value(
"shipping_address",
r.message.shipping_address || this.frm.doc.shipping_address || ""
);
}, },
}); });
erpnext.utils.set_letter_head(this.frm) erpnext.utils.set_letter_head(this.frm)

View File

@@ -76,9 +76,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
// Update paid amount on return/debit note creation // Update paid amount on return/debit note creation
if ( if (
this.frm.doc.doctype === "Purchase Invoice" this.frm.doc.doctype === "Purchase Invoice" &&
&& this.frm.doc.is_return this.frm.doc.is_return &&
&& (this.frm.doc.grand_total > this.frm.doc.paid_amount) this.frm.doc.grand_total < 0 &&
this.frm.doc.grand_total > this.frm.doc.paid_amount
) { ) {
this.frm.doc.paid_amount = flt(this.frm.doc.grand_total, precision("grand_total")); this.frm.doc.paid_amount = flt(this.frm.doc.grand_total, precision("grand_total"));
} }

View File

@@ -1082,12 +1082,25 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
} }
due_date(doc, cdt) { discount_date(doc, cdt, cdn) {
// Remove fields as discount_date is auto-managed by payment terms
const row = locals[cdt][cdn];
["discount_validity", "discount_validity_based_on"].forEach((field) => {
row[field] = "";
});
this.frm.refresh_field("payment_schedule");
}
due_date(doc, cdt, cdn) {
// due_date is to be changed, payment terms template and/or payment schedule must // due_date is to be changed, payment terms template and/or payment schedule must
// be removed as due_date is automatically changed based on payment terms // be removed as due_date is automatically changed based on payment terms
if (doc.doctype !== cdt) { if (doc.doctype !== cdt) {
// triggered by change to the due_date field in payment schedule child table // Remove fields as due_date is auto-managed by payment terms
// do nothing to avoid infinite clearing loop const row = locals[cdt][cdn];
["due_date_based_on", "credit_days", "credit_months"].forEach((field) => {
row[field] = "";
});
this.frm.refresh_field("payment_schedule");
return; return;
} }
@@ -2579,6 +2592,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
'valid_from': ["<=", doc.transaction_date || doc.bill_date || doc.posting_date], 'valid_from': ["<=", doc.transaction_date || doc.bill_date || doc.posting_date],
'item_group': item.item_group, 'item_group': item.item_group,
"base_net_rate": item.base_net_rate, "base_net_rate": item.base_net_rate,
"disabled": 0,
} }
if (doc.tax_category) if (doc.tax_category)
@@ -2620,6 +2634,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
payment_term(doc, cdt, cdn) { payment_term(doc, cdt, cdn) {
const me = this; const me = this;
var row = locals[cdt][cdn]; var row = locals[cdt][cdn];
// empty date condition fields
[
"due_date_based_on",
"credit_days",
"credit_months",
"discount_validity",
"discount_validity_based_on",
].forEach(function (field) {
row[field] = "";
});
if(row.payment_term) { if(row.payment_term) {
frappe.call({ frappe.call({
method: "erpnext.controllers.accounts_controller.get_payment_term_details", method: "erpnext.controllers.accounts_controller.get_payment_term_details",
@@ -2632,14 +2657,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}, },
callback: function(r) { callback: function(r) {
if(r.message && !r.exc) { if(r.message && !r.exc) {
for (var d in r.message) {
frappe.model.set_value(cdt, cdn, d, r.message[d]);
const company_currency = me.get_company_currency(); const company_currency = me.get_company_currency();
me.update_payment_schedule_grid_labels(company_currency); for (let d in r.message) {
row[d] = r.message[d];
} }
me.update_payment_schedule_grid_labels(company_currency)
me.frm.refresh_field("payment_schedule");
} }
} },
}) });
} else {
me.frm.refresh_field("payment_schedule");
} }
} }

View File

@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, flt, getdate, nowdate, today from frappe.utils import add_days, flt, getdate, nowdate, today
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
from erpnext.controllers.accounts_controller import InvalidQtyError, update_child_qty_rate from erpnext.controllers.accounts_controller import InvalidQtyError, get_due_date, update_child_qty_rate
from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import ( from erpnext.maintenance.doctype.maintenance_schedule.test_maintenance_schedule import (
make_maintenance_schedule, make_maintenance_schedule,
) )
@@ -1680,14 +1680,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
so.load_from_db() so.load_from_db()
self.assertRaises(frappe.LinkExistsError, so.cancel) self.assertRaises(frappe.LinkExistsError, so.cancel)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_payment_terms_are_fetched_when_creating_sales_invoice(self): def test_payment_terms_are_fetched_when_creating_sales_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template, create_payment_terms_template,
) )
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
automatically_fetch_payment_terms()
so = make_sales_order(uom="Nos", do_not_save=1) so = make_sales_order(uom="Nos", do_not_save=1)
create_payment_terms_template() create_payment_terms_template()
so.payment_terms_template = "Test Receivable Template" so.payment_terms_template = "Test Receivable Template"
@@ -1701,8 +1700,6 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertEqual(so.payment_terms_template, si.payment_terms_template) self.assertEqual(so.payment_terms_template, si.payment_terms_template)
compare_payment_schedules(self, so, si) compare_payment_schedules(self, so, si)
automatically_fetch_payment_terms(enable=0)
def test_zero_amount_sales_order_billing_status(self): def test_zero_amount_sales_order_billing_status(self):
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
@@ -2421,16 +2418,14 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
self.assertEqual(si2.items[0].qty, 20) self.assertEqual(si2.items[0].qty, 20)
def automatically_fetch_payment_terms(enable=1):
accounts_settings = frappe.get_doc("Accounts Settings")
accounts_settings.automatically_fetch_payment_terms = enable
accounts_settings.save()
def compare_payment_schedules(doc, doc1, doc2): def compare_payment_schedules(doc, doc1, doc2):
for index, schedule in enumerate(doc1.get("payment_schedule")): for index, schedule in enumerate(doc1.get("payment_schedule")):
posting_date = doc1.get("bill_date") or doc1.get("posting_date") or doc1.get("transaction_date")
due_date = schedule.due_date
if schedule.due_date_based_on:
due_date = get_due_date(schedule, posting_date=posting_date)
doc.assertEqual(schedule.payment_term, doc2.payment_schedule[index].payment_term) doc.assertEqual(schedule.payment_term, doc2.payment_schedule[index].payment_term)
doc.assertEqual(getdate(schedule.due_date), doc2.payment_schedule[index].due_date) doc.assertEqual(due_date, doc2.payment_schedule[index].due_date)
doc.assertEqual(schedule.invoice_portion, doc2.payment_schedule[index].invoice_portion) doc.assertEqual(schedule.invoice_portion, doc2.payment_schedule[index].invoice_portion)
doc.assertEqual(schedule.payment_amount, doc2.payment_schedule[index].payment_amount) doc.assertEqual(schedule.payment_amount, doc2.payment_schedule[index].payment_amount)

View File

@@ -17,6 +17,7 @@
"role_to_override_stop_action", "role_to_override_stop_action",
"column_break_15", "column_break_15",
"maintain_same_sales_rate", "maintain_same_sales_rate",
"fallback_to_default_price_list",
"editable_price_list_rate", "editable_price_list_rate",
"validate_selling_price", "validate_selling_price",
"editable_bundle_item_rates", "editable_bundle_item_rates",
@@ -216,6 +217,12 @@
"fieldname": "allow_zero_qty_in_quotation", "fieldname": "allow_zero_qty_in_quotation",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Quotation with Zero Quantity" "label": "Allow Quotation with Zero Quantity"
},
{
"default": "0",
"fieldname": "fallback_to_default_price_list",
"fieldtype": "Check",
"label": "Use Prices from Default Price List as Fallback"
} }
], ],
"grid_page_length": 50, "grid_page_length": 50,
@@ -224,7 +231,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2025-05-06 15:23:14.332971", "modified": "2025-09-23 21:10:14.826653",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Selling", "module": "Selling",
"name": "Selling Settings", "name": "Selling Settings",

View File

@@ -5,6 +5,7 @@
import frappe import frappe
from frappe import _
from frappe.custom.doctype.property_setter.property_setter import make_property_setter from frappe.custom.doctype.property_setter.property_setter import make_property_setter
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint from frappe.utils import cint
@@ -33,6 +34,7 @@ class SellingSettings(Document):
editable_bundle_item_rates: DF.Check editable_bundle_item_rates: DF.Check
editable_price_list_rate: DF.Check editable_price_list_rate: DF.Check
enable_discount_accounting: DF.Check enable_discount_accounting: DF.Check
fallback_to_default_price_list: DF.Check
hide_tax_id: DF.Check hide_tax_id: DF.Check
maintain_same_rate_action: DF.Literal["Stop", "Warn"] maintain_same_rate_action: DF.Literal["Stop", "Warn"]
maintain_same_sales_rate: DF.Check maintain_same_sales_rate: DF.Check
@@ -69,16 +71,35 @@ class SellingSettings(Document):
hide_name_field=False, hide_name_field=False,
) )
self.validate_fallback_to_default_price_list()
def validate_fallback_to_default_price_list(self):
if (
self.fallback_to_default_price_list
and self.has_value_changed("fallback_to_default_price_list")
and frappe.get_single_value("Stock Settings", "auto_insert_price_list_rate_if_missing")
):
stock_meta = frappe.get_meta("Stock Settings")
frappe.msgprint(
_(
"You have enabled {0} and {1} in {2}. This can lead to prices from the default price list being inserted into the transaction price list."
).format(
"<i>{}</i>".format(_(self.meta.get_label("fallback_to_default_price_list"))),
"<i>{}</i>".format(_(stock_meta.get_label("auto_insert_price_list_rate_if_missing"))),
frappe.bold(_("Stock Settings")),
)
)
def toggle_hide_tax_id(self): def toggle_hide_tax_id(self):
self.hide_tax_id = cint(self.hide_tax_id) _hide_tax_id = cint(self.hide_tax_id)
# Make property setters to hide tax_id fields # Make property setters to hide tax_id fields
for doctype in ("Sales Order", "Sales Invoice", "Delivery Note"): for doctype in ("Sales Order", "Sales Invoice", "Delivery Note"):
make_property_setter( make_property_setter(
doctype, "tax_id", "hidden", self.hide_tax_id, "Check", validate_fields_for_doctype=False doctype, "tax_id", "hidden", _hide_tax_id, "Check", validate_fields_for_doctype=False
) )
make_property_setter( make_property_setter(
doctype, "tax_id", "print_hide", self.hide_tax_id, "Check", validate_fields_for_doctype=False doctype, "tax_id", "print_hide", _hide_tax_id, "Check", validate_fields_for_doctype=False
) )
def toggle_editable_rate_for_bundle_items(self): def toggle_editable_rate_for_bundle_items(self):

View File

@@ -25,6 +25,7 @@ def after_install():
set_single_defaults() set_single_defaults()
create_print_setting_custom_fields() create_print_setting_custom_fields()
create_custom_company_links()
add_all_roles_to("Administrator") add_all_roles_to("Administrator")
create_default_success_action() create_default_success_action()
create_default_energy_point_rules() create_default_energy_point_rules()
@@ -132,6 +133,39 @@ def create_print_setting_custom_fields():
) )
def create_custom_company_links():
"""Add link fields to Company in Email Account and Communication.
These DocTypes are provided by the Frappe Framework but need to be associated
with a company in ERPNext to allow for multitenancy. I.e. one company should
not be able to access emails and communications from another company.
"""
create_custom_fields(
{
"Email Account": [
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"insert_after": "email_id",
},
],
"Communication": [
{
"label": _("Company"),
"fieldname": "company",
"fieldtype": "Link",
"options": "Company",
"insert_after": "email_account",
"fetch_from": "email_account.company",
"read_only": 1,
},
],
},
)
def create_default_success_action(): def create_default_success_action():
for success_action in get_default_success_action(): for success_action in get_default_success_action():
if not frappe.db.exists("Success Action", success_action.get("ref_doctype")): if not frappe.db.exists("Success Action", success_action.get("ref_doctype")):

View File

@@ -7,15 +7,16 @@ from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import flt, nowtime from frappe.utils import flt, nowtime
from frappe.utils.deprecations import deprecated from frappe.utils.deprecations import deprecated
from pypika import Order from pypika import Order
from pypika.functions import Coalesce
class DeprecatedSerialNoValuation: class DeprecatedSerialNoValuation:
@deprecated @deprecated
def calculate_stock_value_from_deprecarated_ledgers(self): def calculate_stock_value_from_deprecarated_ledgers(self):
if not has_sle_for_serial_nos(self.sle.item_code): serial_nos = []
return if hasattr(self, "old_serial_nos"):
serial_nos = self.old_serial_nos
serial_nos = self.get_filterd_serial_nos()
if not serial_nos: if not serial_nos:
return return
@@ -25,17 +26,6 @@ class DeprecatedSerialNoValuation:
self.stock_value_change += flt(stock_value_change) self.stock_value_change += flt(stock_value_change)
def get_filterd_serial_nos(self):
serial_nos = []
non_filtered_serial_nos = self.get_serial_nos()
# If the serial no inwarded using the Serial and Batch Bundle, then the serial no should not be considered
for serial_no in non_filtered_serial_nos:
if serial_no and serial_no not in self.serial_no_incoming_rate:
serial_nos.append(serial_no)
return serial_nos
@deprecated @deprecated
def get_incoming_value_for_serial_nos(self, serial_nos): def get_incoming_value_for_serial_nos(self, serial_nos):
from erpnext.stock.utils import get_combine_datetime from erpnext.stock.utils import get_combine_datetime
@@ -81,20 +71,6 @@ class DeprecatedSerialNoValuation:
return incoming_values return incoming_values
@frappe.request_cache
def has_sle_for_serial_nos(item_code):
serial_nos = frappe.db.get_all(
"Stock Ledger Entry",
fields=["name"],
filters={"serial_no": ("is", "set"), "is_cancelled": 0, "item_code": item_code},
limit=1,
)
if serial_nos:
return True
return False
class DeprecatedBatchNoValuation: class DeprecatedBatchNoValuation:
@deprecated @deprecated
def calculate_avg_rate_from_deprecarated_ledgers(self): def calculate_avg_rate_from_deprecarated_ledgers(self):
@@ -197,9 +173,15 @@ class DeprecatedBatchNoValuation:
@deprecated @deprecated
def set_balance_value_for_non_batchwise_valuation_batches(self): def set_balance_value_for_non_batchwise_valuation_batches(self):
if hasattr(self, "prev_sle"):
self.last_sle = self.prev_sle
else:
self.last_sle = self.get_last_sle_for_non_batch() self.last_sle = self.get_last_sle_for_non_batch()
if self.last_sle and self.last_sle.stock_queue: if self.last_sle and self.last_sle.stock_queue:
self.stock_queue = json.loads(self.last_sle.stock_queue or "[]") or [] self.stock_queue = self.last_sle.stock_queue
if isinstance(self.stock_queue, str):
self.stock_queue = json.loads(self.stock_queue) or []
self.set_balance_value_from_sl_entries() self.set_balance_value_from_sl_entries()
self.set_balance_value_from_bundle() self.set_balance_value_from_bundle()
@@ -293,10 +275,7 @@ class DeprecatedBatchNoValuation:
query = query.where(sle.name != self.sle.name) query = query.where(sle.name != self.sle.name)
if self.sle.serial_and_batch_bundle: if self.sle.serial_and_batch_bundle:
query = query.where( query = query.where(Coalesce(sle.serial_and_batch_bundle, "") != self.sle.serial_and_batch_bundle)
(sle.serial_and_batch_bundle != self.sle.serial_and_batch_bundle)
| (sle.serial_and_batch_bundle.isnull())
)
data = query.run(as_dict=True) data = query.run(as_dict=True)

View File

@@ -22,6 +22,17 @@ frappe.ui.form.on("Batch", {
frappe.set_route("query-report", "Stock Ledger"); frappe.set_route("query-report", "Stock Ledger");
}); });
frm.trigger("make_dashboard"); frm.trigger("make_dashboard");
frm.add_custom_button(__("Recalculate Batch Qty"), () => {
frm.call({
method: "recalculate_batch_qty",
doc: frm.doc,
freeze: true,
callback: () => {
frm.reload_doc();
},
});
});
} }
}, },
item: (frm) => { item: (frm) => {

View File

@@ -156,6 +156,17 @@ class Batch(Document):
if frappe.db.get_value("Item", self.item, "has_batch_no") == 0: if frappe.db.get_value("Item", self.item, "has_batch_no") == 0:
frappe.throw(_("The selected item cannot have Batch")) frappe.throw(_("The selected item cannot have Batch"))
@frappe.whitelist()
def recalculate_batch_qty(self):
batches = get_batch_qty(batch_no=self.name, item_code=self.item)
batch_qty = 0.0
if batches:
for row in batches:
batch_qty += row.get("qty")
self.db_set("batch_qty", batch_qty)
frappe.msgprint(_("Batch Qty updated to {0}").format(batch_qty), alert=True)
def set_batchwise_valuation(self): def set_batchwise_valuation(self):
from erpnext.stock.utils import get_valuation_method from erpnext.stock.utils import get_valuation_method

View File

@@ -15,7 +15,6 @@ from erpnext.accounts.utils import get_balance_on
from erpnext.controllers.accounts_controller import InvalidQtyError from erpnext.controllers.accounts_controller import InvalidQtyError
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
from erpnext.selling.doctype.sales_order.test_sales_order import ( from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
compare_payment_schedules, compare_payment_schedules,
create_dn_against_so, create_dn_against_so,
make_sales_order, make_sales_order,
@@ -1300,14 +1299,13 @@ class TestDeliveryNote(FrappeTestCase):
frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1) frappe.db.set_single_value("Stock Settings", "use_serial_batch_fields", 1)
frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 0) frappe.db.set_single_value("Accounts Settings", "delete_linked_ledger_entries", 0)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_payment_terms_are_fetched_when_creating_sales_invoice(self): def test_payment_terms_are_fetched_when_creating_sales_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template, create_payment_terms_template,
) )
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
automatically_fetch_payment_terms()
so = make_sales_order(uom="Nos", do_not_save=1) so = make_sales_order(uom="Nos", do_not_save=1)
create_payment_terms_template() create_payment_terms_template()
so.payment_terms_template = "Test Receivable Template" so.payment_terms_template = "Test Receivable Template"
@@ -1327,8 +1325,6 @@ class TestDeliveryNote(FrappeTestCase):
self.assertEqual(so.payment_terms_template, si.payment_terms_template) self.assertEqual(so.payment_terms_template, si.payment_terms_template)
compare_payment_schedules(self, so, si) compare_payment_schedules(self, so, si)
automatically_fetch_payment_terms(enable=0)
def test_returned_qty_in_return_dn(self): def test_returned_qty_in_return_dn(self):
# SO ---> SI ---> DN # SO ---> SI ---> DN
# | # |

View File

@@ -140,7 +140,6 @@ frappe.ui.form.on("Pick List", {
frm.trigger("add_get_items_button"); frm.trigger("add_get_items_button");
if (frm.doc.docstatus === 1) { if (frm.doc.docstatus === 1) {
const status_completed = frm.doc.status === "Completed"; const status_completed = frm.doc.status === "Completed";
frm.set_df_property("locations", "allow_on_submit", status_completed ? 0 : 1);
if (!status_completed) { if (!status_completed) {
frm.add_custom_button(__("Update Current Stock"), () => frm.add_custom_button(__("Update Current Stock"), () =>

View File

@@ -77,7 +77,6 @@
"options": "Work Order" "options": "Work Order"
}, },
{ {
"allow_on_submit": 1,
"fieldname": "locations", "fieldname": "locations",
"fieldtype": "Table", "fieldtype": "Table",
"label": "Item Locations", "label": "Item Locations",
@@ -247,7 +246,7 @@
], ],
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2025-07-23 08:34:32.099673", "modified": "2025-10-03 18:36:52.282355",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Pick List", "name": "Pick List",

View File

@@ -1180,6 +1180,7 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount) self.assertEqual(discrepancy_caused_by_exchange_rate_diff, amount)
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
def test_payment_terms_are_fetched_when_creating_purchase_invoice(self): def test_payment_terms_are_fetched_when_creating_purchase_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
create_payment_terms_template, create_payment_terms_template,
@@ -1190,12 +1191,9 @@ class TestPurchaseReceipt(FrappeTestCase):
make_pr_against_po, make_pr_against_po,
) )
from erpnext.selling.doctype.sales_order.test_sales_order import ( from erpnext.selling.doctype.sales_order.test_sales_order import (
automatically_fetch_payment_terms,
compare_payment_schedules, compare_payment_schedules,
) )
automatically_fetch_payment_terms()
po = create_purchase_order(qty=10, rate=100, do_not_save=1) po = create_purchase_order(qty=10, rate=100, do_not_save=1)
create_payment_terms_template() create_payment_terms_template()
po.payment_terms_template = "Test Receivable Template" po.payment_terms_template = "Test Receivable Template"
@@ -1213,8 +1211,6 @@ class TestPurchaseReceipt(FrappeTestCase):
# self.assertEqual(po.payment_terms_template, pi.payment_terms_template) # self.assertEqual(po.payment_terms_template, pi.payment_terms_template)
compare_payment_schedules(self, po, pi) compare_payment_schedules(self, po, pi)
automatically_fetch_payment_terms(enable=0)
@change_settings("Stock Settings", {"allow_negative_stock": 1}) @change_settings("Stock Settings", {"allow_negative_stock": 1})
def test_neg_to_positive(self): def test_neg_to_positive(self):
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

View File

@@ -823,9 +823,28 @@ frappe.ui.form.on("Stock Entry", {
refresh_field("process_loss_qty"); refresh_field("process_loss_qty");
} }
}, },
set_fg_completed_qty(frm) {
let fg_completed_qty = 0;
frm.doc.items.forEach((item) => {
if (item.is_finished_item) {
fg_completed_qty += flt(item.qty);
}
});
frm.doc.fg_completed_qty = fg_completed_qty;
frm.refresh_field("fg_completed_qty");
},
}); });
frappe.ui.form.on("Stock Entry Detail", { frappe.ui.form.on("Stock Entry Detail", {
items_add(frm, cdt, cdn) {
let item = frappe.get_doc(cdt, cdn);
if (item.is_finished_item) {
frm.events.set_fg_completed_qty(frm);
}
},
set_basic_rate_manually(frm, cdt, cdn) { set_basic_rate_manually(frm, cdt, cdn) {
let row = locals[cdt][cdn]; let row = locals[cdt][cdn];
frm.fields_dict.items.grid.update_docfield_property( frm.fields_dict.items.grid.update_docfield_property(
@@ -837,6 +856,10 @@ frappe.ui.form.on("Stock Entry Detail", {
qty(frm, cdt, cdn) { qty(frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn); frm.events.set_basic_rate(frm, cdt, cdn);
let item = frappe.get_doc(cdt, cdn);
if (item.is_finished_item) {
frm.events.set_fg_completed_qty(frm);
}
}, },
conversion_factor(frm, cdt, cdn) { conversion_factor(frm, cdt, cdn) {

View File

@@ -1356,12 +1356,6 @@ class StockEntry(StockController):
d.item_code, self.work_order d.item_code, self.work_order
) )
) )
elif flt(d.transfer_qty) > flt(self.fg_completed_qty):
frappe.throw(
_("Quantity in row {0} ({1}) must be same as manufactured quantity {2}").format(
d.idx, d.transfer_qty, self.fg_completed_qty
)
)
finished_items.append(d.item_code) finished_items.append(d.item_code)
@@ -2279,10 +2273,12 @@ class StockEntry(StockController):
wo_item_qty = item.transferred_qty or item.required_qty wo_item_qty = item.transferred_qty or item.required_qty
wo_qty_consumed = flt(wo_item_qty) - flt(item.consumed_qty) wo_qty_unconsumed = flt(wo_item_qty) - flt(item.consumed_qty)
wo_qty_to_produce = flt(work_order_qty) - flt(wo.produced_qty) wo_qty_to_produce = flt(work_order_qty) - flt(wo.produced_qty)
bom_qty_per_unit = item.required_qty / wo.qty # per-unit BOM qty
req_qty_each = (wo_qty_consumed) / (wo_qty_to_produce or 1) req_qty_each = (wo_qty_unconsumed) / (wo_qty_to_produce or 1)
req_qty_each = min(req_qty_each, bom_qty_per_unit)
qty = req_qty_each * flt(self.fg_completed_qty) qty = req_qty_each * flt(self.fg_completed_qty)

View File

@@ -1323,9 +1323,18 @@ class TestStockEntry(FrappeTestCase):
posting_date="2021-07-02", # Illegal SE posting_date="2021-07-02", # Illegal SE
purpose="Material Transfer", purpose="Material Transfer",
), ),
dict(
item_code=item_code,
qty=2,
from_warehouse=warehouse_names[0],
to_warehouse=warehouse_names[1],
batch_no=batch_no,
posting_date="2021-07-02", # Illegal SE
purpose="Material Transfer",
),
] ]
self.assertRaises(NegativeStockError, create_stock_entries, sequence_of_entries) self.assertRaises(frappe.ValidationError, create_stock_entries, sequence_of_entries)
@change_settings("Stock Settings", {"allow_negative_stock": 0}) @change_settings("Stock Settings", {"allow_negative_stock": 0})
def test_future_negative_sle_batch(self): def test_future_negative_sle_batch(self):

View File

@@ -73,7 +73,8 @@
"label": "Batch No", "label": "Batch No",
"oldfieldname": "batch_no", "oldfieldname": "batch_no",
"oldfieldtype": "Data", "oldfieldtype": "Data",
"read_only": 1 "read_only": 1,
"search_index": 1
}, },
{ {
"fieldname": "warehouse", "fieldname": "warehouse",
@@ -361,7 +362,7 @@
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2025-04-22 12:37:41.304109", "modified": "2025-10-04 09:59:15.546556",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Ledger Entry", "name": "Stock Ledger Entry",

View File

@@ -105,6 +105,7 @@ class StockSettings(Document):
self.validate_clean_description_html() self.validate_clean_description_html()
self.validate_pending_reposts() self.validate_pending_reposts()
self.validate_stock_reservation() self.validate_stock_reservation()
self.validate_auto_insert_price_list_rate_if_missing()
self.change_precision_for_for_sales() self.change_precision_for_for_sales()
self.change_precision_for_purchase() self.change_precision_for_purchase()
@@ -219,6 +220,23 @@ class StockSettings(Document):
) )
) )
def validate_auto_insert_price_list_rate_if_missing(self):
if (
self.auto_insert_price_list_rate_if_missing
and self.has_value_changed("auto_insert_price_list_rate_if_missing")
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
selling_meta = frappe.get_meta("Selling Settings")
frappe.msgprint(
_(
"You have enabled {0} and {1} in {2}. This can lead to prices from the default price list being inserted in the transaction price list."
).format(
"<i>{}</i>".format(_(self.meta.get_label("auto_insert_price_list_rate_if_missing"))),
"<i>{}</i>".format(_(selling_meta.get_label("fallback_to_default_price_list"))),
frappe.bold(_("Selling Settings")),
)
)
def on_update(self): def on_update(self):
self.toggle_warehouse_field_for_inter_warehouse_transfer() self.toggle_warehouse_field_for_inter_warehouse_transfer()

View File

@@ -98,6 +98,15 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
out.update(get_price_list_rate(args, item)) out.update(get_price_list_rate(args, item))
if (
not out.price_list_rate
and args.transaction_type == "selling"
and frappe.get_single_value("Selling Settings", "fallback_to_default_price_list")
):
fallback_args = args.copy()
fallback_args.price_list = frappe.get_single_value("Selling Settings", "selling_price_list")
out.update(get_price_list_rate(fallback_args, item))
args.customer = current_customer args.customer = current_customer
if args.customer and cint(args.is_pos): if args.customer and cint(args.is_pos):
@@ -695,8 +704,10 @@ def _get_item_tax_template(args, taxes, out=None, for_validate=False):
taxes_with_no_validity = [] taxes_with_no_validity = []
for tax in taxes: for tax in taxes:
tax_company = frappe.get_cached_value("Item Tax Template", tax.item_tax_template, "company") disabled, tax_company = frappe.get_cached_value(
if tax_company == args["company"]: "Item Tax Template", tax.item_tax_template, ["disabled", "company"]
)
if not disabled and tax_company == args["company"]:
if tax.valid_from or tax.maximum_net_rate: if tax.valid_from or tax.maximum_net_rate:
# In purchase Invoice first preference will be given to supplier invoice date # In purchase Invoice first preference will be given to supplier invoice date
# if supplier date is not present then posting date # if supplier date is not present then posting date
@@ -1507,7 +1518,7 @@ def get_valuation_rate(item_code, company, warehouse=None):
return frappe.db.get_value( return frappe.db.get_value(
"Bin", {"item_code": item_code, "warehouse": warehouse}, ["valuation_rate"], as_dict=True "Bin", {"item_code": item_code, "warehouse": warehouse}, ["valuation_rate"], as_dict=True
) or {"valuation_rate": 0} ) or {"valuation_rate": item.get("valuation_rate") or 0}
elif not item.get("is_stock_item"): elif not item.get("is_stock_item"):
pi_item = frappe.qb.DocType("Purchase Invoice Item") pi_item = frappe.qb.DocType("Purchase Invoice Item")

View File

@@ -24,6 +24,11 @@ frappe.query_reports["Stock Qty vs Serial No Count"] = {
}, },
reqd: 1, reqd: 1,
}, },
{
fieldname: "show_disables_items",
label: __("Show Disabled Items"),
fieldtype: "Check",
},
], ],
formatter: function (value, row, column, data, default_formatter) { formatter: function (value, row, column, data, default_formatter) {

View File

@@ -9,7 +9,7 @@ from frappe import _
def execute(filters=None): def execute(filters=None):
validate_warehouse(filters) validate_warehouse(filters)
columns = get_columns() columns = get_columns()
data = get_data(filters.warehouse) data = get_data(filters.warehouse, filters.show_disables_items)
return columns, data return columns, data
@@ -38,12 +38,13 @@ def get_columns():
return columns return columns
def get_data(warehouse): def get_data(warehouse, show_disables_items):
filters = {"has_serial_no": True}
if not show_disables_items:
filters["disabled"] = False
serial_item_list = frappe.get_all( serial_item_list = frappe.get_all(
"Item", "Item",
filters={ filters=filters,
"has_serial_no": True,
},
fields=["item_code", "item_name"], fields=["item_code", "item_name"],
) )

View File

@@ -580,11 +580,13 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
else: else:
self.serial_no_incoming_rate = defaultdict(float) self.serial_no_incoming_rate = defaultdict(float)
self.stock_value_change = 0.0 self.stock_value_change = 0.0
self.old_serial_nos = []
serial_nos = self.get_serial_nos() serial_nos = self.get_serial_nos()
for serial_no in serial_nos: for serial_no in serial_nos:
incoming_rate = self.get_incoming_rate_from_bundle(serial_no) incoming_rate = self.get_incoming_rate_from_bundle(serial_no)
if incoming_rate is None: if incoming_rate is None:
self.old_serial_nos.append(serial_no)
continue continue
self.stock_value_change += incoming_rate self.stock_value_change += incoming_rate
@@ -1363,11 +1365,12 @@ def get_batch_current_qty(batch):
def throw_negative_batch_validation(batch_no, qty): def throw_negative_batch_validation(batch_no, qty):
frappe.throw( frappe.msgprint(
_("The Batch {0} has negative quantity {1}. Please correct the quantity.").format( _(
bold(batch_no), bold(qty) "The Batch {0} has negative batch quantity {1}. To fix this, go to the batch and click on Recalculate Batch Qty. If the issue still persists, create an inward entry."
), ).format(bold(get_link_to_form("Batch", batch_no)), bold(qty)),
title=_("Negative Batch Quantity"), title=_("Warning!"),
indicator="orange",
) )

View File

@@ -1010,13 +1010,12 @@ class update_entries_after:
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle): if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
return return
if self.args.get("sle_id") and sle.actual_qty < 0: if sle.actual_qty < 0 and (
doc = frappe.db.get_value( sle.voucher_type in ["Stock Reconciliation", "Asset Capitalization"]
"Serial and Batch Bundle", or not frappe.db.get_value(sle.voucher_type, sle.voucher_no, "is_return")
sle.serial_and_batch_bundle, ):
["total_amount", "total_qty"], doc = frappe._dict({})
as_dict=1, self.update_serial_batch_no_valuation(sle, doc, prev_sle=self.wh_data)
)
else: else:
doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle) doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
doc.set_incoming_rate( doc.set_incoming_rate(
@@ -1040,6 +1039,88 @@ class update_entries_after:
self.wh_data.qty_after_transaction, self.flt_precision self.wh_data.qty_after_transaction, self.flt_precision
) )
def update_serial_batch_no_valuation(self, sle, doc, prev_sle=None):
from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation
sabb_data = get_serial_from_sabb(sle.serial_and_batch_bundle)
if not sabb_data:
doc.update({"total_amount": 0.0, "total_qty": 0.0, "avg_rate": 0.0})
return
serial_nos = [d.serial_no for d in sabb_data if d.serial_no]
if serial_nos:
sle["serial_nos"] = get_serial_nos_data(",".join(serial_nos))
sn_obj = SerialNoValuation(
sle=sle,
item_code=self.item_code,
warehouse=sle.warehouse,
)
else:
sle["batch_nos"] = {row.batch_no: row for row in sabb_data if row.batch_no}
sn_obj = BatchNoValuation(
sle=sle,
item_code=self.item_code,
warehouse=sle.warehouse,
prev_sle=prev_sle,
)
tot_amt = 0.0
total_qty = 0.0
avg_rate = 0.0
for d in sabb_data:
incoming_rate = get_incoming_rate_for_serial_and_batch(self.item_code, d, sn_obj)
if flt(incoming_rate, self.currency_precision) == flt(
d.valuation_rate, self.currency_precision
) and not getattr(d, "stock_queue", None):
continue
amount = incoming_rate * flt(d.qty)
tot_amt += flt(amount)
total_qty += flt(d.qty)
values_to_update = {
"incoming_rate": incoming_rate,
"stock_value_difference": amount,
}
if d.stock_queue:
values_to_update["stock_queue"] = d.stock_queue
frappe.db.set_value(
"Serial and Batch Entry",
d.name,
values_to_update,
update_modified=False,
)
if total_qty:
avg_rate = tot_amt / total_qty
doc.update(
{
"total_amount": tot_amt,
"total_qty": total_qty,
"avg_rate": avg_rate,
}
)
frappe.db.set_value(
"Serial and Batch Bundle",
sle.serial_and_batch_bundle,
{
"total_qty": total_qty,
"avg_rate": avg_rate,
"total_amount": tot_amt,
},
update_modified=False,
)
for key in ("serial_nos", "batch_nos"):
if key in sle:
del sle[key]
def get_outgoing_rate_for_batched_item(self, sle): def get_outgoing_rate_for_batched_item(self, sle):
if self.wh_data.qty_after_transaction == 0: if self.wh_data.qty_after_transaction == 0:
return 0 return 0
@@ -2297,3 +2378,45 @@ def is_transfer_stock_entry(voucher_no):
purpose = frappe.get_cached_value("Stock Entry", voucher_no, "purpose") purpose = frappe.get_cached_value("Stock Entry", voucher_no, "purpose")
return purpose in ["Material Transfer", "Material Transfer for Manufacture", "Send to Subcontractor"] return purpose in ["Material Transfer", "Material Transfer for Manufacture", "Send to Subcontractor"]
@frappe.request_cache
def get_serial_from_sabb(serial_and_batch_bundle):
return frappe.get_all(
"Serial and Batch Entry",
filters={"parent": serial_and_batch_bundle},
fields=["serial_no", "batch_no", "name", "qty", "incoming_rate"],
order_by="idx",
)
def get_incoming_rate_for_serial_and_batch(item_code, row, sn_obj):
if row.serial_no:
return abs(sn_obj.serial_no_incoming_rate.get(row.serial_no, 0.0))
else:
stock_queue = []
if hasattr(sn_obj, "stock_queue") and sn_obj.stock_queue:
stock_queue = parse_json(sn_obj.stock_queue)
val_method = get_valuation_method(item_code)
actual_qty = row.qty
if stock_queue and val_method == "FIFO" and row.batch_no in sn_obj.non_batchwise_valuation_batches:
if actual_qty < 0:
stock_queue = FIFOValuation(stock_queue)
_prev_qty, prev_stock_value = stock_queue.get_total_stock_and_value()
stock_queue.remove_stock(qty=abs(actual_qty))
_qty, stock_value = stock_queue.get_total_stock_and_value()
stock_value_difference = stock_value - prev_stock_value
incoming_rate = abs(flt(stock_value_difference) / abs(flt(actual_qty)))
stock_queue = stock_queue.state
else:
incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(row.batch_no)))
stock_queue.append([row.qty, incoming_rate])
row.stock_queue = json.dumps(stock_queue)
else:
incoming_rate = abs(flt(sn_obj.batch_avg_rate.get(row.batch_no)))
return incoming_rate

View File

@@ -233,6 +233,8 @@
"options": "Project" "options": "Project"
}, },
{ {
"fetch_from": "email_account.company",
"fetch_if_empty": 1,
"fieldname": "company", "fieldname": "company",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Company", "label": "Company",
@@ -391,7 +393,7 @@
"icon": "fa fa-ticket", "icon": "fa fa-ticket",
"idx": 7, "idx": 7,
"links": [], "links": [],
"modified": "2025-02-18 21:18:52.797745", "modified": "2025-09-25 11:10:53.556731",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Support", "module": "Support",
"name": "Issue", "name": "Issue",