mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 20:05:09 +00:00
Merge pull request #49942 from frappe/version-15-hotfix
chore: release v15
This commit is contained in:
@@ -10,14 +10,19 @@
|
||||
"description",
|
||||
"section_break_4",
|
||||
"due_date",
|
||||
"invoice_portion",
|
||||
"mode_of_payment",
|
||||
"column_break_5",
|
||||
"invoice_portion",
|
||||
"due_date_based_on",
|
||||
"credit_days",
|
||||
"credit_months",
|
||||
"section_break_6",
|
||||
"discount_type",
|
||||
"discount_date",
|
||||
"column_break_9",
|
||||
"discount",
|
||||
"discount_type",
|
||||
"column_break_9",
|
||||
"discount_validity_based_on",
|
||||
"discount_validity",
|
||||
"section_break_9",
|
||||
"payment_amount",
|
||||
"outstanding",
|
||||
@@ -172,12 +177,50 @@
|
||||
"label": "Paid Amount (Company Currency)",
|
||||
"options": "Company:company:default_currency",
|
||||
"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,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-03-11 11:06:51.792982",
|
||||
"modified": "2025-07-31 08:38:25.820701",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Schedule",
|
||||
@@ -189,4 +232,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,27 @@ class PaymentSchedule(Document):
|
||||
base_outstanding: DF.Currency
|
||||
base_paid_amount: DF.Currency
|
||||
base_payment_amount: DF.Currency
|
||||
credit_days: DF.Int
|
||||
credit_months: DF.Int
|
||||
description: DF.SmallText | None
|
||||
discount: DF.Float
|
||||
discount_date: DF.Date | None
|
||||
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
|
||||
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
|
||||
mode_of_payment: DF.Link | None
|
||||
outstanding: DF.Currency
|
||||
|
||||
@@ -161,4 +161,4 @@
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2150,19 +2150,16 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
rate = flt(sle.stock_value_difference) / flt(sle.actual_qty)
|
||||
self.assertAlmostEqual(rate, 500)
|
||||
|
||||
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
|
||||
def test_payment_allocation_for_payment_terms(self):
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import (
|
||||
create_pr_against_po,
|
||||
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 (
|
||||
make_purchase_invoice as make_pi_from_pr,
|
||||
)
|
||||
|
||||
automatically_fetch_payment_terms()
|
||||
frappe.db.set_value(
|
||||
"Payment Terms Template",
|
||||
"_Test Payment Term Template",
|
||||
@@ -2188,7 +2185,6 @@ class TestPurchaseInvoice(FrappeTestCase, StockTestMixin):
|
||||
pi = make_pi_from_pr(pr.name)
|
||||
self.assertEqual(pi.payment_schedule[0].payment_amount, 1000)
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
frappe.db.set_value(
|
||||
"Payment Terms Template",
|
||||
"_Test Payment Term Template",
|
||||
|
||||
@@ -483,18 +483,23 @@ class Subscription(Document):
|
||||
|
||||
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`
|
||||
"""
|
||||
if prorate is None:
|
||||
prorate = False
|
||||
|
||||
prorate_factor = 1
|
||||
if prorate:
|
||||
prorate_factor = get_prorata_factor(
|
||||
self.current_invoice_end,
|
||||
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 = []
|
||||
@@ -511,33 +516,19 @@ class Subscription(Document):
|
||||
|
||||
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_code": item_code,
|
||||
"qty": plan.qty,
|
||||
"rate": get_plan_rate(
|
||||
plan.plan,
|
||||
plan.qty,
|
||||
party,
|
||||
self.current_invoice_start,
|
||||
self.current_invoice_end,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
}
|
||||
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,
|
||||
prorate_factor,
|
||||
),
|
||||
"cost_center": plan_doc.cost_center,
|
||||
}
|
||||
|
||||
if deferred:
|
||||
item.update(
|
||||
|
||||
@@ -8,6 +8,7 @@ from frappe.utils.data import (
|
||||
add_days,
|
||||
add_months,
|
||||
add_to_date,
|
||||
add_years,
|
||||
cint,
|
||||
date_diff,
|
||||
flt,
|
||||
@@ -555,6 +556,33 @@ class TestSubscription(FrappeTestCase):
|
||||
subscription.reload()
|
||||
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():
|
||||
create_plan(plan_name="_Test Plan Name", cost=900, currency="INR")
|
||||
|
||||
@@ -1270,7 +1270,7 @@ class ReceivablePayableReport:
|
||||
def setup_ageing_columns(self):
|
||||
# for charts
|
||||
self.ageing_column_labels = []
|
||||
ranges = [*self.ranges, "Above"]
|
||||
ranges = [*self.ranges, _("Above")]
|
||||
|
||||
prev_range_value = 0
|
||||
for idx, curr_range_value in enumerate(ranges):
|
||||
|
||||
@@ -171,7 +171,7 @@ class AccountsReceivableSummary(ReceivablePayableReport):
|
||||
self.add_column(_("Difference"), fieldname="diff")
|
||||
|
||||
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:
|
||||
self.add_column(label=_("Future Payment Amount"), fieldname="future_amount")
|
||||
|
||||
@@ -52,7 +52,7 @@ frappe.query_reports["Financial Ratios"] = {
|
||||
},
|
||||
],
|
||||
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)) {
|
||||
value = $(`<span>${value}</span>`);
|
||||
@@ -60,7 +60,7 @@ frappe.query_reports["Financial Ratios"] = {
|
||||
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";
|
||||
}
|
||||
|
||||
|
||||
@@ -147,9 +147,9 @@ def get_gl_data(filters, period_list, years):
|
||||
|
||||
def add_liquidity_ratios(data, years, current_asset, current_liability, quick_asset):
|
||||
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:
|
||||
row = {
|
||||
@@ -165,13 +165,13 @@ def add_solvency_ratios(
|
||||
data, years, total_asset, total_liability, net_sales, cogs, total_income, total_expense
|
||||
):
|
||||
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"}
|
||||
gross_profit_ratio = {"ratio": "Gross Profit Ratio"}
|
||||
net_profit_ratio = {"ratio": "Net Profit Ratio"}
|
||||
return_on_asset_ratio = {"ratio": "Return on Asset Ratio"}
|
||||
return_on_equity_ratio = {"ratio": "Return on Equity Ratio"}
|
||||
debt_equity_ratio = {"ratio": _("Debt Equity Ratio")}
|
||||
gross_profit_ratio = {"ratio": _("Gross Profit Ratio")}
|
||||
net_profit_ratio = {"ratio": _("Net Profit Ratio")}
|
||||
return_on_asset_ratio = {"ratio": _("Return on Asset Ratio")}
|
||||
return_on_equity_ratio = {"ratio": _("Return on Equity Ratio")}
|
||||
|
||||
for year in years:
|
||||
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):
|
||||
precision = frappe.db.get_single_value("System Settings", "float_precision")
|
||||
data.append({"ratio": "Turnover Ratios"})
|
||||
data.append({"ratio": _("Turnover Ratios")})
|
||||
|
||||
avg_data = {}
|
||||
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 = [
|
||||
["Fixed Asset Turnover Ratio", net_sales, total_asset],
|
||||
["Debtor Turnover Ratio", net_sales, avg_debtors],
|
||||
["Creditor Turnover Ratio", direct_expense, avg_creditors],
|
||||
["Inventory Turnover Ratio", cogs, avg_stock],
|
||||
[_("Fixed Asset Turnover Ratio"), net_sales, total_asset],
|
||||
[_("Debtor Turnover Ratio"), net_sales, avg_debtors],
|
||||
[_("Creditor Turnover Ratio"), direct_expense, avg_creditors],
|
||||
[_("Inventory Turnover Ratio"), cogs, avg_stock],
|
||||
]
|
||||
for ratio in ratio_data:
|
||||
row = {
|
||||
|
||||
@@ -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 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 = []
|
||||
accounts_to_show = set()
|
||||
|
||||
for d in data:
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
columns[0] = "Sales Invoice:Link/Item:300"
|
||||
# removing Item Code and Item Name columns
|
||||
del columns[4:6]
|
||||
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]
|
||||
else:
|
||||
del columns[5:7]
|
||||
|
||||
total_base_amount = 0
|
||||
total_buying_amount = 0
|
||||
@@ -275,7 +280,7 @@ def get_columns(group_wise_columns, filters):
|
||||
"label": _("Posting Date"),
|
||||
"fieldname": "posting_date",
|
||||
"fieldtype": "Date",
|
||||
"width": 100,
|
||||
"width": 120,
|
||||
},
|
||||
"posting_time": {
|
||||
"label": _("Posting Time"),
|
||||
|
||||
@@ -947,19 +947,28 @@ def update_accounting_ledgers_after_reference_removal(
|
||||
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
|
||||
if ref_doc.doctype in ("Sales Invoice", "Purchase Invoice"):
|
||||
ref_doc.set("advances", [])
|
||||
adv_type = qb.DocType(f"{ref_doc.doctype} Advance")
|
||||
qb.from_(adv_type).delete().where(adv_type.parent == ref_doc.name).run()
|
||||
row_names = []
|
||||
for adv in ref_doc.get("advances") or []:
|
||||
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):
|
||||
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)
|
||||
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(
|
||||
@@ -1026,7 +1035,6 @@ def remove_ref_doc_link_from_pe(
|
||||
query = query.where(per.parent == payment_name)
|
||||
|
||||
reference_rows = query.run(as_dict=True)
|
||||
|
||||
if not reference_rows:
|
||||
return
|
||||
|
||||
|
||||
@@ -790,17 +790,33 @@ frappe.ui.form.on("Asset Finance Book", {
|
||||
});
|
||||
|
||||
erpnext.asset.scrap_asset = function (frm) {
|
||||
frappe.confirm(__("Do you really want to scrap this asset?"), function () {
|
||||
frappe.call({
|
||||
args: {
|
||||
asset_name: frm.doc.name,
|
||||
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,
|
||||
},
|
||||
method: "erpnext.assets.doctype.asset.depreciation.scrap_asset",
|
||||
callback: function (r) {
|
||||
cur_frm.reload_doc();
|
||||
},
|
||||
});
|
||||
],
|
||||
size: "medium",
|
||||
primary_action_label: "Submit",
|
||||
primary_action(values) {
|
||||
frappe.call({
|
||||
args: {
|
||||
asset_name: frm.doc.name,
|
||||
scrap_date: values.scrap_date,
|
||||
},
|
||||
method: "erpnext.assets.doctype.asset.depreciation.scrap_asset",
|
||||
callback: function (r) {
|
||||
frm.reload_doc();
|
||||
scrap_dialog.hide();
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
scrap_dialog.show();
|
||||
};
|
||||
|
||||
erpnext.asset.restore_asset = function (frm) {
|
||||
|
||||
@@ -394,7 +394,7 @@ def get_comma_separated_links(names, doctype):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def scrap_asset(asset_name):
|
||||
def scrap_asset(asset_name, scrap_date=None):
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
if asset.docstatus != 1:
|
||||
@@ -402,7 +402,11 @@ def scrap_asset(asset_name):
|
||||
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))
|
||||
|
||||
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(
|
||||
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))
|
||||
|
||||
|
||||
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()
|
||||
def restore_asset(asset_name):
|
||||
asset = frappe.get_doc("Asset", asset_name)
|
||||
|
||||
@@ -15,6 +15,7 @@ from frappe.utils import (
|
||||
is_last_day_of_the_month,
|
||||
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.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
@@ -219,6 +220,31 @@ class TestAsset(AssetSetup):
|
||||
)
|
||||
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)
|
||||
asset.load_from_db()
|
||||
first_asset_depr_schedule.load_from_db()
|
||||
|
||||
@@ -142,10 +142,18 @@ class AssetMovement(Document):
|
||||
def update_asset_location_and_custodian(self, asset_id, location, employee):
|
||||
asset = frappe.get_doc("Asset", asset_id)
|
||||
|
||||
updates = {}
|
||||
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:
|
||||
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):
|
||||
if location and employee:
|
||||
|
||||
@@ -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.debit != 0)
|
||||
.where(gle.is_cancelled == 0)
|
||||
.where(gle.is_opening == "No")
|
||||
.where(company.name == filters.company)
|
||||
.where(asset.docstatus == 1)
|
||||
)
|
||||
|
||||
@@ -541,12 +541,8 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
self.assertRaises(frappe.ValidationError, pr.submit)
|
||||
self.assertRaises(frappe.ValidationError, pi.submit)
|
||||
|
||||
@change_settings("Accounts Settings", {"automatically_fetch_payment_terms": 1})
|
||||
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)
|
||||
|
||||
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(pi.payment_schedule[1].payment_amount, 2500.0)
|
||||
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):
|
||||
from erpnext.stock.utils import InvalidWarehouseCompany
|
||||
@@ -718,6 +713,7 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
)
|
||||
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):
|
||||
po = create_purchase_order(do_not_save=1)
|
||||
po.payment_terms_template = "_Test Payment Term Template"
|
||||
@@ -905,18 +901,16 @@ class TestPurchaseOrder(FrappeTestCase):
|
||||
bo.load_from_db()
|
||||
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):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
|
||||
create_payment_terms_template,
|
||||
)
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import (
|
||||
automatically_fetch_payment_terms,
|
||||
compare_payment_schedules,
|
||||
)
|
||||
|
||||
automatically_fetch_payment_terms()
|
||||
|
||||
po = create_purchase_order(qty=10, rate=100, do_not_save=1)
|
||||
create_payment_terms_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)
|
||||
compare_payment_schedules(self, po, pi)
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
def test_internal_transfer_flow(self):
|
||||
from erpnext.accounts.doctype.cost_center.test_cost_center import create_cost_center
|
||||
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
|
||||
|
||||
@@ -109,21 +109,9 @@ frappe.ui.form.on("Supplier", {
|
||||
__("View")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Bank Account"),
|
||||
function () {
|
||||
erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
frm.add_custom_button(__("Bank Account"), () => frm.make_methods["Bank Account"](), __("Create"));
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Pricing Rule"),
|
||||
function () {
|
||||
erpnext.utils.make_pricing_rule(frm.doc.doctype, frm.doc.name);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
frm.add_custom_button(__("Pricing Rule"), () => frm.make_methods["Pricing Rule"](), __("Create"));
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Get Supplier Group Details"),
|
||||
|
||||
@@ -231,6 +231,11 @@ class AccountsController(TransactionBase):
|
||||
|
||||
self.validate_date_with_fiscal_year()
|
||||
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()
|
||||
# validate inter company transaction rate
|
||||
@@ -282,11 +287,6 @@ class AccountsController(TransactionBase):
|
||||
|
||||
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.set_inter_company_account()
|
||||
|
||||
@@ -2558,6 +2558,7 @@ class AccountsController(TransactionBase):
|
||||
|
||||
self.payment_schedule = []
|
||||
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:
|
||||
payment_schedule = {
|
||||
@@ -2570,6 +2571,17 @@ class AccountsController(TransactionBase):
|
||||
}
|
||||
|
||||
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(
|
||||
grand_total * flt(payment_schedule["invoice_portion"]) / 100,
|
||||
schedule.precision("payment_amount"),
|
||||
@@ -3369,14 +3381,27 @@ def get_payment_term_details(
|
||||
term = frappe.get_doc("Payment Term", term)
|
||||
else:
|
||||
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.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.mode_of_payment = term.mode_of_payment
|
||||
term_details.base_outstanding = term_details.base_payment_amount
|
||||
|
||||
if 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
|
||||
date = bill_date or posting_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":
|
||||
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":
|
||||
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
|
||||
|
||||
|
||||
@@ -3407,11 +3432,11 @@ def get_discount_date(term, posting_date=None, bill_date=None):
|
||||
discount_validity = None
|
||||
date = bill_date or posting_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":
|
||||
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":
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -313,7 +313,7 @@ def get_project_name(doctype, txt, searchfield, start, page_len, filters):
|
||||
if filters:
|
||||
if filters.get("customer"):
|
||||
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"):
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"code_list",
|
||||
"canonical_uri",
|
||||
"title",
|
||||
"common_code",
|
||||
"description",
|
||||
@@ -71,10 +72,17 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Description",
|
||||
"max_height": "60px"
|
||||
},
|
||||
{
|
||||
"fetch_from": "code_list.canonical_uri",
|
||||
"fieldname": "canonical_uri",
|
||||
"fieldtype": "Data",
|
||||
"label": "Canonical URI"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"links": [],
|
||||
"modified": "2024-11-06 07:46:17.175687",
|
||||
"modified": "2025-10-04 17:22:28.176155",
|
||||
"modified_by": "Administrator",
|
||||
"module": "EDI",
|
||||
"name": "Common Code",
|
||||
@@ -94,10 +102,11 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "common_code,description",
|
||||
"show_title_field_in_link": 1,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"title_field": "title"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class CommonCode(Document):
|
||||
|
||||
additional_data: DF.Code | None
|
||||
applies_to: DF.Table[DynamicLink]
|
||||
canonical_uri: DF.Data | None
|
||||
code_list: DF.Link
|
||||
common_code: DF.Data
|
||||
description: DF.SmallText | None
|
||||
|
||||
@@ -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)):
|
||||
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)
|
||||
|
||||
reverse_tree = OrderedDict(reversed(list(production_item_wise_rm.items())))
|
||||
|
||||
@@ -748,6 +748,7 @@ class ProductionPlan(Document):
|
||||
work_order_data = {
|
||||
"wip_warehouse": default_warehouses.get("wip_warehouse"),
|
||||
"fg_warehouse": default_warehouses.get("fg_warehouse"),
|
||||
"scrap_warehouse": default_warehouses.get("scrap_warehouse"),
|
||||
"company": self.get("company"),
|
||||
}
|
||||
|
||||
@@ -1821,7 +1822,7 @@ def get_sub_assembly_items(
|
||||
|
||||
|
||||
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):
|
||||
row[field] = default_warehouses.get(field)
|
||||
|
||||
|
||||
@@ -2828,6 +2828,111 @@ class TestWorkOrder(FrappeTestCase):
|
||||
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):
|
||||
from erpnext.stock.doctype.stock_entry.test_stock_entry import (
|
||||
|
||||
@@ -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.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.create_asset_depreciation_schedules_from_assets
|
||||
|
||||
6
erpnext/patches/v16_0/create_company_custom_fields.py
Normal file
6
erpnext/patches/v16_0/create_company_custom_fields.py
Normal 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()
|
||||
@@ -173,8 +173,11 @@ erpnext.buying = {
|
||||
callback: (r) => {
|
||||
this.frm.set_value("billing_address", r.message.primary_address || "");
|
||||
|
||||
if(!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) return;
|
||||
this.frm.set_value("shipping_address", r.message.shipping_address || "");
|
||||
if (!frappe.meta.has_field(this.frm.doc.doctype, "shipping_address")) return;
|
||||
this.frm.set_value(
|
||||
"shipping_address",
|
||||
r.message.shipping_address || this.frm.doc.shipping_address || ""
|
||||
);
|
||||
},
|
||||
});
|
||||
erpnext.utils.set_letter_head(this.frm)
|
||||
|
||||
@@ -76,9 +76,10 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
|
||||
|
||||
// Update paid amount on return/debit note creation
|
||||
if (
|
||||
this.frm.doc.doctype === "Purchase Invoice"
|
||||
&& this.frm.doc.is_return
|
||||
&& (this.frm.doc.grand_total > this.frm.doc.paid_amount)
|
||||
this.frm.doc.doctype === "Purchase Invoice" &&
|
||||
this.frm.doc.is_return &&
|
||||
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"));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// be removed as due_date is automatically changed based on payment terms
|
||||
if (doc.doctype !== cdt) {
|
||||
// triggered by change to the due_date field in payment schedule child table
|
||||
// do nothing to avoid infinite clearing loop
|
||||
// Remove fields as due_date is auto-managed by payment terms
|
||||
const row = locals[cdt][cdn];
|
||||
["due_date_based_on", "credit_days", "credit_months"].forEach((field) => {
|
||||
row[field] = "";
|
||||
});
|
||||
this.frm.refresh_field("payment_schedule");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2579,6 +2592,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
'valid_from': ["<=", doc.transaction_date || doc.bill_date || doc.posting_date],
|
||||
'item_group': item.item_group,
|
||||
"base_net_rate": item.base_net_rate,
|
||||
"disabled": 0,
|
||||
}
|
||||
|
||||
if (doc.tax_category)
|
||||
@@ -2620,6 +2634,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
payment_term(doc, cdt, cdn) {
|
||||
const me = this;
|
||||
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) {
|
||||
frappe.call({
|
||||
method: "erpnext.controllers.accounts_controller.get_payment_term_details",
|
||||
@@ -2632,14 +2657,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
},
|
||||
callback: function(r) {
|
||||
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();
|
||||
me.update_payment_schedule_grid_labels(company_currency);
|
||||
const company_currency = me.get_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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from frappe.tests.utils import FrappeTestCase, change_settings
|
||||
from frappe.utils import add_days, flt, getdate, nowdate, today
|
||||
|
||||
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 (
|
||||
make_maintenance_schedule,
|
||||
)
|
||||
@@ -1680,14 +1680,13 @@ class TestSalesOrder(AccountsTestMixin, FrappeTestCase):
|
||||
so.load_from_db()
|
||||
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):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
|
||||
create_payment_terms_template,
|
||||
)
|
||||
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)
|
||||
create_payment_terms_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)
|
||||
compare_payment_schedules(self, so, si)
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
def test_zero_amount_sales_order_billing_status(self):
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
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(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.payment_amount, doc2.payment_schedule[index].payment_amount)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"role_to_override_stop_action",
|
||||
"column_break_15",
|
||||
"maintain_same_sales_rate",
|
||||
"fallback_to_default_price_list",
|
||||
"editable_price_list_rate",
|
||||
"validate_selling_price",
|
||||
"editable_bundle_item_rates",
|
||||
@@ -216,6 +217,12 @@
|
||||
"fieldname": "allow_zero_qty_in_quotation",
|
||||
"fieldtype": "Check",
|
||||
"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,
|
||||
@@ -224,7 +231,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-06 15:23:14.332971",
|
||||
"modified": "2025-09-23 21:10:14.826653",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Selling Settings",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.custom.doctype.property_setter.property_setter import make_property_setter
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint
|
||||
@@ -33,6 +34,7 @@ class SellingSettings(Document):
|
||||
editable_bundle_item_rates: DF.Check
|
||||
editable_price_list_rate: DF.Check
|
||||
enable_discount_accounting: DF.Check
|
||||
fallback_to_default_price_list: DF.Check
|
||||
hide_tax_id: DF.Check
|
||||
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
|
||||
maintain_same_sales_rate: DF.Check
|
||||
@@ -69,16 +71,35 @@ class SellingSettings(Document):
|
||||
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):
|
||||
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
|
||||
for doctype in ("Sales Order", "Sales Invoice", "Delivery Note"):
|
||||
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(
|
||||
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):
|
||||
|
||||
@@ -25,6 +25,7 @@ def after_install():
|
||||
|
||||
set_single_defaults()
|
||||
create_print_setting_custom_fields()
|
||||
create_custom_company_links()
|
||||
add_all_roles_to("Administrator")
|
||||
create_default_success_action()
|
||||
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():
|
||||
for success_action in get_default_success_action():
|
||||
if not frappe.db.exists("Success Action", success_action.get("ref_doctype")):
|
||||
|
||||
@@ -7,15 +7,16 @@ from frappe.query_builder.functions import CombineDatetime, Sum
|
||||
from frappe.utils import flt, nowtime
|
||||
from frappe.utils.deprecations import deprecated
|
||||
from pypika import Order
|
||||
from pypika.functions import Coalesce
|
||||
|
||||
|
||||
class DeprecatedSerialNoValuation:
|
||||
@deprecated
|
||||
def calculate_stock_value_from_deprecarated_ledgers(self):
|
||||
if not has_sle_for_serial_nos(self.sle.item_code):
|
||||
return
|
||||
serial_nos = []
|
||||
if hasattr(self, "old_serial_nos"):
|
||||
serial_nos = self.old_serial_nos
|
||||
|
||||
serial_nos = self.get_filterd_serial_nos()
|
||||
if not serial_nos:
|
||||
return
|
||||
|
||||
@@ -25,17 +26,6 @@ class DeprecatedSerialNoValuation:
|
||||
|
||||
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
|
||||
def get_incoming_value_for_serial_nos(self, serial_nos):
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
@@ -81,20 +71,6 @@ class DeprecatedSerialNoValuation:
|
||||
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:
|
||||
@deprecated
|
||||
def calculate_avg_rate_from_deprecarated_ledgers(self):
|
||||
@@ -197,9 +173,15 @@ class DeprecatedBatchNoValuation:
|
||||
|
||||
@deprecated
|
||||
def set_balance_value_for_non_batchwise_valuation_batches(self):
|
||||
self.last_sle = self.get_last_sle_for_non_batch()
|
||||
if hasattr(self, "prev_sle"):
|
||||
self.last_sle = self.prev_sle
|
||||
else:
|
||||
self.last_sle = self.get_last_sle_for_non_batch()
|
||||
|
||||
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_bundle()
|
||||
@@ -293,10 +275,7 @@ class DeprecatedBatchNoValuation:
|
||||
query = query.where(sle.name != self.sle.name)
|
||||
|
||||
if self.sle.serial_and_batch_bundle:
|
||||
query = query.where(
|
||||
(sle.serial_and_batch_bundle != self.sle.serial_and_batch_bundle)
|
||||
| (sle.serial_and_batch_bundle.isnull())
|
||||
)
|
||||
query = query.where(Coalesce(sle.serial_and_batch_bundle, "") != self.sle.serial_and_batch_bundle)
|
||||
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ frappe.ui.form.on("Batch", {
|
||||
frappe.set_route("query-report", "Stock Ledger");
|
||||
});
|
||||
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) => {
|
||||
|
||||
@@ -156,6 +156,17 @@ class Batch(Document):
|
||||
if frappe.db.get_value("Item", self.item, "has_batch_no") == 0:
|
||||
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):
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ from erpnext.accounts.utils import get_balance_on
|
||||
from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import (
|
||||
automatically_fetch_payment_terms,
|
||||
compare_payment_schedules,
|
||||
create_dn_against_so,
|
||||
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("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):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
|
||||
create_payment_terms_template,
|
||||
)
|
||||
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)
|
||||
create_payment_terms_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)
|
||||
compare_payment_schedules(self, so, si)
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
def test_returned_qty_in_return_dn(self):
|
||||
# SO ---> SI ---> DN
|
||||
# |
|
||||
|
||||
@@ -140,7 +140,6 @@ frappe.ui.form.on("Pick List", {
|
||||
frm.trigger("add_get_items_button");
|
||||
if (frm.doc.docstatus === 1) {
|
||||
const status_completed = frm.doc.status === "Completed";
|
||||
frm.set_df_property("locations", "allow_on_submit", status_completed ? 0 : 1);
|
||||
|
||||
if (!status_completed) {
|
||||
frm.add_custom_button(__("Update Current Stock"), () =>
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
"options": "Work Order"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "locations",
|
||||
"fieldtype": "Table",
|
||||
"label": "Item Locations",
|
||||
@@ -247,7 +246,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-23 08:34:32.099673",
|
||||
"modified": "2025-10-03 18:36:52.282355",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
@@ -1180,6 +1180,7 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
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):
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
|
||||
create_payment_terms_template,
|
||||
@@ -1190,12 +1191,9 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
make_pr_against_po,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import (
|
||||
automatically_fetch_payment_terms,
|
||||
compare_payment_schedules,
|
||||
)
|
||||
|
||||
automatically_fetch_payment_terms()
|
||||
|
||||
po = create_purchase_order(qty=10, rate=100, do_not_save=1)
|
||||
create_payment_terms_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)
|
||||
compare_payment_schedules(self, po, pi)
|
||||
|
||||
automatically_fetch_payment_terms(enable=0)
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 1})
|
||||
def test_neg_to_positive(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
@@ -823,9 +823,28 @@ frappe.ui.form.on("Stock Entry", {
|
||||
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", {
|
||||
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) {
|
||||
let row = locals[cdt][cdn];
|
||||
frm.fields_dict.items.grid.update_docfield_property(
|
||||
@@ -837,6 +856,10 @@ frappe.ui.form.on("Stock Entry Detail", {
|
||||
|
||||
qty(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) {
|
||||
|
||||
@@ -1356,12 +1356,6 @@ class StockEntry(StockController):
|
||||
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)
|
||||
|
||||
@@ -2279,10 +2273,12 @@ class StockEntry(StockController):
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
|
||||
@@ -1323,9 +1323,18 @@ class TestStockEntry(FrappeTestCase):
|
||||
posting_date="2021-07-02", # Illegal SE
|
||||
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})
|
||||
def test_future_negative_sle_batch(self):
|
||||
|
||||
@@ -73,7 +73,8 @@
|
||||
"label": "Batch No",
|
||||
"oldfieldname": "batch_no",
|
||||
"oldfieldtype": "Data",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "warehouse",
|
||||
@@ -361,7 +362,7 @@
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-04-22 12:37:41.304109",
|
||||
"modified": "2025-10-04 09:59:15.546556",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Ledger Entry",
|
||||
|
||||
@@ -105,6 +105,7 @@ class StockSettings(Document):
|
||||
self.validate_clean_description_html()
|
||||
self.validate_pending_reposts()
|
||||
self.validate_stock_reservation()
|
||||
self.validate_auto_insert_price_list_rate_if_missing()
|
||||
self.change_precision_for_for_sales()
|
||||
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):
|
||||
self.toggle_warehouse_field_for_inter_warehouse_transfer()
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
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
|
||||
|
||||
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 = []
|
||||
|
||||
for tax in taxes:
|
||||
tax_company = frappe.get_cached_value("Item Tax Template", tax.item_tax_template, "company")
|
||||
if tax_company == args["company"]:
|
||||
disabled, tax_company = frappe.get_cached_value(
|
||||
"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:
|
||||
# In purchase Invoice first preference will be given to supplier invoice 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(
|
||||
"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"):
|
||||
pi_item = frappe.qb.DocType("Purchase Invoice Item")
|
||||
|
||||
@@ -24,6 +24,11 @@ frappe.query_reports["Stock Qty vs Serial No Count"] = {
|
||||
},
|
||||
reqd: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_disables_items",
|
||||
label: __("Show Disabled Items"),
|
||||
fieldtype: "Check",
|
||||
},
|
||||
],
|
||||
|
||||
formatter: function (value, row, column, data, default_formatter) {
|
||||
|
||||
@@ -9,7 +9,7 @@ from frappe import _
|
||||
def execute(filters=None):
|
||||
validate_warehouse(filters)
|
||||
columns = get_columns()
|
||||
data = get_data(filters.warehouse)
|
||||
data = get_data(filters.warehouse, filters.show_disables_items)
|
||||
return columns, data
|
||||
|
||||
|
||||
@@ -38,12 +38,13 @@ def get_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(
|
||||
"Item",
|
||||
filters={
|
||||
"has_serial_no": True,
|
||||
},
|
||||
filters=filters,
|
||||
fields=["item_code", "item_name"],
|
||||
)
|
||||
|
||||
|
||||
@@ -580,11 +580,13 @@ class SerialNoValuation(DeprecatedSerialNoValuation):
|
||||
else:
|
||||
self.serial_no_incoming_rate = defaultdict(float)
|
||||
self.stock_value_change = 0.0
|
||||
self.old_serial_nos = []
|
||||
|
||||
serial_nos = self.get_serial_nos()
|
||||
for serial_no in serial_nos:
|
||||
incoming_rate = self.get_incoming_rate_from_bundle(serial_no)
|
||||
if incoming_rate is None:
|
||||
self.old_serial_nos.append(serial_no)
|
||||
continue
|
||||
|
||||
self.stock_value_change += incoming_rate
|
||||
@@ -1363,11 +1365,12 @@ def get_batch_current_qty(batch):
|
||||
|
||||
|
||||
def throw_negative_batch_validation(batch_no, qty):
|
||||
frappe.throw(
|
||||
_("The Batch {0} has negative quantity {1}. Please correct the quantity.").format(
|
||||
bold(batch_no), bold(qty)
|
||||
),
|
||||
title=_("Negative Batch Quantity"),
|
||||
frappe.msgprint(
|
||||
_(
|
||||
"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=_("Warning!"),
|
||||
indicator="orange",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1010,13 +1010,12 @@ class update_entries_after:
|
||||
if not frappe.db.exists("Serial and Batch Bundle", sle.serial_and_batch_bundle):
|
||||
return
|
||||
|
||||
if self.args.get("sle_id") and sle.actual_qty < 0:
|
||||
doc = frappe.db.get_value(
|
||||
"Serial and Batch Bundle",
|
||||
sle.serial_and_batch_bundle,
|
||||
["total_amount", "total_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
if sle.actual_qty < 0 and (
|
||||
sle.voucher_type in ["Stock Reconciliation", "Asset Capitalization"]
|
||||
or not frappe.db.get_value(sle.voucher_type, sle.voucher_no, "is_return")
|
||||
):
|
||||
doc = frappe._dict({})
|
||||
self.update_serial_batch_no_valuation(sle, doc, prev_sle=self.wh_data)
|
||||
else:
|
||||
doc = frappe.get_doc("Serial and Batch Bundle", sle.serial_and_batch_bundle)
|
||||
doc.set_incoming_rate(
|
||||
@@ -1040,6 +1039,88 @@ class update_entries_after:
|
||||
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):
|
||||
if self.wh_data.qty_after_transaction == 0:
|
||||
return 0
|
||||
@@ -2297,3 +2378,45 @@ def is_transfer_stock_entry(voucher_no):
|
||||
purpose = frappe.get_cached_value("Stock Entry", voucher_no, "purpose")
|
||||
|
||||
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
|
||||
|
||||
@@ -233,6 +233,8 @@
|
||||
"options": "Project"
|
||||
},
|
||||
{
|
||||
"fetch_from": "email_account.company",
|
||||
"fetch_if_empty": 1,
|
||||
"fieldname": "company",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company",
|
||||
@@ -391,7 +393,7 @@
|
||||
"icon": "fa fa-ticket",
|
||||
"idx": 7,
|
||||
"links": [],
|
||||
"modified": "2025-02-18 21:18:52.797745",
|
||||
"modified": "2025-09-25 11:10:53.556731",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Support",
|
||||
"name": "Issue",
|
||||
|
||||
Reference in New Issue
Block a user