mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-03 21:50:53 +00:00
Compare commits
63 Commits
chore/test
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85776c00b | ||
|
|
0f7ee3a843 | ||
|
|
e546132ac3 | ||
|
|
24a13d16bb | ||
|
|
17e7f91690 | ||
|
|
ef7fb1084d | ||
|
|
18d16fa5cf | ||
|
|
dc09362454 | ||
|
|
2000a9db36 | ||
|
|
dd35d977f7 | ||
|
|
4545dd939a | ||
|
|
7b0c35caaf | ||
|
|
341a07dffa | ||
|
|
9c911438f1 | ||
|
|
3240411876 | ||
|
|
8f96e5f2aa | ||
|
|
8456e88d93 | ||
|
|
c5ab9958ff | ||
|
|
113d914b9c | ||
|
|
ebd8547629 | ||
|
|
740c5a07ff | ||
|
|
3dfb3f385b | ||
|
|
dae90e90df | ||
|
|
d1d592cf0c | ||
|
|
23e3dd94c0 | ||
|
|
6255d99fda | ||
|
|
81d5eac0ea | ||
|
|
0b20438da9 | ||
|
|
8632019d2a | ||
|
|
c9960b4d51 | ||
|
|
2cc02e61d9 | ||
|
|
6fc28edde9 | ||
|
|
196730c535 | ||
|
|
4d39f698bd | ||
|
|
0a7abe7144 | ||
|
|
a168bb7ea4 | ||
|
|
edfa0a7a1d | ||
|
|
5888cdf3a0 | ||
|
|
a1f413e8a8 | ||
|
|
cd167bdd40 | ||
|
|
5d48c44bbb | ||
|
|
ecc8ec672b | ||
|
|
3b1e57966e | ||
|
|
974571aba7 | ||
|
|
7d917e497a | ||
|
|
e041e33860 | ||
|
|
832b5a56bf | ||
|
|
abded56174 | ||
|
|
6f866545b9 | ||
|
|
147e1539dc | ||
|
|
9980d47524 | ||
|
|
97794b7ded | ||
|
|
ef5f47fafd | ||
|
|
c51edbd88e | ||
|
|
745f657a0e | ||
|
|
5c87e2e398 | ||
|
|
3167e8ba77 | ||
|
|
94ab09e4a3 | ||
|
|
83d821d8c4 | ||
|
|
22dc51a57a | ||
|
|
df54382727 | ||
|
|
ccd2aae481 | ||
|
|
41000ea109 |
@@ -1,10 +1,59 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
aggregate_with_last_account_closing_balance,
|
||||
generate_key,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def entry(**overrides):
|
||||
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestAccountClosingBalance(ERPNextTestSuite):
|
||||
pass
|
||||
"""The closing-balance snapshot is built by merging this period's entries with the
|
||||
previous period's. These lock the merge/key logic that drives that carry-forward."""
|
||||
|
||||
def test_matching_entries_are_summed(self):
|
||||
# this is how a prior-period balance carries forward into the current one
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
|
||||
entry(
|
||||
account="Cash - _TC",
|
||||
debit=50,
|
||||
credit=20,
|
||||
debit_in_account_currency=50,
|
||||
credit_in_account_currency=20,
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 1)
|
||||
row = next(iter(merged.values()))
|
||||
self.assertEqual(row["debit"], 150)
|
||||
self.assertEqual(row["credit"], 20)
|
||||
# the account-currency columns are accumulated in the same pass
|
||||
self.assertEqual(row["debit_in_account_currency"], 150)
|
||||
self.assertEqual(row["credit_in_account_currency"], 20)
|
||||
|
||||
def test_entries_are_kept_separate_per_dimension(self):
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
|
||||
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 2)
|
||||
|
||||
def test_period_closing_flag_is_part_of_the_key(self):
|
||||
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
|
||||
# account must not merge, so the flag has to distinguish their keys
|
||||
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
|
||||
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
|
||||
self.assertNotEqual(key_reversal, key_closing)
|
||||
|
||||
@@ -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>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
{{__('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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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
|
||||
|
||||
@@ -1,8 +1,76 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt
|
||||
|
||||
from erpnext.accounts.doctype.bank_guarantee.bank_guarantee import get_voucher_details
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
BANK = "_Test BG Bank"
|
||||
|
||||
|
||||
class TestBankGuarantee(ERPNextTestSuite):
|
||||
pass
|
||||
"""Bank Guarantee records a guarantee issued/received against a customer or
|
||||
supplier. validate() needs a party; on_submit() needs the bank details filled in."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
if not frappe.db.exists("Bank", BANK):
|
||||
frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert()
|
||||
|
||||
def make_bg(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Bank Guarantee")
|
||||
doc.bg_type = args.bg_type or "Receiving"
|
||||
doc.amount = args.amount if args.amount is not None else 1000
|
||||
doc.start_date = args.start_date or "2026-06-01"
|
||||
if args.end_date:
|
||||
doc.end_date = args.end_date
|
||||
doc.customer = args.get("customer", "_Test Customer")
|
||||
doc.supplier = args.get("supplier")
|
||||
# fields on_submit requires — present by default, cleared per-test to assert the guard
|
||||
doc.bank_guarantee_number = args.get("bank_guarantee_number", "BG-001")
|
||||
doc.name_of_beneficiary = args.get("name_of_beneficiary", "Test Beneficiary")
|
||||
doc.bank = args.get("bank", BANK)
|
||||
return doc
|
||||
|
||||
def test_validate_requires_customer_or_supplier(self):
|
||||
doc = self.make_bg(customer=None)
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_submit_requires_guarantee_number(self):
|
||||
doc = self.make_bg(bank_guarantee_number="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_submit_requires_beneficiary_name(self):
|
||||
doc = self.make_bg(name_of_beneficiary="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_submit_requires_bank(self):
|
||||
doc = self.make_bg(bank="")
|
||||
doc.insert()
|
||||
self.assertRaises(frappe.ValidationError, doc.submit)
|
||||
|
||||
def test_valid_guarantee_submits(self):
|
||||
doc = self.make_bg()
|
||||
doc.insert()
|
||||
doc.submit()
|
||||
self.assertEqual(frappe.db.get_value("Bank Guarantee", doc.name, "docstatus"), 1)
|
||||
|
||||
def test_get_voucher_details_for_receiving(self):
|
||||
so = make_sales_order()
|
||||
details = get_voucher_details("Receiving", so.name)
|
||||
self.assertEqual(details.customer, so.customer)
|
||||
self.assertEqual(flt(details.grand_total), flt(so.grand_total))
|
||||
|
||||
def test_end_date_before_start_date_is_not_validated(self):
|
||||
# SUSPECTED BUG: validate() never checks that end_date >= start_date, so a
|
||||
# guarantee that expires before it starts saves cleanly. Locking the current
|
||||
# (wrong) behaviour so a future fix that adds the check trips this test.
|
||||
doc = self.make_bg(start_date="2026-06-30", end_date="2026-06-01")
|
||||
doc.insert()
|
||||
self.assertTrue(frappe.db.exists("Bank Guarantee", doc.name))
|
||||
|
||||
@@ -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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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,6 +8,7 @@ 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
|
||||
@@ -97,3 +98,40 @@ 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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def move_up(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
|
||||
@@ -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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def revise_budget(budget_name: str):
|
||||
old_budget = frappe.get_doc("Budget", budget_name)
|
||||
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
DATE = "2026-06-15"
|
||||
|
||||
|
||||
class TestCashierClosing(ERPNextTestSuite):
|
||||
pass
|
||||
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
|
||||
date/time window and rolls payments, expense, custody and returns into net_amount."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_invoice_in_window(self, rate=100):
|
||||
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
|
||||
si.posting_time = "10:30:00"
|
||||
si.submit()
|
||||
si.reload() # read outstanding_amount as persisted after submit
|
||||
return si
|
||||
|
||||
def make_closing(self, user="Administrator", payments=None, **args):
|
||||
doc = frappe.new_doc("Cashier Closing")
|
||||
doc.user = user
|
||||
doc.date = args.get("date", DATE)
|
||||
doc.from_time = args.get("from_time", "09:00:00")
|
||||
doc.time = args.get("time", "18:00:00")
|
||||
for amount in payments or []:
|
||||
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
|
||||
doc.expense = args.get("expense", 0)
|
||||
doc.custody = args.get("custody", 0)
|
||||
doc.returns = args.get("returns", 0)
|
||||
return doc
|
||||
|
||||
def test_from_time_must_be_before_to_time(self):
|
||||
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_equal_from_and_to_time_is_rejected(self):
|
||||
# validate_time uses >=, so a zero-length window is also blocked
|
||||
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
|
||||
si = self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
|
||||
doc.save()
|
||||
|
||||
# the in-window invoice is picked up as outstanding
|
||||
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
|
||||
# net = payments + outstanding + expense - custody + returns
|
||||
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
|
||||
|
||||
def test_outstanding_is_scoped_to_the_invoice_owner(self):
|
||||
# The invoice is created by Administrator; a closing for a different user does
|
||||
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
|
||||
# creator) rather than an explicit cashier/POS-user field, which is fragile when
|
||||
# invoices are created by a shared or system user.
|
||||
self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(user="Guest", payments=[500])
|
||||
doc.save()
|
||||
self.assertEqual(doc.outstanding_amount, 0)
|
||||
self.assertEqual(doc.net_amount, 500)
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, 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):
|
||||
pass
|
||||
"""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")]
|
||||
)
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
|
||||
@@ -45,6 +45,20 @@ class JournalEntryTemplate(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_party()
|
||||
self.validate_account_company()
|
||||
|
||||
def validate_account_company(self):
|
||||
"""Each row's account must belong to the template's company."""
|
||||
for account in self.accounts:
|
||||
if (
|
||||
account.account
|
||||
and frappe.get_cached_value("Account", account.account, "company") != self.company
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Account {1} does not belong to company {2}").format(
|
||||
account.idx, account.account, self.company
|
||||
)
|
||||
)
|
||||
|
||||
def validate_party(self):
|
||||
"""
|
||||
|
||||
@@ -1,9 +1,45 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestJournalEntryTemplate(ERPNextTestSuite):
|
||||
pass
|
||||
"""Journal Entry Template's only real rule is validate_party: party_type is
|
||||
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_template(self, rows, company=COMPANY):
|
||||
doc = frappe.new_doc("Journal Entry Template")
|
||||
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
|
||||
doc.company = company
|
||||
doc.voucher_type = "Journal Entry"
|
||||
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
|
||||
for row in rows:
|
||||
doc.append("accounts", row)
|
||||
return doc
|
||||
|
||||
def test_party_type_only_on_receivable_or_payable_account(self):
|
||||
# Cash is neither Receivable nor Payable, so a party_type here is invalid
|
||||
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_party_requires_party_type(self):
|
||||
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_account_from_other_company_is_rejected(self):
|
||||
other_receivable = frappe.db.get_value(
|
||||
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
|
||||
)
|
||||
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
|
||||
doc = self.make_template(
|
||||
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
@@ -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>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
|
||||
@@ -5,9 +5,59 @@ import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestModeofPayment(ERPNextTestSuite):
|
||||
pass
|
||||
"""Mode of Payment validates its per-company default accounts (account company
|
||||
must match the row, no company twice) and blocks disabling while a POS Profile
|
||||
still references it."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_mop(self, accounts=None, enabled=1):
|
||||
doc = frappe.new_doc("Mode of Payment")
|
||||
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
|
||||
doc.type = "General"
|
||||
doc.enabled = enabled
|
||||
for company, account in accounts or []:
|
||||
doc.append("accounts", {"company": company, "default_account": account})
|
||||
return doc
|
||||
|
||||
def test_valid_mode_of_payment_saves(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_account_of_wrong_company_throws(self):
|
||||
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
|
||||
doc = self.make_mop(accounts=[(COMPANY, other_account)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_repeating_company_throws(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
|
||||
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
|
||||
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
|
||||
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
|
||||
# and a mode still referenced by a POS Profile disables without complaint.
|
||||
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
|
||||
make_pos_profile() # its payments row references the "Cash" mode of payment
|
||||
cash = frappe.get_doc("Mode of Payment", "Cash")
|
||||
cash.enabled = 0
|
||||
cash.save()
|
||||
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
|
||||
|
||||
def test_disabling_unreferenced_mode_succeeds(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.enabled, 0)
|
||||
|
||||
|
||||
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):
|
||||
|
||||
@@ -1,8 +1,67 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
|
||||
get_percentage,
|
||||
get_periodwise_distribution_data,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestMonthlyDistribution(ERPNextTestSuite):
|
||||
pass
|
||||
"""Monthly Distribution spreads an amount across months. validate() enforces a
|
||||
100% total; get_percentage() sums the months that fall inside a period window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_distribution(self, allocations):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
|
||||
for month, pct in allocations:
|
||||
doc.append("percentages", {"month": month, "percentage_allocation": pct})
|
||||
return doc
|
||||
|
||||
def test_get_months_populates_twelve_even_rows(self):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = "_Test MD Even"
|
||||
doc.get_months()
|
||||
|
||||
self.assertEqual(len(doc.percentages), 12)
|
||||
self.assertEqual(doc.percentages[0].month, "January")
|
||||
self.assertEqual(doc.percentages[-1].month, "December")
|
||||
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
|
||||
for d in doc.percentages:
|
||||
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
|
||||
# the auto-populated rows round to exactly 100 and pass validation
|
||||
doc.validate()
|
||||
|
||||
def test_validate_rejects_total_other_than_100(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_get_percentage_sums_period_window(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert() # total is 100, so validate passes
|
||||
|
||||
# a quarter starting in January covers Jan+Feb+Mar
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
|
||||
# a single month picks up only that month
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
|
||||
# months with no row simply contribute 0 (there is no guard that all 12 exist)
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
|
||||
|
||||
def test_periodwise_distribution_maps_each_period(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert()
|
||||
|
||||
period_list = [
|
||||
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
|
||||
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
|
||||
]
|
||||
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
|
||||
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
|
||||
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation
|
||||
|
||||
@@ -1,9 +1,67 @@
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.party_link.party_link import create_party_link
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CUSTOMER = "_Test Customer"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_2 = "_Test Supplier 1"
|
||||
|
||||
|
||||
class TestPartyLink(ERPNextTestSuite):
|
||||
pass
|
||||
"""Party Link ties a Customer and a Supplier together as one underlying party.
|
||||
validate() constrains the primary role and blocks duplicate links."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_create_party_link_with_customer_primary(self):
|
||||
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
self.assertEqual(link.primary_role, "Customer")
|
||||
self.assertEqual(link.secondary_role, "Supplier")
|
||||
self.assertEqual(link.primary_party, CUSTOMER)
|
||||
self.assertEqual(link.secondary_party, SUPPLIER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_create_party_link_with_supplier_primary(self):
|
||||
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
|
||||
self.assertEqual(link.primary_role, "Supplier")
|
||||
self.assertEqual(link.secondary_role, "Customer")
|
||||
self.assertEqual(link.primary_party, SUPPLIER)
|
||||
self.assertEqual(link.secondary_party, CUSTOMER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_primary_role_must_be_customer_or_supplier(self):
|
||||
doc = frappe.new_doc("Party Link")
|
||||
doc.primary_role = "Employee"
|
||||
doc.primary_party = CUSTOMER
|
||||
doc.secondary_role = "Supplier"
|
||||
doc.secondary_party = SUPPLIER
|
||||
# validate() alone isolates the role rule from the dynamic-link checks
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_duplicate_link_throws(self):
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
dup = frappe.new_doc("Party Link")
|
||||
dup.primary_role = "Customer"
|
||||
dup.primary_party = CUSTOMER
|
||||
dup.secondary_role = "Supplier"
|
||||
dup.secondary_party = SUPPLIER
|
||||
self.assertRaises(frappe.ValidationError, dup.insert)
|
||||
|
||||
def test_party_can_wrongly_be_primary_in_two_links(self):
|
||||
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
|
||||
# *primary* in another link isn't blocked, so one customer can be linked to two
|
||||
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
|
||||
# behaviour so a fix that blocks primary reuse trips this test.
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
link2 = frappe.new_doc("Party Link")
|
||||
link2.primary_role = "Customer"
|
||||
link2.primary_party = CUSTOMER
|
||||
link2.secondary_role = "Supplier"
|
||||
link2.secondary_party = SUPPLIER_2
|
||||
link2.insert()
|
||||
self.assertTrue(frappe.db.exists("Party Link", link2.name))
|
||||
|
||||
@@ -414,21 +414,17 @@ 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");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
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");
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -718,7 +718,7 @@ class PaymentRequest(Document):
|
||||
row_number += TO_SKIP_NEW_ROW
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def make_payment_request(**args):
|
||||
"""Make payment request"""
|
||||
|
||||
|
||||
@@ -41,21 +41,17 @@ 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");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
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");
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</ul>
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
<h4><i class="fa fa-question-sign"></i>
|
||||
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
|
||||
${__("How Pricing Rule is applied?")}
|
||||
</h4>
|
||||
<ol>
|
||||
|
||||
@@ -106,6 +106,8 @@ def get_pr_instance(doc: str):
|
||||
"party",
|
||||
"receivable_payable_account",
|
||||
"default_advance_account",
|
||||
"bank_cash_account",
|
||||
"cost_center",
|
||||
"from_invoice_date",
|
||||
"to_invoice_date",
|
||||
"from_payment_date",
|
||||
|
||||
@@ -1,11 +1,73 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
get_pr_instance,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestProcessPaymentReconciliation(ERPNextTestSuite):
|
||||
pass
|
||||
"""Process Payment Reconciliation validates its accounts against the company,
|
||||
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_ppr(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Process Payment Reconciliation")
|
||||
doc.company = COMPANY
|
||||
doc.party_type = "Customer"
|
||||
doc.party = "_Test Customer"
|
||||
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
|
||||
doc.bank_cash_account = args.get("bank_cash_account")
|
||||
doc.from_invoice_date = args.get("from_invoice_date")
|
||||
doc.to_invoice_date = args.get("to_invoice_date")
|
||||
return doc
|
||||
|
||||
def other_company_account(self, **extra):
|
||||
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
|
||||
account = frappe.db.get_value("Account", filters, "name")
|
||||
self.assertTrue(account, "need a matching account in _Test Company 1")
|
||||
return account
|
||||
|
||||
def test_receivable_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_bank_cash_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(bank_cash_account=self.other_company_account())
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_submit_sets_status_to_queued(self):
|
||||
doc = self.make_ppr()
|
||||
doc.insert()
|
||||
doc.submit()
|
||||
self.assertEqual(doc.status, "Queued")
|
||||
|
||||
def test_get_pr_instance_copies_filters_and_caps_limits(self):
|
||||
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.company, COMPANY)
|
||||
self.assertEqual(pr.party, "_Test Customer")
|
||||
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
|
||||
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
|
||||
# the tool run is capped so a single process can't fetch unbounded rows
|
||||
self.assertEqual(pr.invoice_limit, 1000)
|
||||
self.assertEqual(pr.payment_limit, 1000)
|
||||
|
||||
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
|
||||
doc = self.make_ppr(bank_cash_account="Cash - _TC")
|
||||
doc.cost_center = "_Test Cost Center - _TC"
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
|
||||
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")
|
||||
|
||||
@@ -113,3 +113,38 @@ 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,11 +1,56 @@
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from unittest.mock import patch
|
||||
|
||||
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):
|
||||
pass
|
||||
"""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)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -131,7 +130,6 @@ 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)
|
||||
@@ -331,33 +329,25 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
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,
|
||||
)
|
||||
# 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,
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -530,95 +520,6 @@ 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,3 +121,65 @@ 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)
|
||||
|
||||
@@ -79,7 +79,9 @@ def get_plan_rate(
|
||||
start_date = getdate(start_date)
|
||||
end_date = getdate(end_date)
|
||||
|
||||
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
|
||||
delta = relativedelta.relativedelta(end_date, start_date)
|
||||
# include the years component so cross-year spans aren't under-counted
|
||||
no_of_months = delta.years * 12 + delta.months + 1
|
||||
cost = plan.cost * no_of_months
|
||||
|
||||
# Adjust cost if start or end date is not month start or end
|
||||
|
||||
@@ -1,8 +1,54 @@
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSubscriptionPlan(ERPNextTestSuite):
|
||||
pass
|
||||
"""Subscription Plan validates its interval and computes a rate. The Monthly
|
||||
Rate branch multiplies cost by the number of months in the billing window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_plan(self, **args):
|
||||
args = frappe._dict(args)
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
|
||||
plan.item = args.item or "_Test Item"
|
||||
plan.currency = args.currency or "INR"
|
||||
plan.price_determination = args.price_determination
|
||||
plan.cost = args.cost or 0
|
||||
plan.billing_interval = args.billing_interval or "Month"
|
||||
plan.billing_interval_count = (
|
||||
args.billing_interval_count if args.billing_interval_count is not None else 1
|
||||
)
|
||||
return plan
|
||||
|
||||
def test_billing_interval_count_must_be_positive(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
|
||||
self.assertRaises(frappe.ValidationError, plan.insert)
|
||||
|
||||
def test_fixed_rate_applies_prorate_factor(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
|
||||
plan.insert()
|
||||
self.assertEqual(get_plan_rate(plan.name), 100)
|
||||
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
|
||||
|
||||
def test_monthly_rate_within_year(self):
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
|
||||
self.assertEqual(rate, 300)
|
||||
|
||||
def test_monthly_rate_across_year_boundary(self):
|
||||
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
|
||||
# 2-month remainder that relativedelta.months alone would give
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
|
||||
self.assertEqual(rate, 1400)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Banking",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.924019",
|
||||
"modified": "2026-07-03 13:43:50.924019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Banking",
|
||||
@@ -43,7 +43,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "tool",
|
||||
"icon": "wrench",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Reconciliation",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Budgeting",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-02 04:24:48.116724",
|
||||
"modified": "2026-07-03 04:24:48.116724",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budgeting",
|
||||
@@ -59,7 +59,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.095321",
|
||||
"modified": "2026-07-03 13:44:08.095321",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -284,7 +284,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"idx": 4,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
@@ -587,7 +587,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.471142",
|
||||
"modified": "2026-07-03 13:44:08.471142",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -622,7 +622,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -635,7 +635,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -786,7 +786,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"icon": "coins",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.184761",
|
||||
"modified": "2026-07-03 13:43:50.184761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -44,7 +44,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "money-coins-1",
|
||||
"icon": "coins",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"icon": "coins",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Share Management",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:51.040978",
|
||||
"modified": "2026-07-03 13:43:51.040978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Management",
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "customer",
|
||||
"icon": "user",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Shareholder",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Subscriptions",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 14:08:36.999272",
|
||||
"modified": "2026-07-03 14:08:36.999272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscriptions",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "money-coins-1",
|
||||
"icon": "coins",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Taxes",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-06-14 13:43:50.894825",
|
||||
"modified": "2026-07-03 13:43:50.894825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "stock",
|
||||
"icon": "package",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Tax Template",
|
||||
|
||||
@@ -587,3 +587,47 @@ 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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "assets",
|
||||
"icon": "archive",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Assets",
|
||||
@@ -199,7 +199,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.417956",
|
||||
"modified": "2026-07-03 13:44:08.417956",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"module_onboarding": "Asset Onboarding",
|
||||
@@ -217,7 +217,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -230,7 +230,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -295,7 +295,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"icon": "rocket",
|
||||
"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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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": "buying",
|
||||
"icon": "shopping-cart",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Buying",
|
||||
@@ -501,7 +501,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:43:50.509039",
|
||||
"modified": "2026-07-03 13:43:50.509039",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"module_onboarding": "Buying Onboarding",
|
||||
@@ -532,7 +532,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -545,7 +545,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -610,7 +610,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "liabilities",
|
||||
"icon": "scale",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
|
||||
@@ -1724,7 +1724,7 @@ def get_missing_company_details(doctype: str, docname: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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");
|
||||
},
|
||||
"fa fa-list",
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +380,7 @@ def get_lead_with_phone_number(number):
|
||||
return lead
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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": "crm",
|
||||
"icon": "handshake",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "CRM",
|
||||
@@ -421,7 +421,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:08.297053",
|
||||
"modified": "2026-07-03 13:44:08.297053",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM",
|
||||
@@ -471,7 +471,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -510,7 +510,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "customer",
|
||||
"icon": "user",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
@@ -644,7 +644,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"icon": "rocket",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
@@ -776,7 +776,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sell",
|
||||
"icon": "store",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Campaign",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"icon_type": "Folder",
|
||||
"idx": 1,
|
||||
"label": "Accounting",
|
||||
"link_to": "",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-27 17:04:04.351402",
|
||||
"modified": "2026-07-03 17:04:04.351402",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "assets",
|
||||
"icon": "archive",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Assets",
|
||||
"link_to": "Assets",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.220411",
|
||||
"modified": "2026-07-03 20:07:01.220411",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Assets",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "expenses",
|
||||
"icon": "chart-pie",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-23 14:39:30.839274",
|
||||
"modified": "2026-07-03 14:39:30.839274",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Budget",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "buying",
|
||||
"icon": "shopping-cart",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Buying",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.196163",
|
||||
"modified": "2026-07-03 20:07:01.196163",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "crm",
|
||||
"icon": "handshake",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "CRM",
|
||||
"link_to": "CRM",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-06 14:54:05.112927",
|
||||
"modified": "2026-07-03 14:54:05.112927",
|
||||
"modified_by": "Administrator",
|
||||
"name": "CRM",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "setting",
|
||||
"icon": "settings",
|
||||
"icon_type": "Link",
|
||||
"idx": 10,
|
||||
"label": "ERPNext Settings",
|
||||
"link_to": "ERPNext Settings",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "",
|
||||
"modified": "2026-01-09 14:59:56.044037",
|
||||
"modified": "2026-07-03 14:59:56.044037",
|
||||
"modified_by": "Administrator",
|
||||
"name": "ERPNext Settings",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Home",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.174950",
|
||||
"modified": "2026-07-03 20:07:01.174950",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Home",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "accounting",
|
||||
"icon": "wallet",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Invoicing",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-23 15:17:23.564795",
|
||||
"modified": "2026-07-03 15:17:23.564795",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Invoicing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "organization",
|
||||
"icon": "factory",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Manufacturing",
|
||||
"link_to": "Manufacturing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.246693",
|
||||
"modified": "2026-07-03 20:07:01.246693",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Manufacturing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "project",
|
||||
"icon": "folder-kanban",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Projects",
|
||||
"link_to": "Projects",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.226383",
|
||||
"modified": "2026-07-03 20:07:01.226383",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Projects",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "quality",
|
||||
"icon": "shield-check",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Quality",
|
||||
"link_to": "Quality",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.239523",
|
||||
"modified": "2026-07-03 20:07:01.239523",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Quality",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "sell",
|
||||
"icon": "store",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Selling",
|
||||
"link_to": "Selling",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.189446",
|
||||
"modified": "2026-07-03 20:07:01.189446",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Selling",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "stock",
|
||||
"icon": "package",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Stock",
|
||||
"link_to": "Stock",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-01 20:07:01.212940",
|
||||
"modified": "2026-07-03 20:07:01.212940",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Stock",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "getting-started",
|
||||
"icon": "package-2",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Subcontracting",
|
||||
"link_to": "Subcontracting",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "/assets/erpnext/desktop_icons/subcontracting.svg",
|
||||
"modified": "2026-01-01 20:07:01.323508",
|
||||
"modified": "2026-07-03 20:07:01.323508",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Subcontracting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "support",
|
||||
"icon": "headset",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Support",
|
||||
"link_to": "Support",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-01-06 14:53:54.100467",
|
||||
"modified": "2026-07-03 14:53:54.100467",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Support",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_plaid_configuration():
|
||||
return "disabled"
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
response = frappe.parse_json(response)
|
||||
bank = frappe.parse_json(bank)
|
||||
|
||||
@@ -86,6 +86,10 @@ 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
|
||||
|
||||
@@ -125,6 +129,7 @@ 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,6 +1061,9 @@ 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",
|
||||
@@ -1071,9 +1074,301 @@ 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.556)
|
||||
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)
|
||||
|
||||
def test_secondary_items_without_sfg(self):
|
||||
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:
|
||||
|
||||
@@ -169,6 +169,29 @@ 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,6 +691,28 @@ 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,6 +288,7 @@ 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()
|
||||
@@ -975,6 +976,9 @@ 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()
|
||||
|
||||
@@ -1070,7 +1074,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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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("es-line-down", "sm", "mb-1"));
|
||||
else $(e.currentTarget).html(frappe.utils.icon("es-line-up", "sm", "mb-1"));
|
||||
$(e.currentTarget).html(frappe.utils.icon("chevron-down", "sm", "mb-1"));
|
||||
else $(e.currentTarget).html(frappe.utils.icon("chevron-up", "sm", "mb-1"));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ class Workstation(Document):
|
||||
|
||||
return schedule_date
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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-dot-vertical">
|
||||
<use href="#icon-ellipsis-vertical">
|
||||
</use>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "organization",
|
||||
"icon": "building-2",
|
||||
"idx": 1,
|
||||
"is_hidden": 0,
|
||||
"label": "Manufacturing",
|
||||
@@ -432,7 +432,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:07.420267",
|
||||
"modified": "2026-07-03 13:44:07.420267",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"module_onboarding": "Manufacturing Onboarding",
|
||||
@@ -463,7 +463,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -476,7 +476,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -528,7 +528,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "stock",
|
||||
"icon": "package",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Stock Entry",
|
||||
@@ -541,7 +541,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "getting-started",
|
||||
"icon": "rocket",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Material Planning",
|
||||
@@ -627,7 +627,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "tool",
|
||||
"icon": "wrench",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Tools",
|
||||
|
||||
@@ -628,7 +628,7 @@ def allow_to_make_project_update(project, time, frequency):
|
||||
return True
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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": "project",
|
||||
"icon": "folder-kanban",
|
||||
"idx": 1,
|
||||
"is_hidden": 0,
|
||||
"label": "Projects",
|
||||
@@ -367,7 +367,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-01 13:20:50.651608",
|
||||
"modified": "2026-07-03 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": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -413,7 +413,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -427,7 +427,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "projects",
|
||||
"icon": "folder-kanban",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Project",
|
||||
|
||||
@@ -111,7 +111,7 @@ class BOMConfigurator {
|
||||
this.frm?.doc.docstatus === 0
|
||||
? [
|
||||
{
|
||||
label: __(frappe.utils.icon("edit", "sm") + " BOM"),
|
||||
label: __(frappe.utils.icon("pencil", "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("add", "sm") + " Raw Material"),
|
||||
label: __(frappe.utils.icon("plus", "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("add", "sm") + " Sub Assembly"),
|
||||
label: __(frappe.utils.icon("plus", "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("add", "sm") + " Phantom Item"),
|
||||
label: __(frappe.utils.icon("plus", "sm") + " Phantom Item"),
|
||||
click: function (node) {
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
view.events.add_sub_assembly(node, view, true);
|
||||
|
||||
@@ -118,7 +118,6 @@ 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("call")}
|
||||
${frappe.utils.icon("phone")}
|
||||
</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-url" class="like-icon"></use>
|
||||
<use href="#icon-link" 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-small-message"></use>
|
||||
<use href="#icon-message-circle"></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-small-message"></use>
|
||||
<use href="#icon-message-circle"></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-add"></use>
|
||||
<use href="#icon-plus"></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-edit"></use></svg>
|
||||
<svg class="icon icon-sm"><use xlink:href="#icon-pencil"></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("close", "xs", "es-icon")}
|
||||
${frappe.utils.icon("x", "xs")}
|
||||
</a>
|
||||
`;
|
||||
|
||||
|
||||
@@ -148,7 +148,7 @@ def get_children(
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "quality",
|
||||
"icon": "shield-check",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Quality",
|
||||
@@ -161,7 +161,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:07.920643",
|
||||
"modified": "2026-07-03 13:44:07.920643",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Quality Management",
|
||||
"name": "Quality",
|
||||
@@ -178,7 +178,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -217,7 +217,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "review",
|
||||
"icon": "star",
|
||||
"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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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
|
||||
},
|
||||
});
|
||||
},
|
||||
"fa fa-download",
|
||||
null,
|
||||
"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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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("close", "sm")}
|
||||
${frappe.utils.icon("x", "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("close", "xs", "es-icon")}
|
||||
${frappe.utils.icon("x", "xs")}
|
||||
</a>`
|
||||
);
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ erpnext.SalesFunnel = class SalesFunnel {
|
||||
function () {
|
||||
me.get_data();
|
||||
},
|
||||
"fa fa-refresh"
|
||||
"refresh-cw"
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
"creation": "2013-10-04 13:17:18.000000",
|
||||
"docstatus": 0,
|
||||
"doctype": "Page",
|
||||
"icon": "fa fa-filter",
|
||||
"icon": "funnel",
|
||||
"idx": 1,
|
||||
"modified": "2013-10-04 13:17:18.000000",
|
||||
"modified": "2026-07-03 13:17:18.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "sales-funnel",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "sell",
|
||||
"icon": "store",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Selling",
|
||||
@@ -622,7 +622,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-06-14 13:44:07.820564",
|
||||
"modified": "2026-07-03 13:44:07.820564",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"module_onboarding": "Selling Onboarding",
|
||||
@@ -653,7 +653,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "home",
|
||||
"icon": "house",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -666,7 +666,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart",
|
||||
"icon": "chart-column",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -692,7 +692,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "sell",
|
||||
"icon": "store",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Sales Order",
|
||||
@@ -839,7 +839,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "stock",
|
||||
"icon": "package",
|
||||
"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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
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()
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user