mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-03 21:50:53 +00:00
Compare commits
1 Commits
develop
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28367f75e9 |
@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
|
||||
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<p>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
@@ -188,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
|
||||
return {"balance": 0, "date": None}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
|
||||
"""
|
||||
Set the closing balance as per statement for a bank account and date
|
||||
|
||||
@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
|
||||
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_bank_transaction(
|
||||
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
|
||||
):
|
||||
@@ -146,7 +146,7 @@ def update_bank_transaction(
|
||||
)[0]
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_journal_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
|
||||
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_payment_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
|
||||
return output
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_internal_transfer(
|
||||
bank_transaction_name: str | int,
|
||||
posting_date: str | date,
|
||||
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
return alert_message, indicator
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = frappe.parse_json(vouchers)
|
||||
|
||||
@@ -8,7 +8,6 @@ from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -98,40 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
# assert API output post reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": date,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def test_get_bank_transactions_excludes_dates_after_to_date(self):
|
||||
self.make_bank_transaction(date=today())
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_auto_reconcile_message_for_no_matches(self):
|
||||
message, indicator = get_auto_reconcile_message([], [])
|
||||
self.assertEqual(indicator, "blue")
|
||||
self.assertIn("No matches", message)
|
||||
|
||||
def test_auto_reconcile_message_counts_and_pluralizes(self):
|
||||
# reconciled count is reported and the indicator turns green
|
||||
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
|
||||
self.assertEqual(indicator, "green")
|
||||
self.assertIn("2 Transaction(s) Reconciled", message)
|
||||
|
||||
# partially-reconciled label is singular for one, plural for many
|
||||
singular, _ = get_auto_reconcile_message(["p1"], [])
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
|
||||
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
|
||||
"""
|
||||
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction
|
||||
|
||||
@@ -34,7 +34,7 @@ def upload_bank_statement():
|
||||
return {"columns": columns, "data": data}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
|
||||
self.get_report_summary()
|
||||
self.update_node()
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_left(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Left"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_right(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Right"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def move_up(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
|
||||
@@ -1,11 +1,47 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
import datetime
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBisectAccountingStatements(ERPNextTestSuite):
|
||||
pass
|
||||
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
|
||||
These cover the date validation and that the bisection cleanly partitions the range."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
def _leaf_days(self):
|
||||
leaves = frappe.get_all(
|
||||
"Bisect Nodes",
|
||||
filters={"left_child": ["is", "not set"]},
|
||||
fields=["period_from_date", "period_to_date"],
|
||||
)
|
||||
# every leaf spans a single day
|
||||
for leaf in leaves:
|
||||
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
|
||||
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
|
||||
|
||||
def test_validate_dates_rejects_reversed_range(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.from_date = "2026-01-08"
|
||||
doc.to_date = "2026-01-01"
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_bfs_partitions_range_into_single_days(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
|
||||
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
|
||||
def test_dfs_produces_the_same_partition_as_bfs(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
|
||||
@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
|
||||
return from_year.year_start_date, to_year.year_end_date
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def revise_budget(budget_name: str):
|
||||
old_budget = frappe.get_doc("Budget", budget_name)
|
||||
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
|
||||
build_forest,
|
||||
validate_columns,
|
||||
validate_missing_roots,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
# columns: account_name, parent_account, account_number, parent_account_number,
|
||||
# is_group, account_type, root_type, account_currency
|
||||
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
|
||||
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
|
||||
|
||||
class TestChartofAccountsImporter(ERPNextTestSuite):
|
||||
"""The importer parses an uploaded CoA into a nested tree and validates its
|
||||
shape. These cover the parsing/validation helpers without a file upload."""
|
||||
|
||||
def test_validate_columns_rejects_blank_file(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [])
|
||||
|
||||
def test_validate_columns_requires_eight_columns(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
|
||||
# the standard template width passes
|
||||
validate_columns([ROOT])
|
||||
|
||||
def test_build_forest_nests_child_under_parent(self):
|
||||
forest = build_forest([ROOT, CHILD])
|
||||
self.assertIn("Assets", forest)
|
||||
self.assertIn("Cash", forest["Assets"])
|
||||
|
||||
def test_build_forest_rejects_unknown_parent(self):
|
||||
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
|
||||
|
||||
def test_build_forest_requires_account_name(self):
|
||||
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
|
||||
|
||||
def test_validate_missing_roots_requires_all_root_types(self):
|
||||
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
validate_missing_roots,
|
||||
[{"root_type": rt} for rt in present],
|
||||
)
|
||||
# all five root types present -> no error
|
||||
validate_missing_roots(
|
||||
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
|
||||
)
|
||||
pass
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
|
||||
@@ -414,17 +414,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -718,7 +718,7 @@ class PaymentRequest(Document):
|
||||
row_number += TO_SKIP_NEW_ROW
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_payment_request(**args):
|
||||
"""Make payment request"""
|
||||
|
||||
|
||||
@@ -41,17 +41,21 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</ul>
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
|
||||
<h4><i class="fa fa-question-sign"></i>
|
||||
${__("How Pricing Rule is applied?")}
|
||||
</h4>
|
||||
<ol>
|
||||
|
||||
@@ -113,38 +113,3 @@ def create_process_soa(**args):
|
||||
process_soa.update(soa_dict)
|
||||
process_soa.save()
|
||||
return process_soa
|
||||
|
||||
|
||||
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
|
||||
"""validate() fills in default subject/body/pdf templates and enforces the
|
||||
basic constraints. Exercised on the document directly (no email/PDF flow)."""
|
||||
|
||||
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
|
||||
doc = frappe.new_doc("Process Statement Of Accounts")
|
||||
doc.report = report
|
||||
doc.company = "_Test Company"
|
||||
if with_customer:
|
||||
doc.append("customers", {"customer": "_Test Customer"})
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_customers_are_required(self):
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
|
||||
|
||||
def test_general_ledger_body_uses_a_date_range(self):
|
||||
doc = self.make_soa(report="General Ledger")
|
||||
doc.validate()
|
||||
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
|
||||
# subject and pdf name are also defaulted
|
||||
self.assertTrue(doc.subject)
|
||||
self.assertTrue(doc.pdf_name)
|
||||
|
||||
def test_receivable_body_uses_the_posting_date(self):
|
||||
doc = self.make_soa(report="Accounts Receivable")
|
||||
doc.validate()
|
||||
self.assertIn("until {{ doc.posting_date }}", doc.body)
|
||||
|
||||
def test_account_must_belong_to_company(self):
|
||||
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other, "need an account in _Test Company 1")
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)
|
||||
|
||||
@@ -1,56 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.process_subscription.process_subscription import (
|
||||
create_subscription_process,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription.test_subscription import (
|
||||
create_parties,
|
||||
create_subscription,
|
||||
make_plans,
|
||||
reset_settings,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcessSubscription(ERPNextTestSuite):
|
||||
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
|
||||
for every non-cancelled Subscription (or just one when a subscription is named)."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
# mirror TestSubscription setup so subscriptions build against known settings
|
||||
make_plans()
|
||||
create_parties()
|
||||
reset_settings()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
def enqueued_subscriptions(self, subscription=None):
|
||||
"""Submit a Process Subscription while capturing what gets enqueued."""
|
||||
calls = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
with patch("frappe.enqueue", side_effect=capture):
|
||||
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
|
||||
|
||||
# each enqueue is handed a batch (list) of subscription names
|
||||
return [name for call in calls for name in call.get("subscription", [])]
|
||||
|
||||
def test_named_subscription_is_the_only_one_enqueued(self):
|
||||
sub = create_subscription(start_date="2026-01-01")
|
||||
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
|
||||
|
||||
def test_cancelled_subscriptions_are_skipped(self):
|
||||
active = create_subscription(start_date="2026-01-01")
|
||||
cancelled = create_subscription(start_date="2026-01-01")
|
||||
cancelled.cancel_subscription()
|
||||
|
||||
enqueued = self.enqueued_subscriptions()
|
||||
self.assertIn(active.name, enqueued)
|
||||
self.assertNotIn(cancelled.name, enqueued)
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -130,6 +131,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
@@ -329,25 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
# When Update Stock is disabled, this invoice has no stock impact: the linked
|
||||
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
|
||||
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
|
||||
# amount against the supplier - booking PPV again would double count it and leave
|
||||
# SRBNB partially uncleared.
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
handled = False
|
||||
if (
|
||||
item.item_code
|
||||
and item.item_code in stock_items
|
||||
and item.get("purchase_receipt")
|
||||
and not doc.is_return
|
||||
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
|
||||
):
|
||||
handled = self.make_standard_cost_srbnb_split(
|
||||
gl_entries, item, expense_account, account_currency, base_amount
|
||||
)
|
||||
|
||||
if not handled:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -520,6 +530,95 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
},
|
||||
)
|
||||
|
||||
def make_standard_cost_srbnb_split(
|
||||
self, gl_entries, item, expense_account, account_currency, base_amount
|
||||
):
|
||||
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
|
||||
value the receipt actually booked and post the (Net Amount - standard) difference to the
|
||||
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
|
||||
can't be resolved."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
precision = item.precision("base_net_amount")
|
||||
standard_value = flt(self.get_pr_stock_value(item), precision)
|
||||
if not standard_value:
|
||||
return False
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": standard_value,
|
||||
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
variance = flt(base_amount - standard_value, precision)
|
||||
if variance:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": get_purchase_price_variance_account(item.item_code, doc.company),
|
||||
"against": doc.supplier,
|
||||
"debit": variance,
|
||||
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def get_pr_stock_value(self, item):
|
||||
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
|
||||
row is billing.
|
||||
|
||||
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
|
||||
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
|
||||
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
|
||||
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
|
||||
non-stock invoice).
|
||||
|
||||
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
|
||||
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
|
||||
pr_detail = frappe.db.get_value(
|
||||
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
|
||||
)
|
||||
if not pr_detail or not pr_detail.warehouse:
|
||||
return 0.0
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == "Purchase Receipt")
|
||||
& (sle.voucher_no == item.purchase_receipt)
|
||||
& (sle.voucher_detail_no == item.pr_detail)
|
||||
& (sle.warehouse == pr_detail.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
if not accepted_value or not flt(pr_detail.stock_qty):
|
||||
return accepted_value
|
||||
|
||||
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
|
||||
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
|
||||
Variance; for all other items it keeps the existing behaviour (default expense account)."""
|
||||
|
||||
@@ -121,65 +121,3 @@ class TestShareTransfer(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
self.assertRaises(ShareDontExists, doc.insert)
|
||||
|
||||
|
||||
class TestShareTransferValidation(ERPNextTestSuite):
|
||||
"""basic_validations() enforces the transfer's internal consistency. Exercised
|
||||
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
|
||||
are needed - it only reasons about the document's own fields."""
|
||||
|
||||
def make_transfer(self, **overrides):
|
||||
doc = frappe.new_doc("Share Transfer")
|
||||
doc.update(
|
||||
{
|
||||
"transfer_type": "Transfer",
|
||||
"date": "2026-01-01",
|
||||
"from_shareholder": "SH-A",
|
||||
"to_shareholder": "SH-B",
|
||||
"to_folio_no": "1",
|
||||
"share_type": "Equity",
|
||||
"from_no": 1,
|
||||
"to_no": 100,
|
||||
"no_of_shares": 100,
|
||||
"rate": 10,
|
||||
"amount": 1000,
|
||||
"company": "_Test Company",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_baseline_transfer_is_consistent(self):
|
||||
# the helper's defaults must pass, otherwise the negative cases prove nothing
|
||||
self.make_transfer().basic_validations()
|
||||
|
||||
def test_seller_and_buyer_must_differ(self):
|
||||
doc = self.make_transfer(to_shareholder="SH-A")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_share_count_must_match_the_number_range(self):
|
||||
# 1..100 is 100 shares, not 50
|
||||
doc = self.make_transfer(no_of_shares=50)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_must_equal_rate_times_shares(self):
|
||||
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_is_derived_when_left_blank(self):
|
||||
doc = self.make_transfer(amount=0)
|
||||
doc.basic_validations()
|
||||
self.assertEqual(doc.amount, 1000)
|
||||
|
||||
def test_equity_or_liability_account_is_required(self):
|
||||
doc = self.make_transfer(equity_or_liability_account=None)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_issue_requires_a_to_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_purchase_requires_a_from_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Banking",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.924019",
|
||||
"modified": "2026-06-14 13:43:50.924019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Banking",
|
||||
@@ -43,7 +43,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wrench",
|
||||
"icon": "tool",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Reconciliation",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Budgeting",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 04:24:48.116724",
|
||||
"modified": "2026-07-02 04:24:48.116724",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budgeting",
|
||||
@@ -59,7 +59,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.095321",
|
||||
"modified": "2026-06-14 13:44:08.095321",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -284,7 +284,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 4,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
@@ -587,7 +587,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.471142",
|
||||
"modified": "2026-06-14 13:44:08.471142",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -622,7 +622,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -635,7 +635,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -786,7 +786,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.184761",
|
||||
"modified": "2026-06-14 13:43:50.184761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -44,7 +44,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Share Management",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:51.040978",
|
||||
"modified": "2026-06-14 13:43:51.040978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Management",
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "user",
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Shareholder",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Subscriptions",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 14:08:36.999272",
|
||||
"modified": "2026-06-14 14:08:36.999272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscriptions",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Taxes",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.894825",
|
||||
"modified": "2026-06-14 13:43:50.894825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Tax Template",
|
||||
|
||||
@@ -587,47 +587,3 @@ def get_actual_sle_dict(name):
|
||||
}
|
||||
|
||||
return sle_dict
|
||||
|
||||
|
||||
class TestAssetCapitalizationValidation(ERPNextTestSuite):
|
||||
"""Row-level validations for the consumed/target items. Exercised on the document
|
||||
directly (the integration tests above cover the full capitalization posting)."""
|
||||
|
||||
def make_capitalization(self, **fields):
|
||||
doc = frappe.new_doc("Asset Capitalization")
|
||||
doc.company = "_Test Company"
|
||||
doc.update(fields)
|
||||
return doc
|
||||
|
||||
def test_source_items_are_mandatory(self):
|
||||
doc = self.make_capitalization()
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_source_mandatory)
|
||||
|
||||
def test_target_item_must_be_a_fixed_asset(self):
|
||||
# _Test Item is a stock item, not a fixed asset
|
||||
doc = self.make_capitalization(target_item_code="_Test Item")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_target_item)
|
||||
|
||||
def test_consumed_stock_row_rejects_a_non_stock_item(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("stock_items", {"item_code": "_Test Non Stock Item", "stock_qty": 1})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
|
||||
|
||||
def test_consumed_stock_row_requires_positive_qty(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("stock_items", {"item_code": "_Test Item", "stock_qty": 0})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
|
||||
|
||||
def test_service_row_rejects_a_stock_item(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("service_items", {"item_code": "_Test Item", "qty": 1, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_service_item)
|
||||
|
||||
def test_service_row_requires_positive_qty_and_rate(self):
|
||||
zero_qty = self.make_capitalization()
|
||||
zero_qty.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 0, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, zero_qty.validate_service_item)
|
||||
|
||||
zero_rate = self.make_capitalization()
|
||||
zero_rate.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 1, "rate": 0})
|
||||
self.assertRaises(frappe.ValidationError, zero_rate.validate_service_item)
|
||||
|
||||
@@ -224,7 +224,7 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "archive",
|
||||
"icon": "assets",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Assets",
|
||||
@@ -199,7 +199,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.417956",
|
||||
"modified": "2026-06-14 13:44:08.417956",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"module_onboarding": "Asset Onboarding",
|
||||
@@ -217,7 +217,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -230,7 +230,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -295,7 +295,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
|
||||
@@ -55,7 +55,7 @@ def make_supplier_quotation_from_rfq(
|
||||
|
||||
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ def refresh_scorecards():
|
||||
frappe.get_doc("Supplier Scorecard", sc_name).save()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_all_scorecards(docname: str):
|
||||
sc = frappe.get_doc("Supplier Scorecard", docname)
|
||||
supplier = frappe.get_doc("Supplier", sc.supplier)
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Buying",
|
||||
@@ -501,7 +501,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:43:50.509039",
|
||||
"modified": "2026-06-14 13:43:50.509039",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"module_onboarding": "Buying Onboarding",
|
||||
@@ -532,7 +532,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -545,7 +545,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -610,7 +610,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scale",
|
||||
"icon": "liabilities",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
|
||||
@@ -1724,7 +1724,7 @@ def get_missing_company_details(doctype: str, docname: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_company_master_and_address(current_doctype: str, name: str, company: str, details: dict | str):
|
||||
from frappe.utils import validate_email_address
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
|
||||
return [item for item in items if item.get("item_code") in inspection_required_items]
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_quality_inspections(
|
||||
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
|
||||
):
|
||||
|
||||
@@ -17,7 +17,7 @@ frappe.ui.form.on("Campaign", {
|
||||
frappe.route_options = { utm_source: "Campaign", utm_campaign: frm.doc.name };
|
||||
frappe.set_route("List", "Lead");
|
||||
},
|
||||
null,
|
||||
"fa fa-list",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ def get_lead_with_phone_number(number):
|
||||
return lead
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
|
||||
@@ -110,7 +110,7 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
|
||||
return target_doc
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
|
||||
"""raise a issue from email"""
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def make_supplier_quotation(source_name: str, target_doc: str | Document | None
|
||||
return doclist
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_opportunity_from_communication(
|
||||
communication: str, company: str, ignore_communication_links: bool = False
|
||||
):
|
||||
|
||||
@@ -389,7 +389,7 @@ def get_item_details(item_code: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_multiple_status(names: str | list[str], status: str):
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "handshake",
|
||||
"icon": "crm",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "CRM",
|
||||
@@ -421,7 +421,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.297053",
|
||||
"modified": "2026-06-14 13:44:08.297053",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM",
|
||||
@@ -471,7 +471,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -510,7 +510,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "user",
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
@@ -644,7 +644,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
@@ -776,7 +776,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Campaign",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"icon_type": "Folder",
|
||||
"idx": 1,
|
||||
"label": "Accounting",
|
||||
"link_to": "",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 17:04:04.351402",
|
||||
"modified": "2026-01-27 17:04:04.351402",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "archive",
|
||||
"icon": "assets",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Assets",
|
||||
"link_to": "Assets",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.220411",
|
||||
"modified": "2026-01-01 20:07:01.220411",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Assets",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "chart-pie",
|
||||
"icon": "expenses",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:39:30.839274",
|
||||
"modified": "2026-01-23 14:39:30.839274",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Budget",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Buying",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.196163",
|
||||
"modified": "2026-01-01 20:07:01.196163",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "handshake",
|
||||
"icon": "crm",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "CRM",
|
||||
"link_to": "CRM",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:54:05.112927",
|
||||
"modified": "2026-01-06 14:54:05.112927",
|
||||
"modified_by": "Administrator",
|
||||
"name": "CRM",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"icon": "setting",
|
||||
"icon_type": "Link",
|
||||
"idx": 10,
|
||||
"label": "ERPNext Settings",
|
||||
"link_to": "ERPNext Settings",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "",
|
||||
"modified": "2026-07-03 14:59:56.044037",
|
||||
"modified": "2026-01-09 14:59:56.044037",
|
||||
"modified_by": "Administrator",
|
||||
"name": "ERPNext Settings",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Home",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.174950",
|
||||
"modified": "2026-01-01 20:07:01.174950",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Home",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Invoicing",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 15:17:23.564795",
|
||||
"modified": "2026-01-23 15:17:23.564795",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Invoicing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "factory",
|
||||
"icon": "organization",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Manufacturing",
|
||||
"link_to": "Manufacturing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.246693",
|
||||
"modified": "2026-01-01 20:07:01.246693",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Manufacturing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "folder-kanban",
|
||||
"icon": "project",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Projects",
|
||||
"link_to": "Projects",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.226383",
|
||||
"modified": "2026-01-01 20:07:01.226383",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Projects",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "shield-check",
|
||||
"icon": "quality",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Quality",
|
||||
"link_to": "Quality",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.239523",
|
||||
"modified": "2026-01-01 20:07:01.239523",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Quality",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Selling",
|
||||
"link_to": "Selling",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.189446",
|
||||
"modified": "2026-01-01 20:07:01.189446",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Selling",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Stock",
|
||||
"link_to": "Stock",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.212940",
|
||||
"modified": "2026-01-01 20:07:01.212940",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Stock",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "package-2",
|
||||
"icon": "getting-started",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Subcontracting",
|
||||
"link_to": "Subcontracting",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "/assets/erpnext/desktop_icons/subcontracting.svg",
|
||||
"modified": "2026-07-03 20:07:01.323508",
|
||||
"modified": "2026-01-01 20:07:01.323508",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Subcontracting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "headset",
|
||||
"icon": "support",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Support",
|
||||
"link_to": "Support",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:53:54.100467",
|
||||
"modified": "2026-01-06 14:53:54.100467",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Support",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_plaid_configuration():
|
||||
return "disabled"
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_institution(token: str, response: str | dict):
|
||||
response = frappe.parse_json(response)
|
||||
|
||||
@@ -79,7 +79,7 @@ def add_institution(token: str, response: str | dict):
|
||||
return bank
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
response = frappe.parse_json(response)
|
||||
bank = frappe.parse_json(bank)
|
||||
|
||||
@@ -86,10 +86,6 @@ def make_material_request(source_name: str, target_doc: Document | str | None =
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
|
||||
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
|
||||
set_previous_operation_serial_batch,
|
||||
)
|
||||
|
||||
def update_item(source, target, source_parent):
|
||||
target.t_warehouse = source_parent.wip_warehouse
|
||||
|
||||
@@ -129,7 +125,6 @@ def make_stock_entry(source_name: str, target_doc: Document | str | None = None)
|
||||
wo_allows_alternate_item
|
||||
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
|
||||
)
|
||||
set_previous_operation_serial_batch(target, item)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Job Card",
|
||||
|
||||
@@ -1061,9 +1061,6 @@ class TestJobCard(ERPNextTestSuite):
|
||||
job_card.submit()
|
||||
|
||||
for row in fg_bom.items:
|
||||
if row.item_code == sfg.name:
|
||||
continue
|
||||
|
||||
make_stock_entry(
|
||||
item_code=row.item_code,
|
||||
target="Stores - _TC",
|
||||
@@ -1074,301 +1071,9 @@ class TestJobCard(ERPNextTestSuite):
|
||||
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
|
||||
manufacturing_entry.submit()
|
||||
|
||||
sfg_row = next(row for row in manufacturing_entry.items if row.item_code == sfg.name)
|
||||
self.assertEqual(flt(sfg_row.basic_rate, 3), 95.0)
|
||||
|
||||
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
|
||||
self.assertEqual(manufacturing_entry.items[2].qty, 9)
|
||||
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
|
||||
|
||||
def test_semi_fg_batch_auto_pull_on_manufacture(self):
|
||||
"""Batch produced by an operation should auto-pull into the next operation's
|
||||
semi-finished consumption row (skip-transfer Manufacture entry)."""
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
|
||||
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
|
||||
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
|
||||
warehouse = "Stores - _TC"
|
||||
|
||||
rm1 = make_item("Auto Pull RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("Auto Pull RM 2", {"is_stock_item": 1}).name
|
||||
fg1 = make_item("Auto Pull FG 1", {"is_stock_item": 1}).name
|
||||
sfg = make_item(
|
||||
"Auto Pull SFG 1",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "AP-SFG-.#####",
|
||||
},
|
||||
).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
|
||||
operation1 = {
|
||||
"operation": "Auto Pull Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": "Auto Pull Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.operations[1].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
|
||||
|
||||
# Operation A -> produces the SFG batch
|
||||
jc_a = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op A"}, "name"
|
||||
),
|
||||
)
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-01-01 08:00:00",
|
||||
"to_time": "2024-01-01 09:00:00",
|
||||
"completed_qty": jc_a.for_quantity,
|
||||
},
|
||||
)
|
||||
jc_a.submit()
|
||||
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
me_a.submit()
|
||||
|
||||
me_a.reload()
|
||||
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
|
||||
self.assertTrue(sfg_fg_row.serial_and_batch_bundle)
|
||||
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
|
||||
|
||||
# Operation B -> consumes the SFG; its batch should be auto-pulled from Operation A
|
||||
jc_b = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op B"}, "name"
|
||||
),
|
||||
)
|
||||
jc_b.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-02-01 08:00:00",
|
||||
"to_time": "2024-02-01 09:00:00",
|
||||
"completed_qty": jc_b.for_quantity,
|
||||
},
|
||||
)
|
||||
jc_b.submit()
|
||||
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
|
||||
|
||||
sfg_consume_row = next(r for r in me_b.items if r.item_code == sfg and r.s_warehouse)
|
||||
self.assertTrue(
|
||||
sfg_consume_row.serial_and_batch_bundle,
|
||||
"Previous operation's batch was not auto-pulled into the semi-finished consumption row",
|
||||
)
|
||||
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
|
||||
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
|
||||
|
||||
def test_semi_fg_auto_pull_with_uom_conversion(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
|
||||
set_previous_operation_serial_batch,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
|
||||
|
||||
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
|
||||
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
|
||||
warehouse = "Stores - _TC"
|
||||
|
||||
rm1 = make_item("UOM Pull RM 1", {"is_stock_item": 1}).name
|
||||
rm2 = make_item("UOM Pull RM 2", {"is_stock_item": 1}).name
|
||||
fg1 = make_item("UOM Pull FG 1", {"is_stock_item": 1}).name
|
||||
sfg = make_item(
|
||||
"UOM Pull SFG 1",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "UP-SFG-.#####",
|
||||
"uoms": [{"uom": "Box", "conversion_factor": 5}],
|
||||
},
|
||||
).name
|
||||
|
||||
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
|
||||
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
|
||||
sfg_bom.insert()
|
||||
sfg_bom.submit()
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg1,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm2, "qty": 1})
|
||||
|
||||
operation1 = {
|
||||
"operation": "UOM Pull Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": sfg,
|
||||
"bom_no": sfg_bom.name,
|
||||
"finished_good_qty": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
operation2 = {
|
||||
"operation": "UOM Pull Op B",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg1,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 2,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
|
||||
make_workstation(operation1)
|
||||
make_operation(operation1)
|
||||
make_operation(operation2)
|
||||
|
||||
fg_bom.append("operations", operation1)
|
||||
fg_bom.append("operations", operation2)
|
||||
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg1,
|
||||
qty=5,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.operations[1].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
|
||||
make_stock_entry(item_code=sfg, target=warehouse, qty=5, basic_rate=100, posting_date="2024-01-01")
|
||||
|
||||
jc_a = frappe.get_doc(
|
||||
"Job Card",
|
||||
frappe.db.get_value(
|
||||
"Job Card", {"work_order": work_order.name, "operation": "UOM Pull Op A"}, "name"
|
||||
),
|
||||
)
|
||||
jc_a.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-02-01 08:00:00",
|
||||
"to_time": "2024-02-01 09:00:00",
|
||||
"completed_qty": jc_a.for_quantity,
|
||||
},
|
||||
)
|
||||
jc_a.submit()
|
||||
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
|
||||
me_a.submit()
|
||||
me_a.reload()
|
||||
|
||||
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
|
||||
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.company = "_Test Company"
|
||||
se.purpose = "Material Transfer"
|
||||
se.work_order = work_order.name
|
||||
se.set_stock_entry_type()
|
||||
row = se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": sfg,
|
||||
"qty": 1,
|
||||
"uom": "Box",
|
||||
"conversion_factor": 5,
|
||||
"s_warehouse": warehouse,
|
||||
"t_warehouse": "_Test Warehouse - _TC",
|
||||
},
|
||||
)
|
||||
set_previous_operation_serial_batch(se, row)
|
||||
|
||||
self.assertTrue(row.serial_and_batch_bundle)
|
||||
self.assertEqual(
|
||||
abs(frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")),
|
||||
5.0,
|
||||
)
|
||||
|
||||
se.save()
|
||||
se.submit()
|
||||
se.reload()
|
||||
|
||||
row = se.items[0]
|
||||
consumed_batches = get_batches_from_bundle(row.serial_and_batch_bundle)
|
||||
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
|
||||
self.assertEqual(abs(sum(consumed_batches.values())), 5.0)
|
||||
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
|
||||
|
||||
def test_secondary_items_without_sfg(self):
|
||||
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:
|
||||
|
||||
@@ -169,29 +169,6 @@ class OperationsService:
|
||||
|
||||
self.doc.set("operations", operations)
|
||||
self.calculate_time()
|
||||
self.set_operation_warehouses()
|
||||
|
||||
def set_operation_warehouses(self):
|
||||
"""For semi-finished goods tracking, default each operation's warehouses from the Work
|
||||
Order and chain them: the first operation pulls from the WO source warehouse and every
|
||||
later operation pulls from the previous operation's output; intermediate outputs go to the
|
||||
WIP warehouse while the final operation outputs to the WO finished goods warehouse.
|
||||
|
||||
Only empty fields are filled, so values configured on the BOM/operation are preserved."""
|
||||
if not self.doc.track_semi_finished_goods or not self.doc.operations:
|
||||
return
|
||||
|
||||
operations = self.doc.operations
|
||||
last_idx = len(operations) - 1
|
||||
for idx, op in enumerate(operations):
|
||||
if not op.source_warehouse:
|
||||
op.source_warehouse = self.doc.source_warehouse
|
||||
|
||||
if not op.fg_warehouse:
|
||||
op.fg_warehouse = self.doc.fg_warehouse if idx == last_idx else self.doc.source_warehouse
|
||||
|
||||
if not op.wip_warehouse:
|
||||
op.wip_warehouse = self.doc.wip_warehouse
|
||||
|
||||
def _collect_bom_operations(self):
|
||||
operations = []
|
||||
|
||||
@@ -691,28 +691,6 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
ste.save()
|
||||
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
|
||||
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
|
||||
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
|
||||
# "_Test Item Group" is master data with buying_cost_center already set to
|
||||
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
|
||||
# BOM need to be created, since no existing item in that group has one.
|
||||
fg_item = make_item(
|
||||
"_Test FG Item For Item Group Cost Center",
|
||||
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
|
||||
)
|
||||
|
||||
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
|
||||
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
|
||||
|
||||
wo_order = make_wo_order_test_record(
|
||||
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
|
||||
)
|
||||
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
|
||||
ste.insert()
|
||||
|
||||
fg_row = next(d for d in ste.items if d.is_finished_item)
|
||||
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
|
||||
|
||||
def test_operation_time_with_batch_size(self):
|
||||
fg_item = "Test Batch Size Item For BOM"
|
||||
rm1 = "Test Batch Size Item RM 1 For BOM"
|
||||
|
||||
@@ -288,7 +288,6 @@ class WorkOrder(Document):
|
||||
self.validate_sales_order()
|
||||
|
||||
self.set_default_warehouse()
|
||||
self.set_operation_warehouses()
|
||||
self.validate_warehouse_belongs_to_company()
|
||||
self.check_wip_warehouse_skip()
|
||||
self.calculate_operating_cost()
|
||||
@@ -976,9 +975,6 @@ class WorkOrder(Document):
|
||||
def set_work_order_operations(self):
|
||||
return OperationsService(self).set_work_order_operations()
|
||||
|
||||
def set_operation_warehouses(self):
|
||||
return OperationsService(self).set_operation_warehouses()
|
||||
|
||||
def update_operation_status(self):
|
||||
return OperationsService(self).update_operation_status()
|
||||
|
||||
@@ -1074,7 +1070,7 @@ def get_bom_operations(doctype: str, txt: str, searchfield: str, start: int, pag
|
||||
return frappe.get_all("BOM Operation", filters=filters, fields=["operation"], as_list=1)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_work_order_ops(name: str):
|
||||
po = frappe.get_doc("Work Order", name)
|
||||
po.set_work_order_operations()
|
||||
|
||||
@@ -232,8 +232,8 @@ class WorkstationDashboard {
|
||||
.find(".section-body-job-card")
|
||||
.hasClass("hide")
|
||||
)
|
||||
$(e.currentTarget).html(frappe.utils.icon("chevron-down", "sm", "mb-1"));
|
||||
else $(e.currentTarget).html(frappe.utils.icon("chevron-up", "sm", "mb-1"));
|
||||
$(e.currentTarget).html(frappe.utils.icon("es-line-down", "sm", "mb-1"));
|
||||
else $(e.currentTarget).html(frappe.utils.icon("es-line-up", "sm", "mb-1"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ class Workstation(Document):
|
||||
|
||||
return schedule_date
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
doc.check_permission("write")
|
||||
@@ -233,7 +233,7 @@ class Workstation(Document):
|
||||
|
||||
return doc
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
|
||||
doc = frappe.get_doc("Job Card", job_card)
|
||||
doc.check_permission("submit")
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<span>
|
||||
<span class="menu-btn-group-label">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-ellipsis-vertical">
|
||||
<use href="#icon-dot-vertical">
|
||||
</use>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "building-2",
|
||||
"icon": "organization",
|
||||
"idx": 1,
|
||||
"is_hidden": 0,
|
||||
"label": "Manufacturing",
|
||||
@@ -432,7 +432,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:07.420267",
|
||||
"modified": "2026-06-14 13:44:07.420267",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"module_onboarding": "Manufacturing Onboarding",
|
||||
@@ -463,7 +463,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -476,7 +476,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -528,7 +528,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Stock Entry",
|
||||
@@ -541,7 +541,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Material Planning",
|
||||
@@ -627,7 +627,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wrench",
|
||||
"icon": "tool",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Tools",
|
||||
|
||||
@@ -628,7 +628,7 @@ def allow_to_make_project_update(project, time, frequency):
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_duplicate_project(prev_doc: str | dict, project_name: str):
|
||||
"""Create duplicate project based on the old project"""
|
||||
import json
|
||||
@@ -779,7 +779,7 @@ def create_kanban_board_if_not_exists(project: str):
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_project_status(project: str, status: str):
|
||||
"""
|
||||
set status for project and all related tasks
|
||||
|
||||
@@ -369,7 +369,7 @@ def get_project(doctype: str, txt: str, searchfield: str, start: int, page_len:
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_multiple_status(names: str | list, status: str):
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
@@ -451,7 +451,7 @@ def get_children(
|
||||
return tasks
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
@@ -465,7 +465,7 @@ def add_node():
|
||||
frappe.get_doc(args).insert()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_multiple_tasks(data: str | list, parent: str):
|
||||
data = frappe.parse_json(data)
|
||||
new_doc = {"doctype": "Task", "parent_task": parent if parent != "All Tasks" else ""}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "folder-kanban",
|
||||
"icon": "project",
|
||||
"idx": 1,
|
||||
"is_hidden": 0,
|
||||
"label": "Projects",
|
||||
@@ -367,7 +367,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:20:50.651608",
|
||||
"modified": "2026-07-01 13:20:50.651608",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"module_onboarding": "Projects Onboarding",
|
||||
@@ -399,7 +399,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -413,7 +413,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -427,7 +427,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "folder-kanban",
|
||||
"icon": "projects",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Project",
|
||||
|
||||
@@ -111,7 +111,7 @@ class BOMConfigurator {
|
||||
this.frm?.doc.docstatus === 0
|
||||
? [
|
||||
{
|
||||
label: __(frappe.utils.icon("pencil", "sm") + " BOM"),
|
||||
label: __(frappe.utils.icon("edit", "sm") + " BOM"),
|
||||
click: function (node) {
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
view.events.edit_bom(node, view);
|
||||
@@ -119,7 +119,7 @@ class BOMConfigurator {
|
||||
btnClass: "hidden-xs",
|
||||
},
|
||||
{
|
||||
label: __(frappe.utils.icon("plus", "sm") + " Raw Material"),
|
||||
label: __(frappe.utils.icon("add", "sm") + " Raw Material"),
|
||||
click: function (node) {
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
view.events.add_item(node, view);
|
||||
@@ -130,7 +130,7 @@ class BOMConfigurator {
|
||||
btnClass: "hidden-xs",
|
||||
},
|
||||
{
|
||||
label: __(frappe.utils.icon("plus", "sm") + " Sub Assembly"),
|
||||
label: __(frappe.utils.icon("add", "sm") + " Sub Assembly"),
|
||||
click: function (node) {
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
view.events.add_sub_assembly(node, view);
|
||||
@@ -141,7 +141,7 @@ class BOMConfigurator {
|
||||
btnClass: "hidden-xs",
|
||||
},
|
||||
{
|
||||
label: __(frappe.utils.icon("plus", "sm") + " Phantom Item"),
|
||||
label: __(frappe.utils.icon("add", "sm") + " Phantom Item"),
|
||||
click: function (node) {
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
view.events.add_sub_assembly(node, view, true);
|
||||
|
||||
@@ -118,6 +118,7 @@ erpnext.setup.slides_settings = [
|
||||
// Organization
|
||||
name: "organization",
|
||||
title: __("Setup your organization"),
|
||||
icon: "fa fa-building",
|
||||
fields: [
|
||||
{
|
||||
fieldname: "company_name",
|
||||
|
||||
@@ -27,7 +27,7 @@ frappe.ui.form.ControlData = class ControlData extends frappe.ui.form.ControlDat
|
||||
`
|
||||
<span class="phone-btn">
|
||||
<a class="btn-open no-decoration" title="${__("Make a call")}">
|
||||
${frappe.utils.icon("phone")}
|
||||
${frappe.utils.icon("call")}
|
||||
</span>
|
||||
`
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span>
|
||||
<a class="action-btn" href="/app/call-log/{{ name }}" title="{{ __("Open Call Log") }}">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-link" class="like-icon"></use>
|
||||
<use href="#icon-link-url" class="like-icon"></use>
|
||||
</svg>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<span>
|
||||
<button class="btn btn-sm small new-task-btn mr-1">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-message-circle"></use>
|
||||
<use href="#icon-small-message"></use>
|
||||
</svg>
|
||||
{{ __("New Task") }}
|
||||
</button>
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="row label-area font-md ml-1">
|
||||
<span class="mr-2">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-message-circle"></use>
|
||||
<use href="#icon-small-message"></use>
|
||||
</svg>
|
||||
</span>
|
||||
<a href="/app/todo/{{ tasks[i].name }}" title="{{ __('Open Task') }}">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="new-btn pb-3">
|
||||
<button class="btn btn-sm small new-note-btn mr-1">
|
||||
<svg class="icon icon-sm">
|
||||
<use href="#icon-plus"></use>
|
||||
<use href="#icon-add"></use>
|
||||
</svg>
|
||||
{{ __("New Note") }}
|
||||
</button>
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
<div class="col-xs-1 text-right">
|
||||
<span class="edit-note-btn btn btn-link">
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-pencil"></use></svg>
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-edit"></use></svg>
|
||||
</span>
|
||||
<span class="delete-note-btn btn btn-link pl-2">
|
||||
<svg class="icon icon-xs"><use xlink:href="#icon-delete"></use></svg>
|
||||
|
||||
@@ -491,7 +491,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner {
|
||||
|
||||
const clear_btn = `
|
||||
<a class="btn-clear-last-scanned-warehouse" title="${__("Clear Last Scanned Warehouse")}">
|
||||
${frappe.utils.icon("x", "xs")}
|
||||
${frappe.utils.icon("close", "xs", "es-icon")}
|
||||
</a>
|
||||
`;
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ def get_children(
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "shield-check",
|
||||
"icon": "quality",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Quality",
|
||||
@@ -161,7 +161,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:07.920643",
|
||||
"modified": "2026-06-14 13:44:07.920643",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Quality Management",
|
||||
"name": "Quality",
|
||||
@@ -178,7 +178,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -217,7 +217,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "star",
|
||||
"icon": "review",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Quality Review",
|
||||
|
||||
@@ -196,7 +196,7 @@ class Customer(TransactionBase):
|
||||
if sum(member.allocated_percentage or 0 for member in self.sales_team) != 100:
|
||||
frappe.throw(_("Total contribution percentage should be equal to 100"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def get_customer_group_details(self):
|
||||
doc = frappe.get_doc("Customer Group", self.customer_group)
|
||||
self.accounts = []
|
||||
|
||||
@@ -74,7 +74,7 @@ erpnext.selling.InstallationNote = class InstallationNote extends frappe.ui.form
|
||||
},
|
||||
});
|
||||
},
|
||||
null,
|
||||
"fa fa-download",
|
||||
"btn-default"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -840,7 +840,7 @@ def set_delivery_date(items: list, sales_order: str) -> None:
|
||||
item.schedule_date = delivery_by_bundle.get(item.product_bundle)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_work_orders(items: str | dict, sales_order: str, company: str, project: str | None = None):
|
||||
"""Make Work Orders against the given Sales Order for the given `items`"""
|
||||
items = frappe.parse_json(items).get("items")
|
||||
|
||||
@@ -347,7 +347,7 @@ def check_opening_entry(user: str):
|
||||
return open_vouchers
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_opening_voucher(pos_profile: str, company: str, balance_details: str | list):
|
||||
balance_details = frappe.parse_json(balance_details)
|
||||
|
||||
@@ -438,7 +438,7 @@ def get_past_order_list(search_term: str, status: str, limit: int = 20):
|
||||
return invoice_list
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_customer_info(fieldname: str, customer: str, value: str = ""):
|
||||
customer_doc = frappe.get_doc("Customer", customer)
|
||||
customer_doc.check_permission("write")
|
||||
|
||||
@@ -279,14 +279,14 @@ erpnext.PointOfSale.ItemSelector = class {
|
||||
this.search_field.$wrapper.find(".control-input").append(
|
||||
`<span class="link-btn">
|
||||
<a class="btn-open no-decoration" title="${__("Clear")}">
|
||||
${frappe.utils.icon("x", "sm")}
|
||||
${frappe.utils.icon("close", "sm")}
|
||||
</a>
|
||||
</span>`
|
||||
);
|
||||
|
||||
this.item_group_field.$wrapper.find(".link-btn").append(
|
||||
`<a class="btn-clear" tabindex="-1" style="display: inline-block;" title="${__("Clear Link")}">
|
||||
${frappe.utils.icon("x", "xs")}
|
||||
${frappe.utils.icon("close", "xs", "es-icon")}
|
||||
</a>`
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ erpnext.SalesFunnel = class SalesFunnel {
|
||||
function () {
|
||||
me.get_data();
|
||||
},
|
||||
"refresh-cw"
|
||||
"fa fa-refresh"
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"creation": "2013-10-04 13:17:18.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Page",
|
||||
"icon": "funnel",
|
||||
"icon": "fa fa-filter",
|
||||
"idx": 1,
|
||||
"modified": "2026-07-03 13:17:18.000000",
|
||||
"modified": "2013-10-04 13:17:18.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "sales-funnel",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Selling",
|
||||
@@ -622,7 +622,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:07.820564",
|
||||
"modified": "2026-06-14 13:44:07.820564",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"module_onboarding": "Selling Onboarding",
|
||||
@@ -653,7 +653,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -666,7 +666,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -692,7 +692,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Order",
|
||||
@@ -839,7 +839,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Items & Pricing",
|
||||
|
||||
@@ -1007,7 +1007,7 @@ def get_children(doctype: str, parent: str | None = None, company: str | None =
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
@@ -1118,7 +1118,7 @@ def get_billing_shipping_address(
|
||||
return {"primary_address": primary_address, "shipping_address": shipping_address}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_transaction_deletion_request(company: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def get_children(
|
||||
return frappe.get_all("Department", fields=fields, filters=filters, order_by="name")
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, now_datetime, today
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -116,38 +116,3 @@ def create_email_digest(**args):
|
||||
doc.insert()
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class TestEmailDigestDates(ERPNextTestSuite):
|
||||
"""The digest's reporting windows are pure date math driven by the frequency."""
|
||||
|
||||
def make_digest(self, frequency, from_date="2026-06-15"):
|
||||
doc = frappe.new_doc("Email Digest")
|
||||
doc.frequency = frequency
|
||||
doc.from_date = getdate(from_date)
|
||||
doc.to_date = getdate(from_date)
|
||||
return doc
|
||||
|
||||
def test_set_dates_daily_looks_back_one_day(self):
|
||||
doc = self.make_digest("Daily")
|
||||
doc.set_dates()
|
||||
self.assertEqual(doc.past_from_date, getdate("2026-06-14"))
|
||||
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
|
||||
|
||||
def test_set_dates_weekly_looks_back_one_week(self):
|
||||
doc = self.make_digest("Weekly")
|
||||
doc.set_dates()
|
||||
self.assertEqual(doc.past_from_date, getdate("2026-06-08"))
|
||||
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
|
||||
|
||||
def test_set_dates_monthly_looks_back_one_month(self):
|
||||
doc = self.make_digest("Monthly")
|
||||
doc.set_dates()
|
||||
self.assertEqual(doc.past_from_date, getdate("2026-05-15"))
|
||||
self.assertEqual(doc.past_to_date, getdate("2026-06-14"))
|
||||
|
||||
def test_weekly_window_is_the_previous_monday_to_sunday(self):
|
||||
from_date, to_date = self.make_digest("Weekly").get_from_to_date()
|
||||
self.assertEqual(from_date.weekday(), 0) # Monday
|
||||
self.assertEqual((to_date - from_date).days, 6) # through Sunday
|
||||
self.assertLess(to_date, now_datetime().date()) # entirely in the past
|
||||
|
||||
@@ -432,7 +432,7 @@ def deactivate_sales_person(status: str, employee: str):
|
||||
frappe.db.set_value("Sales Person", sales_person, "enabled", 0)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_user(employee: str, email: str | None = None, create_user_permission: int = 0) -> str:
|
||||
emp = frappe.get_doc("Employee", employee)
|
||||
emp.check_permission("write")
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "sliders-horizontal",
|
||||
"icon": "setting",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "ERPNext Settings",
|
||||
@@ -69,7 +69,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:43:50.429297",
|
||||
"modified": "2026-06-14 13:43:50.429297",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "ERPNext Settings",
|
||||
@@ -97,7 +97,7 @@
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"label": "Accounts Settings",
|
||||
"link_to": "Accounts Settings",
|
||||
"type": "DocType"
|
||||
@@ -110,19 +110,19 @@
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"label": "Stock Settings",
|
||||
"link_to": "Stock Settings",
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"label": "Selling Settings",
|
||||
"link_to": "Selling Settings",
|
||||
"type": "DocType"
|
||||
},
|
||||
{
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"label": "Buying Settings",
|
||||
"link_to": "Buying Settings",
|
||||
"type": "DocType"
|
||||
@@ -158,7 +158,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounts Settings",
|
||||
@@ -184,7 +184,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Selling Settings",
|
||||
@@ -197,7 +197,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Buying Settings",
|
||||
@@ -210,7 +210,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Stock Settings",
|
||||
@@ -236,7 +236,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "folder-kanban",
|
||||
"icon": "projects",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Projects Settings",
|
||||
@@ -249,7 +249,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "handshake",
|
||||
"icon": "crm",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "CRM Settings",
|
||||
@@ -262,7 +262,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "headset",
|
||||
"icon": "support",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Support Settings",
|
||||
@@ -275,7 +275,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Other Settings",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Home",
|
||||
@@ -452,7 +452,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 14:22:16.927245",
|
||||
"modified": "2026-07-01 14:22:16.927245",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Home",
|
||||
|
||||
@@ -79,14 +79,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "building-2",
|
||||
"icon": "organization",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Organization",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 00:45:57.595188",
|
||||
"modified": "2026-06-16 00:45:57.595188",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"module_onboarding": "Organization Onboarding",
|
||||
@@ -103,7 +103,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 1,
|
||||
"icon": "building-2",
|
||||
"icon": "organization",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Company",
|
||||
|
||||
@@ -301,7 +301,7 @@ def get_batches_by_oldest(item_code: str, warehouse: str):
|
||||
return batches_dates
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def split_batch(batch_no: str, item_code: str, warehouse: str, qty: float, new_batch_id: str | None = None):
|
||||
"""Split the batch into a new batch"""
|
||||
batch = frappe.get_doc(doctype="Batch", item=item_code, batch_id=new_batch_id).insert()
|
||||
|
||||
@@ -475,7 +475,7 @@ function render_serial_batch_banner(wrapper) {
|
||||
let banner_html = `
|
||||
<div class="custom-serial-batch-banner ${hiddenClass}">
|
||||
<div class="banner-content">
|
||||
<span class="banner-icon">${frappe.utils.icon("triangle-alert", "lg", "", "padding-bottom:2px")}</span>
|
||||
<span class="banner-icon">${frappe.utils.icon("solid-warning", "lg", "", "padding-bottom:2px")}</span>
|
||||
<span class="banner-text">
|
||||
${__("To use Serial / Batch feature, enable {0} in {1}.", [
|
||||
`<b>${__("Activate Serial / Batch No for Item")}</b>`,
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"label": "Revaluation"
|
||||
},
|
||||
{
|
||||
"description": "Stock Reconciliation that revalues on-hand stock to this standard rate: auto-created when the rate is changed here, or the reconciliation that captured this rate (opening entry or rate change).",
|
||||
"description": "Stock Reconciliation auto-created to revalue on-hand stock to the new standard rate.",
|
||||
"fieldname": "revaluation_entry",
|
||||
"fieldtype": "Link",
|
||||
"label": "Revaluation Entry",
|
||||
@@ -95,7 +95,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-02 11:00:00.000000",
|
||||
"modified": "2026-06-26 11:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Standard Cost",
|
||||
|
||||
@@ -91,75 +91,13 @@ class ItemStandardCost(Document):
|
||||
# previous (or missing) rate earlier in the request, so the revaluation below — and anything
|
||||
# else in this request — reads the newly submitted rate.
|
||||
clear_item_standard_rate_cache()
|
||||
|
||||
# When a Stock Reconciliation captured this rate (opening entry or rate change), it has set
|
||||
# revaluation_entry to itself and performs the revaluation. Don't spawn another one.
|
||||
if self.revaluation_entry:
|
||||
return
|
||||
|
||||
self.create_revaluation_entry()
|
||||
|
||||
def before_cancel(self):
|
||||
self.validate_no_stock_activity_on_or_after_effective_date()
|
||||
|
||||
def has_stock_activity_on_or_after_effective_date(self):
|
||||
"""Is there any live stock transaction for this item on or after the effective date, other than
|
||||
the revaluation Stock Reconciliation this record created? Such transactions are valued at this
|
||||
standard rate, so this record cannot be safely cancelled while they exist."""
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(sle.name)
|
||||
.where(
|
||||
(sle.item_code == self.item_code)
|
||||
& (sle.company == self.company)
|
||||
& (sle.is_cancelled == 0)
|
||||
# posting_datetime (indexed) is preferred over posting_date; get_datetime on the date gives
|
||||
# the start of the effective date, so this matches everything on or after it.
|
||||
& (sle.posting_datetime >= get_datetime(self.effective_date))
|
||||
)
|
||||
.limit(1)
|
||||
frappe.throw(
|
||||
_("Item Standard Cost cannot be cancelled. Submit a new record to change the standard rate.")
|
||||
)
|
||||
|
||||
# The revaluation reco this record created posts on the effective date; exclude it, it is
|
||||
# reversed together with this document in on_cancel.
|
||||
if self.revaluation_entry:
|
||||
query = query.where(sle.voucher_no != self.revaluation_entry)
|
||||
|
||||
return bool(query.run())
|
||||
|
||||
def validate_no_stock_activity_on_or_after_effective_date(self):
|
||||
"""A submitted Item Standard Cost can be cancelled only when no stock transaction exists for the
|
||||
item on or after its effective date, other than the revaluation Stock Reconciliation it created.
|
||||
Later transactions are valued at this standard rate, so cancelling it would corrupt their
|
||||
valuation and force a repost."""
|
||||
if self.has_stock_activity_on_or_after_effective_date():
|
||||
frappe.throw(
|
||||
_(
|
||||
"Item Standard Cost cannot be cancelled because stock transactions exist for Item {0} on or after the Effective Date {1}. Cancel those transactions first."
|
||||
).format(
|
||||
get_link_to_form("Item", self.item_code),
|
||||
frappe.bold(frappe.format(self.effective_date, "Date")),
|
||||
)
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
# Drop the cached standard rate first: this record is now cancelled, so the revaluation reversal
|
||||
# below (and anything else in this request) must re-read the previous effective rate, not this one.
|
||||
clear_item_standard_rate_cache()
|
||||
|
||||
# Set when this cancellation was triggered by the source reconciliation itself (it is already
|
||||
# cancelling); reversing revaluation_entry would loop back into that same reconciliation.
|
||||
if self.flags.from_source_reconciliation:
|
||||
return
|
||||
|
||||
# Reverse the revaluation this record created.
|
||||
if self.revaluation_entry:
|
||||
reco = frappe.get_doc("Stock Reconciliation", self.revaluation_entry)
|
||||
if reco.docstatus == 1:
|
||||
reco.flags.via_item_standard_cost = True
|
||||
reco.cancel()
|
||||
|
||||
def create_revaluation_entry(self):
|
||||
"""Revalue on-hand stock to the new standard rate via a Stock Reconciliation.
|
||||
|
||||
@@ -292,18 +230,6 @@ def get_item_standard_rate(item_code, company, posting_date=None):
|
||||
return flt(rate[0]) if rate else None
|
||||
|
||||
|
||||
def has_item_standard_cost(item_code, company):
|
||||
"""True if a submitted Item Standard Cost exists for the item in the company (any effective date).
|
||||
Used to tell an opening Stock Reconciliation (no standard rate yet, rate is editable) apart from a
|
||||
later one (standard rate owned by Item Standard Cost, only quantity may be adjusted)."""
|
||||
return bool(
|
||||
frappe.db.exists(
|
||||
"Item Standard Cost",
|
||||
{"item_code": item_code, "company": company, "docstatus": 1},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def clear_item_standard_rate_cache():
|
||||
"""Drop the request-cached results of `get_item_standard_rate` so reads after a new Item Standard
|
||||
Cost is submitted see the fresh rate instead of a value cached earlier in the same request."""
|
||||
|
||||
@@ -232,252 +232,29 @@ class TestItemStandardCost(ERPNextTestSuite):
|
||||
|
||||
self.assertFalse(frappe.db.exists("Repost Item Valuation", {"voucher_no": se0.name}))
|
||||
|
||||
def test_cancel_allowed_without_stock_activity(self):
|
||||
# No stock transaction on/after the effective date -> the standard cost can be cancelled.
|
||||
def test_cannot_cancel(self):
|
||||
item = create_standard_cost_item()
|
||||
isc = create_item_standard_cost(item.name, rate=100)
|
||||
isc.cancel()
|
||||
self.assertEqual(isc.docstatus, 2)
|
||||
|
||||
def test_cancel_blocked_with_stock_activity(self):
|
||||
# A stock transaction on/after the effective date is valued at this standard rate, so the
|
||||
# standard cost cannot be cancelled while it exists.
|
||||
item = create_standard_cost_item()
|
||||
isc = create_item_standard_cost(item.name, rate=100)
|
||||
make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=5, basic_rate=100)
|
||||
self.assertRaises(frappe.ValidationError, isc.cancel)
|
||||
|
||||
def test_cancel_reverses_revaluation(self):
|
||||
# Cancelling a rate change reverses the revaluation Stock Reconciliation it created, restoring
|
||||
# the previous stock value (the movement that triggered it predates the effective date).
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=100, effective_date=add_days(today(), -10))
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
target=TEST_WAREHOUSE,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(today(), -5),
|
||||
)
|
||||
|
||||
isc2 = create_item_standard_cost(item.name, rate=130, effective_date=today())
|
||||
self.assertTrue(isc2.revaluation_entry)
|
||||
|
||||
def stock_value():
|
||||
return flt(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": item.name, "warehouse": TEST_WAREHOUSE}, "stock_value"
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(stock_value(), 1300)
|
||||
|
||||
isc2.cancel()
|
||||
self.assertEqual(frappe.db.get_value("Stock Reconciliation", isc2.revaluation_entry, "docstatus"), 2)
|
||||
self.assertEqual(stock_value(), 1000)
|
||||
|
||||
def test_stock_reconciliation_rate_change_creates_standard_cost(self):
|
||||
# Editing the rate on a reconciliation creates a new Item Standard Cost and revalues on-hand
|
||||
# stock to it - the reconciliation is a shortcut into the standard cost, not a manual override.
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
|
||||
def test_direct_stock_reconciliation_blocked(self):
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(
|
||||
item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -10)
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=PI_STORES,
|
||||
company=PI_COMPANY,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(today(), -5),
|
||||
)
|
||||
create_item_standard_cost(item.name, rate=100)
|
||||
make_stock_entry(item_code=item.name, target=TEST_WAREHOUSE, qty=10, basic_rate=100)
|
||||
|
||||
# Same quantity, new rate 130: a new standard cost is set and the 10 on-hand units revalue to 1300.
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=10, rate=130, company=PI_COMPANY
|
||||
)
|
||||
self.assertEqual(reco.docstatus, 1)
|
||||
|
||||
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 130)
|
||||
stock_value = frappe.db.get_value(
|
||||
"Bin", {"item_code": item.name, "warehouse": PI_STORES}, "stock_value"
|
||||
)
|
||||
self.assertEqual(flt(stock_value), 1300)
|
||||
|
||||
def test_stock_reconciliation_qty_change_allowed(self):
|
||||
# A reconciliation may adjust the quantity of a Standard Cost item: stock stays valued at the
|
||||
# standard rate and the value difference is booked to the Stock Adjustment account.
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=100, company=PI_COMPANY)
|
||||
make_stock_entry(
|
||||
item_code=item.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=100
|
||||
)
|
||||
|
||||
# Count down to 8 at the standard rate: value 800, a 200 reduction against Stock Adjustment.
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=8, rate=100, company=PI_COMPANY
|
||||
)
|
||||
self.assertEqual(reco.docstatus, 1)
|
||||
|
||||
bin_data = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": item.name, "warehouse": PI_STORES},
|
||||
["actual_qty", "stock_value"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(bin_data.actual_qty), 8)
|
||||
self.assertEqual(flt(bin_data.stock_value), 800)
|
||||
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry", {"voucher_no": reco.name, "is_cancelled": 0}, "valuation_rate"
|
||||
)
|
||||
self.assertEqual(flt(sle), 100)
|
||||
|
||||
stock_adjustment = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
|
||||
booked = flt(
|
||||
frappe.db.sql(
|
||||
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
|
||||
(reco.name, stock_adjustment),
|
||||
)[0][0]
|
||||
)
|
||||
self.assertEqual(booked, 200)
|
||||
|
||||
def test_opening_reconciliation_creates_standard_cost(self):
|
||||
# With no Item Standard Cost yet, an opening Stock Reconciliation may set the rate; that rate is
|
||||
# captured into an Item Standard Cost record so the resulting stock (and later transactions) are
|
||||
# valued at it.
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_item_standard_rate,
|
||||
has_item_standard_cost,
|
||||
)
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
self.assertFalse(has_item_standard_cost(item.name, PI_COMPANY))
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
|
||||
)
|
||||
self.assertEqual(reco.docstatus, 1)
|
||||
|
||||
# The opening rate is now the item's standard cost.
|
||||
self.assertTrue(has_item_standard_cost(item.name, PI_COMPANY))
|
||||
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 100)
|
||||
|
||||
bin_data = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": item.name, "warehouse": PI_STORES},
|
||||
["actual_qty", "stock_value"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(bin_data.actual_qty), 5)
|
||||
self.assertEqual(flt(bin_data.stock_value), 500)
|
||||
|
||||
def test_opening_reconciliation_requires_rate(self):
|
||||
# An opening reconciliation for a Standard Cost item with no standard rate yet must carry a
|
||||
# positive rate - there is nothing to value the stock at otherwise.
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
create_stock_reconciliation,
|
||||
item_code=item.name,
|
||||
warehouse=PI_STORES,
|
||||
qty=5,
|
||||
rate=0,
|
||||
company=PI_COMPANY,
|
||||
warehouse=TEST_WAREHOUSE,
|
||||
qty=8,
|
||||
rate=120,
|
||||
)
|
||||
|
||||
def test_opening_reconciliation_points_standard_cost_to_itself(self):
|
||||
# The captured Item Standard Cost records the reconciliation as its revaluation entry (the reco is
|
||||
# the revaluation) instead of spawning a second one.
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
|
||||
)
|
||||
|
||||
isc = frappe.db.get_value(
|
||||
"Item Standard Cost",
|
||||
{"item_code": item.name, "company": PI_COMPANY, "docstatus": 1},
|
||||
"revaluation_entry",
|
||||
)
|
||||
self.assertEqual(isc, reco.name)
|
||||
|
||||
def test_opening_reconciliation_cancel_cancels_standard_cost(self):
|
||||
# Cancelling an opening reconciliation removes the stock it created, so the Item Standard Cost it
|
||||
# introduced is cancelled too.
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import has_item_standard_cost
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=5, rate=100, company=PI_COMPANY
|
||||
)
|
||||
isc_name = frappe.db.get_value(
|
||||
"Item Standard Cost", {"item_code": item.name, "company": PI_COMPANY, "docstatus": 1}, "name"
|
||||
)
|
||||
|
||||
reco.cancel()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Item Standard Cost", isc_name, "docstatus"), 2)
|
||||
self.assertFalse(has_item_standard_cost(item.name, PI_COMPANY))
|
||||
|
||||
def test_rate_change_reconciliation_cancel_reverts_standard_cost(self):
|
||||
# A rate-change reconciliation revalues on-hand stock only on/after its effective date, and that
|
||||
# revaluation is reversed on cancel. The pre-existing stock sits before the effective date, so no
|
||||
# live SLE is valued at the new rate once the reco's own entry is reversed. Cancelling therefore
|
||||
# cancels the Item Standard Cost it created and the item falls back to the previous standard rate.
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import get_item_standard_rate
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(
|
||||
item.name, rate=100, company=PI_COMPANY, effective_date=add_days(today(), -10)
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=item.name,
|
||||
to_warehouse=PI_STORES,
|
||||
company=PI_COMPANY,
|
||||
qty=10,
|
||||
basic_rate=100,
|
||||
posting_date=add_days(today(), -5),
|
||||
)
|
||||
|
||||
reco = create_stock_reconciliation(
|
||||
item_code=item.name, warehouse=PI_STORES, qty=10, rate=130, company=PI_COMPANY
|
||||
)
|
||||
isc_name = frappe.db.get_value("Item Standard Cost", {"revaluation_entry": reco.name}, "name")
|
||||
self.assertTrue(isc_name)
|
||||
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 130)
|
||||
|
||||
reco.cancel()
|
||||
|
||||
# The revaluation is reverted, so the standard cost it created is cancelled and the rate reverts.
|
||||
self.assertEqual(frappe.db.get_value("Item Standard Cost", isc_name, "docstatus"), 2)
|
||||
self.assertEqual(flt(get_item_standard_rate(item.name, PI_COMPANY)), 100)
|
||||
|
||||
def test_backdated_transaction_blocked(self):
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=100, effective_date=today())
|
||||
@@ -587,40 +364,6 @@ class TestItemStandardCost(ERPNextTestSuite):
|
||||
# The additional cost is credited out of its source account (it flowed into the variance).
|
||||
self.assertEqual(gl_net(additional_cost_account), -30)
|
||||
|
||||
def test_manufacturing_variance_no_stock_adjustment_entry(self):
|
||||
# With an additional cost in the mix, the net-zero Stock Adjustment reclassification must not
|
||||
# survive as a debit == credit entry: only the real accounts (variance, additional cost source,
|
||||
# stock) should be booked.
|
||||
ensure_mfg_variance_account(PI_COMPANY)
|
||||
additional_cost_account = "Expenses Included In Valuation - TCP1"
|
||||
stock_adjustment = frappe.get_cached_value("Company", PI_COMPANY, "stock_adjustment_account")
|
||||
rm = create_standard_cost_item()
|
||||
fg = create_standard_cost_item()
|
||||
create_item_standard_cost(rm.name, rate=50, company=PI_COMPANY)
|
||||
create_item_standard_cost(fg.name, rate=200, company=PI_COMPANY)
|
||||
|
||||
make_stock_entry(item_code=rm.name, to_warehouse=PI_STORES, company=PI_COMPANY, qty=10, basic_rate=50)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = "Repack"
|
||||
se.stock_entry_type = "Repack"
|
||||
se.company = PI_COMPANY
|
||||
se.append("items", {"item_code": rm.name, "s_warehouse": PI_STORES, "qty": 5})
|
||||
se.append("items", {"item_code": fg.name, "t_warehouse": PI_FG, "qty": 1, "is_finished_item": 1})
|
||||
se.append(
|
||||
"additional_costs",
|
||||
{"expense_account": additional_cost_account, "description": "Freight", "amount": 30},
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
# No Stock Adjustment entry at all - the difference is entirely the manufacturing variance.
|
||||
self.assertFalse(
|
||||
frappe.db.exists(
|
||||
"GL Entry", {"voucher_no": se.name, "account": stock_adjustment, "is_cancelled": 0}
|
||||
)
|
||||
)
|
||||
|
||||
def test_manufacturing_variance_account_required(self):
|
||||
# Without a Manufacturing Variance account, submitting a Standard Cost Manufacture/Repack must fail.
|
||||
previous = frappe.get_cached_value("Company", PI_COMPANY, "default_manufacturing_variance_account")
|
||||
@@ -741,6 +484,43 @@ class TestItemStandardCost(ERPNextTestSuite):
|
||||
# The submit must have invalidated the cache, so this reads the freshly submitted rate.
|
||||
self.assertEqual(flt(get_item_standard_rate(item.name, TEST_COMPANY)), 100)
|
||||
|
||||
def test_pr_stock_value_excludes_rejected_warehouse(self):
|
||||
# Accepted and rejected stock for one receipt row share voucher_detail_no. The standard-cost
|
||||
# SRBNB split must clear only the accepted warehouse's value, not accepted + rejected.
|
||||
from erpnext.accounts.doctype.purchase_invoice.services.gl_composer import (
|
||||
PurchaseInvoiceGLComposer,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=100, company=PI_COMPANY)
|
||||
|
||||
rejected_warehouse = create_warehouse("_Test SC Rejected Warehouse", company=PI_COMPANY)
|
||||
|
||||
# Receive 10 accepted + 2 rejected at a billed rate of 150; both SLEs value at the standard 100.
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
company=PI_COMPANY,
|
||||
warehouse=PI_STORES,
|
||||
qty=10,
|
||||
rejected_qty=2,
|
||||
rejected_warehouse=rejected_warehouse,
|
||||
rate=150,
|
||||
)
|
||||
|
||||
# Method body uses only `item`, so it can be called unbound.
|
||||
def pr_value(stock_qty):
|
||||
mock_item = frappe._dict(
|
||||
purchase_receipt=pr.name, pr_detail=pr.items[0].name, stock_qty=stock_qty
|
||||
)
|
||||
return flt(PurchaseInvoiceGLComposer.get_pr_stock_value(None, mock_item))
|
||||
|
||||
# Billing all 10: accepted only (10 * 100), not accepted + rejected (12 * 100).
|
||||
self.assertEqual(pr_value(10), 1000)
|
||||
# Billing only 4 of the 10 accepted units: pro-rated to the invoiced qty (4 * 100).
|
||||
self.assertEqual(pr_value(4), 400)
|
||||
|
||||
def test_pr_books_variance_to_ppv_account(self):
|
||||
# Receiving a Standard Cost item at a rate above the standard must book the difference to the
|
||||
# Purchase Price Variance account, not the default expense (COGS) account.
|
||||
@@ -791,88 +571,6 @@ class TestItemStandardCost(ERPNextTestSuite):
|
||||
frappe.db.set_value("Company", PI_COMPANY, "default_purchase_price_variance_account", previous)
|
||||
frappe.clear_cache(doctype="Company")
|
||||
|
||||
def test_pi_without_update_stock_does_not_rebook_variance(self):
|
||||
# The Purchase Receipt already booked the 70 variance to PPV. Billing it with a Purchase Invoice
|
||||
# that has Update Stock disabled must only clear "Stock Received But Not Billed" at the full billed
|
||||
# amount (200) - it must NOT re-book the variance to the Purchase Price Variance account.
|
||||
from erpnext.stock.doctype.purchase_receipt.mapper import make_purchase_invoice
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
ppv_account = ensure_ppv_account(PI_COMPANY)
|
||||
srbnb_account = frappe.get_cached_value("Company", PI_COMPANY, "stock_received_but_not_billed")
|
||||
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name, company=PI_COMPANY, warehouse=PI_STORES, qty=1, rate=200
|
||||
)
|
||||
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
self.assertEqual(pi.update_stock, 0)
|
||||
pi.submit()
|
||||
|
||||
def booked(account):
|
||||
return flt(
|
||||
frappe.db.sql(
|
||||
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
|
||||
(pi.name, account),
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
# No variance re-booked on the invoice; SRBNB is fully cleared at the billed value.
|
||||
self.assertEqual(booked(ppv_account), 0)
|
||||
self.assertEqual(booked(srbnb_account), 200)
|
||||
|
||||
def test_material_receipt_books_variance_to_ppv(self):
|
||||
# Receiving a Standard Cost item via Material Receipt at a manual basic rate (200) plus an
|
||||
# additional cost (100) must value stock at the standard 130 and book the rest to the Purchase
|
||||
# Price Variance account: (200*10 + 100) - 130*10 = 800.
|
||||
ppv_account = ensure_ppv_account(PI_COMPANY)
|
||||
additional_cost_account = "Expenses Included In Valuation - TCP1"
|
||||
item = create_standard_cost_item()
|
||||
create_item_standard_cost(item.name, rate=130, company=PI_COMPANY)
|
||||
|
||||
se = frappe.new_doc("Stock Entry")
|
||||
se.purpose = "Material Receipt"
|
||||
se.stock_entry_type = "Material Receipt"
|
||||
se.company = PI_COMPANY
|
||||
se.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item.name,
|
||||
"t_warehouse": PI_STORES,
|
||||
"qty": 10,
|
||||
"basic_rate": 200,
|
||||
},
|
||||
)
|
||||
se.append(
|
||||
"additional_costs",
|
||||
{"expense_account": additional_cost_account, "description": "Freight", "amount": 100},
|
||||
)
|
||||
se.insert()
|
||||
se.submit()
|
||||
|
||||
# Stock is valued at the standard rate, not the manual 200 + additional cost.
|
||||
sle = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": se.name, "is_cancelled": 0},
|
||||
["valuation_rate", "stock_value_difference"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(flt(sle.valuation_rate), 130)
|
||||
self.assertEqual(flt(sle.stock_value_difference), 1300)
|
||||
|
||||
def booked(account):
|
||||
return flt(
|
||||
frappe.db.sql(
|
||||
"select sum(debit - credit) from `tabGL Entry` where voucher_no=%s and account=%s and is_cancelled=0",
|
||||
(se.name, account),
|
||||
)[0][0]
|
||||
)
|
||||
|
||||
self.assertEqual(booked(ppv_account), 800)
|
||||
|
||||
def test_revaluation_posted_after_same_day_movement(self):
|
||||
# A movement earlier on the effective date must not end up after the revaluation, otherwise the
|
||||
# reco would backdate the current quantity ahead of it.
|
||||
|
||||
@@ -10,7 +10,7 @@ erpnext.stock.LandedCostVoucher = class LandedCostVoucher extends erpnext.stock.
|
||||
<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}:
|
||||
</h4>
|
||||
<ul>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user