Compare commits

..

1 Commits

Author SHA1 Message Date
Nabin Hait
5769c86391 fix: reject negative tax rate in Item Tax Template 2026-07-03 14:13:49 +05:30
197 changed files with 1345 additions and 6806 deletions

View File

@@ -1,59 +1,10 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
aggregate_with_last_account_closing_balance,
generate_key,
)
# import frappe
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):
"""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)
pass

View File

@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<p>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
</p>
</td></tr>

View File

@@ -188,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
return {"balance": 0, "date": None}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
"""
Set the closing balance as per statement for a bank account and date

View File

@@ -1,76 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, 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):
"""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))
pass

View File

@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def update_bank_transaction(
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
):
@@ -146,7 +146,7 @@ def update_bank_transaction(
)[0]
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_journal_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_payment_entry_bts(
bank_transaction_name: str,
reference_number: str | None = None,
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
return output
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_internal_transfer(
bank_transaction_name: str | int,
posting_date: str | date,
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
return alert_message, indicator
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
# updated clear date of all the vouchers based on the bank transaction
vouchers = frappe.parse_json(vouchers)

View File

@@ -8,7 +8,6 @@ from frappe.utils import add_days, today
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
auto_reconcile_vouchers,
get_auto_reconcile_message,
get_bank_transactions,
)
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
@@ -98,40 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
# assert API output post reconciliation
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
self.assertEqual(len(transactions), 0)
def make_bank_transaction(self, date, deposit=100):
return (
frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": date,
"deposit": deposit,
"bank_account": self.bank_account,
"currency": "INR",
}
)
.save()
.submit()
)
def test_get_bank_transactions_excludes_dates_after_to_date(self):
self.make_bank_transaction(date=today())
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
self.assertEqual(names, [])
def test_auto_reconcile_message_for_no_matches(self):
message, indicator = get_auto_reconcile_message([], [])
self.assertEqual(indicator, "blue")
self.assertIn("No matches", message)
def test_auto_reconcile_message_counts_and_pluralizes(self):
# reconciled count is reported and the indicator turns green
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
self.assertEqual(indicator, "green")
self.assertIn("2 Transaction(s) Reconciled", message)
# partially-reconciled label is singular for one, plural for many
singular, _ = get_auto_reconcile_message(["p1"], [])
self.assertIn("1 Transaction Partially Reconciled", singular)
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
self.assertIn("2 Transactions Partially Reconciled", plural)

View File

@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
"""
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction

View File

@@ -34,7 +34,7 @@ def upload_bank_statement():
return {"columns": columns, "data": data}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_bank_entries(columns: str, data: str | list, bank_account: str):
header_map = get_header_mapping(columns, bank_account)

View File

@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
self.get_report_summary()
self.update_node()
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def bisect_left(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Left"))
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def bisect_right(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
else:
frappe.msgprint(_("No more children on Right"))
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def move_up(self):
if self.current_node is not None:
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)

View File

@@ -1,47 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import datetime
# import frappe
import frappe
from frappe.utils import getdate
from erpnext.tests.utils import ERPNextTestSuite
class TestBisectAccountingStatements(ERPNextTestSuite):
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
These cover the date validation and that the bisection cleanly partitions the range."""
def setUp(self):
frappe.set_user("Administrator")
frappe.db.delete("Bisect Nodes")
def _leaf_days(self):
leaves = frappe.get_all(
"Bisect Nodes",
filters={"left_child": ["is", "not set"]},
fields=["period_from_date", "period_to_date"],
)
# every leaf spans a single day
for leaf in leaves:
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
def test_validate_dates_rejects_reversed_range(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.from_date = "2026-01-08"
doc.to_date = "2026-01-01"
self.assertRaises(frappe.ValidationError, doc.validate)
def test_bfs_partitions_range_into_single_days(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
def test_dfs_produces_the_same_partition_as_bfs(self):
doc = frappe.new_doc("Bisect Accounting Statements")
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
pass

View File

@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
return from_year.year_start_date, to_year.year_end_date
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def revise_budget(budget_name: str):
old_budget = frappe.get_doc("Budget", budget_name)

View File

@@ -1,67 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2015, 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):
"""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)
pass

View File

@@ -1,54 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
build_forest,
validate_columns,
validate_missing_roots,
)
from erpnext.tests.utils import ERPNextTestSuite
# columns: account_name, parent_account, account_number, parent_account_number,
# is_group, account_type, root_type, account_currency
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
class TestChartofAccountsImporter(ERPNextTestSuite):
"""The importer parses an uploaded CoA into a nested tree and validates its
shape. These cover the parsing/validation helpers without a file upload."""
def test_validate_columns_rejects_blank_file(self):
self.assertRaises(frappe.ValidationError, validate_columns, [])
def test_validate_columns_requires_eight_columns(self):
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
# the standard template width passes
validate_columns([ROOT])
def test_build_forest_nests_child_under_parent(self):
forest = build_forest([ROOT, CHILD])
self.assertIn("Assets", forest)
self.assertIn("Cash", forest["Assets"])
def test_build_forest_rejects_unknown_parent(self):
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
def test_build_forest_requires_account_name(self):
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
def test_validate_missing_roots_requires_all_root_types(self):
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
self.assertRaises(
frappe.ValidationError,
validate_missing_roots,
[{"root_type": rt} for rt in present],
)
# all five root types present -> no error
validate_missing_roots(
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
)
pass

View File

@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
pass
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_or_update_cheque_print_format(template_name: str):
frappe.only_for("System Manager")

View File

@@ -298,65 +298,3 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
for key, _val in expected_data.items():
self.assertEqual(expected_data.get(key), account_details.get(key))
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
"""Validation and gain/loss calculation paths, exercised on the document directly
so they don't need the multi-currency GL setup the integration tests above build."""
def setUp(self):
frappe.set_user("Administrator")
self.company = "_Test Company"
def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05):
doc = frappe.new_doc("Exchange Rate Revaluation")
doc.company = self.company
doc.posting_date = today()
doc.rounding_loss_allowance = rounding_loss_allowance
for row in rows:
doc.append("accounts", row)
return doc
def test_rounding_loss_allowance_must_be_between_0_and_1(self):
for bad in (-0.1, 1, 1.5):
doc = self._revaluation_with_rows([], rounding_loss_allowance=bad)
self.assertRaises(frappe.ValidationError, doc.validate)
# values inside [0, 1) are accepted, at the lower bound and mid-range
for good in (0.0, 0.5):
self._revaluation_with_rows([], rounding_loss_allowance=good).validate()
def test_gain_loss_computed_and_split_by_zero_balance(self):
doc = self._revaluation_with_rows(
[
# open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
# already-settled (zero_balance) row carries a booked loss of 40
{"zero_balance": 1, "gain_loss": -40},
]
)
doc.validate()
# gain_loss is derived only for open rows; the zero-balance row keeps its value
self.assertEqual(doc.accounts[0].gain_loss, 100)
self.assertEqual(doc.gain_loss_unbooked, 100)
self.assertEqual(doc.gain_loss_booked, -40)
self.assertEqual(doc.total_gain_loss, 60)
def test_before_submit_drops_rows_without_gain_loss(self):
doc = self._revaluation_with_rows(
[
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500},
]
)
doc.validate() # second row nets to a 0 gain_loss
doc.remove_accounts_without_gain_loss()
self.assertEqual(len(doc.accounts), 1)
self.assertEqual(doc.accounts[0].gain_loss, 100)
def test_before_submit_requires_at_least_one_gain_loss_row(self):
doc = self._revaluation_with_rows(
[{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}]
)
doc.validate()
self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss)

View File

@@ -5,6 +5,7 @@
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.utils import flt
class ItemTaxTemplate(Document):
@@ -29,6 +30,12 @@ class ItemTaxTemplate(Document):
def validate(self):
self.set_zero_rate_for_not_applicable_tax()
self.validate_tax_accounts()
self.validate_tax_rates()
def validate_tax_rates(self):
for row in self.get("taxes"):
if flt(row.tax_rate) < 0:
frappe.throw(_("Row {0}: Tax Rate cannot be negative").format(row.idx))
def set_zero_rate_for_not_applicable_tax(self):
"""Ensure tax_rate is 0 for any row marked as not applicable."""

View File

@@ -54,9 +54,6 @@ class TestItemTaxTemplate(ERPNextTestSuite):
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, 0)
def test_negative_tax_rate_is_accepted(self):
# SUSPECTED BUG: validate never bounds tax_rate, so a negative (or >100) rate
# saves silently. Locking the current (wrong) behaviour.
def test_negative_tax_rate_is_rejected(self):
doc = self.make_template([(TAX_ACCOUNT, -5, 0)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, -5)
self.assertRaises(frappe.ValidationError, doc.insert)

View File

@@ -45,20 +45,6 @@ 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):
"""

View File

@@ -1,45 +1,9 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2020, 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):
"""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)
pass

View File

@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
${__("Notes")}
</h4>
<ul>

View File

@@ -5,59 +5,9 @@ import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestModeofPayment(ERPNextTestSuite):
"""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)
pass
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):

View File

@@ -1,67 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors 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):
"""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
pass

View File

@@ -1,67 +1,9 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2021, 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):
"""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))
pass

View File

@@ -414,17 +414,21 @@ frappe.ui.form.on("Payment Entry", {
show_general_ledger: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.posting_date,
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
}
},

View File

@@ -2317,65 +2317,3 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
customer.save()
customer = customer.name
return customer
class TestPaymentEntryValidation(ERPNextTestSuite):
"""Field-level validations invoked on the document directly, covering branches the
integration suite above doesn't reach (no GL / reconciliation setup needed)."""
def make_pe(self, **fields):
doc = frappe.new_doc("Payment Entry")
doc.update(fields)
return doc
def test_payment_type_must_be_a_known_value(self):
self.assertRaises(frappe.ValidationError, self.make_pe(payment_type="Foo").validate_payment_type)
self.make_pe(payment_type="Receive").validate_payment_type() # valid value passes
def test_nonexistent_party_is_rejected(self):
doc = self.make_pe(party_type="Customer", party="__No Such Customer__")
self.assertRaises(frappe.ValidationError, doc.validate_party_details)
def test_amount_and_exchange_rate_fields_are_mandatory(self):
# every field but target_exchange_rate is set, so that missing one raises
doc = self.make_pe(
paid_amount=100, received_amount=100, source_exchange_rate=1, target_exchange_rate=0
)
self.assertRaises(frappe.ValidationError, doc.validate_mandatory)
def test_received_amount_cannot_exceed_paid_in_same_currency(self):
doc = self.make_pe(
paid_from_account_currency="INR",
paid_to_account_currency="INR",
paid_amount=100,
received_amount=150,
)
self.assertRaises(frappe.ValidationError, doc.validate_received_amount)
# received <= paid is fine
doc.received_amount = 50
doc.validate_received_amount()
def test_duplicate_reference_rows_are_rejected(self):
doc = self.make_pe()
for _ in range(2):
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-X", "allocated_amount": 100},
)
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_entry)
def test_receive_from_customer_against_negative_outstanding_is_rejected(self):
doc = self.make_pe(party_type="Customer", payment_type="Receive")
doc.append(
"references",
{"reference_doctype": "Sales Invoice", "reference_name": "SI-Y", "allocated_amount": -100},
)
self.assertRaises(frappe.ValidationError, doc.validate_payment_type_with_outstanding)
def test_bank_transaction_requires_a_reference_number(self):
doc = self.make_pe(payment_type="Pay", paid_from="_Test Bank - _TC")
self.assertRaises(frappe.ValidationError, doc.validate_transaction_reference)
# supplying the reference details clears the requirement
doc.reference_no = "TXN-1"
doc.reference_date = "2026-06-15"
doc.validate_transaction_reference()

View File

@@ -718,7 +718,7 @@ class PaymentRequest(Document):
row_number += TO_SKIP_NEW_ROW
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_payment_request(**args):
"""Make payment request"""

View File

@@ -41,17 +41,21 @@ frappe.ui.form.on("Period Closing Voucher", {
refresh: function (frm) {
if (frm.doc.docstatus > 0) {
frm.add_custom_button(__("Ledger"), function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
});
frm.add_custom_button(
__("Ledger"),
function () {
frappe.route_options = {
voucher_no: frm.doc.name,
from_date: frm.doc.period_start_date,
to_date: frm.doc.period_end_date,
company: frm.doc.company,
categorize_by: "",
show_cancelled_entries: frm.doc.docstatus === 2,
};
frappe.set_route("query-report", "General Ledger");
},
"fa fa-table"
);
}
},
});

View File

@@ -1,34 +0,0 @@
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# Regression test for https://github.com/frappe/erpnext/issues/56501
# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos'
# when calling reset_mode_of_payments on a draft POS Invoice.
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import (
POSInvoiceTestMixin,
create_pos_invoice,
)
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin):
def setUp(self):
super().setUp()
create_opening_entry(self.pos_profile, self.test_user.name)
def test_reset_mode_of_payments_does_not_raise_attribute_error(self):
"""Calling reset_mode_of_payments on a draft POS Invoice must not raise
AttributeError for the missing is_created_using_pos attribute.
update_multi_mode_option accesses doc.is_created_using_pos, which is a
field on SalesInvoice but does not exist on POSInvoice, causing the error
reported in #56501 when a user tries to edit a saved draft order.
"""
inv = create_pos_invoice(do_not_submit=True)
# This call must not raise AttributeError on the missing field.
inv.reset_mode_of_payments()
# Payments should have been repopulated from the POS profile.
self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset")

View File

@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
<tr><td>
<h4>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
<i class="fa fa-hand-right"></i>
${__("Notes")}
</h4>
<ul>
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
</ul>
</td></tr>
<tr><td>
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
<h4><i class="fa fa-question-sign"></i>
${__("How Pricing Rule is applied?")}
</h4>
<ol>

View File

@@ -106,8 +106,6 @@ 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",

View File

@@ -1,73 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, 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):
"""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")
pass

View File

@@ -89,55 +89,50 @@ class ProcessPeriodClosingVoucher(Document):
cancel_pcv_processing(self.name)
def initialize_parallel_threads(docname: str):
threads = 4
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(threads)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
x.name,
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=x.name,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
def start_pcv_processing(docname: str):
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
initialize_parallel_threads(docname)
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if normal_balances := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(4)
.for_update(skip_locked=True)
.run(as_dict=True)
):
if not is_scheduler_inactive():
for x in normal_balances:
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": x.processing_date,
"parent": docname,
"report_type": x.report_type,
"parentfield": x.parentfield,
},
"status",
"Running",
)
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
timeout=timeout,
is_async=True,
enqueue_after_commit=True,
docname=docname,
date=x.processing_date,
report_type=x.report_type,
parentfield=x.parentfield,
)
else:
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@frappe.whitelist()
@@ -255,11 +250,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
@frappe.whitelist()
def schedule_next_date(docname: str):
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
.limit(1)
@@ -269,15 +264,15 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
to_process[0].name,
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
"status",
"Running",
)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.enqueue(
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
queue="long",
@@ -285,7 +280,6 @@ def schedule_next_date(docname: str):
is_async=True,
enqueue_after_commit=True,
docname=docname,
row_name=to_process[0].name,
date=to_process[0].processing_date,
report_type=to_process[0].report_type,
parentfield=to_process[0].parentfield,
@@ -450,11 +444,6 @@ def summarize_and_post_ledger_entries(docname):
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
# keep transaction on PPCV and PPCVD short
# prevents concurrency errors - REPEATABLE READ
if not frappe.in_test:
frappe.db.commit() # nosemgrep
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
@@ -540,10 +529,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
def process_individual_date(docname: str, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
"status",
)
if current_date_status != "Running":
@@ -591,20 +580,17 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
"status",
"Completed",
)
# commit heavy computation before touching PPCV or PPCVD
if not frappe.in_test:
frappe.db.commit() # nosemgrep
# chain call
schedule_next_date(docname)

View File

@@ -48,27 +48,18 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
ppcv.save()
return ppcv
def set_processing_date_status(self, row_name, status):
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"status",
status,
)
def get_row_name(self, ppcv_name, rpt_type, parentfield):
return frappe.db.get_all(
"Process Period Closing Voucher Detail",
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
order_by="report_type, idx",
pluck="name",
limit=1,
)[0]
def get_processing_date_closing_balance(self, row_name):
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
row_name,
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
"closing_balance",
)
@@ -106,10 +97,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
@@ -125,10 +117,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Balance sheet balance
rpt_type = "Balance Sheet"
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
@@ -145,10 +138,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
self.set_processing_date_status(row_name, "Running")
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
bal = frappe.parse_json(
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
)
self.assertEqual(len(bal), 2)
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
expected_opening_cash = {

View File

@@ -1,7 +1,7 @@
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
import frappe
# import frappe
from frappe.model.document import Document
@@ -24,10 +24,3 @@ class ProcessPeriodClosingVoucherDetail(Document):
# end: auto-generated types
pass
def on_doctype_update():
frappe.db.add_index(
"Process Period Closing Voucher Detail",
["parent", "status", "parentfield", "idx", "processing_date"],
)

View File

@@ -113,38 +113,3 @@ def create_process_soa(**args):
process_soa.update(soa_dict)
process_soa.save()
return process_soa
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
"""validate() fills in default subject/body/pdf templates and enforces the
basic constraints. Exercised on the document directly (no email/PDF flow)."""
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
doc = frappe.new_doc("Process Statement Of Accounts")
doc.report = report
doc.company = "_Test Company"
if with_customer:
doc.append("customers", {"customer": "_Test Customer"})
doc.update(overrides)
return doc
def test_customers_are_required(self):
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
def test_general_ledger_body_uses_a_date_range(self):
doc = self.make_soa(report="General Ledger")
doc.validate()
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
# subject and pdf name are also defaulted
self.assertTrue(doc.subject)
self.assertTrue(doc.pdf_name)
def test_receivable_body_uses_the_posting_date(self):
doc = self.make_soa(report="Accounts Receivable")
doc.validate()
self.assertIn("until {{ doc.posting_date }}", doc.body)
def test_account_must_belong_to_company(self):
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other, "need an account in _Test Company 1")
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)

View File

@@ -1,56 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
from unittest.mock import patch
# import frappe
import frappe
from erpnext.accounts.doctype.process_subscription.process_subscription import (
create_subscription_process,
)
from erpnext.accounts.doctype.subscription.test_subscription import (
create_parties,
create_subscription,
make_plans,
reset_settings,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestProcessSubscription(ERPNextTestSuite):
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
for every non-cancelled Subscription (or just one when a subscription is named)."""
def setUp(self):
frappe.set_user("Administrator")
# mirror TestSubscription setup so subscriptions build against known settings
make_plans()
create_parties()
reset_settings()
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
def enqueued_subscriptions(self, subscription=None):
"""Submit a Process Subscription while capturing what gets enqueued."""
calls = []
def capture(*args, **kwargs):
calls.append(kwargs)
with patch("frappe.enqueue", side_effect=capture):
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
# each enqueue is handed a batch (list) of subscription names
return [name for call in calls for name in call.get("subscription", [])]
def test_named_subscription_is_the_only_one_enqueued(self):
sub = create_subscription(start_date="2026-01-01")
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
def test_cancelled_subscriptions_are_skipped(self):
active = create_subscription(start_date="2026-01-01")
cancelled = create_subscription(start_date="2026-01-01")
cancelled.cancel_subscription()
enqueued = self.enqueued_subscriptions()
self.assertIn(active.name, enqueued)
self.assertNotIn(cancelled.name, enqueued)
pass

View File

@@ -3,6 +3,7 @@
import frappe
from frappe import _
from frappe.query_builder.functions import Sum
from frappe.utils import cint, flt, get_link_to_form
import erpnext
@@ -130,6 +131,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
get_purchase_document_details,
)
from erpnext.stock.utils import get_valuation_method
doc = self.doc
tax_service = TaxService(doc)
@@ -329,25 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
self.make_provisional_gl_entry(gl_entries, item)
if not doc.is_internal_transfer():
# When Update Stock is disabled, this invoice has no stock impact: the linked
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
# amount against the supplier - booking PPV again would double count it and leave
# SRBNB partially uncleared.
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
handled = False
if (
item.item_code
and item.item_code in stock_items
and item.get("purchase_receipt")
and not doc.is_return
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
):
handled = self.make_standard_cost_srbnb_split(
gl_entries, item, expense_account, account_currency, base_amount
)
if not handled:
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": base_amount,
"debit_in_transaction_currency": amount,
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
)
# check if the exchange rate has changed
if (
@@ -520,6 +530,95 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
},
)
def make_standard_cost_srbnb_split(
self, gl_entries, item, expense_account, account_currency, base_amount
):
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
value the receipt actually booked and post the (Net Amount - standard) difference to the
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
can't be resolved."""
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
get_purchase_price_variance_account,
)
doc = self.doc
precision = item.precision("base_net_amount")
standard_value = flt(self.get_pr_stock_value(item), precision)
if not standard_value:
return False
gl_entries.append(
self.get_gl_dict(
{
"account": expense_account,
"against": doc.supplier,
"debit": standard_value,
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
account_currency,
item=item,
)
)
variance = flt(base_amount - standard_value, precision)
if variance:
gl_entries.append(
self.get_gl_dict(
{
"account": get_purchase_price_variance_account(item.item_code, doc.company),
"against": doc.supplier,
"debit": variance,
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
"cost_center": item.cost_center,
"project": item.project or doc.project,
},
item=item,
)
)
return True
def get_pr_stock_value(self, item):
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
row is billing.
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
non-stock invoice).
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
pr_detail = frappe.db.get_value(
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
)
if not pr_detail or not pr_detail.warehouse:
return 0.0
sle = frappe.qb.DocType("Stock Ledger Entry")
result = (
frappe.qb.from_(sle)
.select(Sum(sle.stock_value_difference))
.where(
(sle.voucher_type == "Purchase Receipt")
& (sle.voucher_no == item.purchase_receipt)
& (sle.voucher_detail_no == item.pr_detail)
& (sle.warehouse == pr_detail.warehouse)
& (sle.is_cancelled == 0)
)
).run()
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
if not accepted_value or not flt(pr_detail.stock_qty):
return accepted_value
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
def get_stock_variance_account(self, item):
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
Variance; for all other items it keeps the existing behaviour (default expense account)."""

View File

@@ -1,55 +1,11 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
# import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestRepostPaymentLedger(ERPNextTestSuite):
"""Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date
(unless rows are added manually) and queues them for a ledger rebuild."""
def setUp(self):
frappe.set_user("Administrator")
def make_repost(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Repost Payment Ledger")
doc.company = COMPANY
doc.posting_date = args.get("posting_date", "2026-06-01")
doc.voucher_type = args.get("voucher_type", "Sales Invoice")
doc.add_manually = args.get("add_manually", 0)
return doc
def test_loads_submitted_vouchers_on_or_after_cutoff(self):
after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1)
before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice")
doc.save() # before_validate loads the vouchers and sets status
loaded = {v.voucher_no for v in doc.repost_vouchers}
self.assertIn(after_cutoff.name, loaded)
# the filter is >= so an invoice posted exactly on the cutoff is included
self.assertIn(on_cutoff.name, loaded)
self.assertNotIn(before_cutoff.name, loaded)
self.assertEqual(doc.repost_status, "Queued")
def test_add_manually_preserves_user_rows(self):
# manually add a BEFORE-cutoff invoice (which the filter would never load) while a
# matching after-cutoff invoice also exists. If auto-loading wrongly ran it would
# drop the manual row and pull the after-cutoff one, so this distinguishes the modes.
manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
doc = self.make_repost(add_manually=1, posting_date="2026-06-01")
doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name})
doc.save()
rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers]
self.assertEqual(rows, [("Sales Invoice", manual_si.name)])
pass

View File

@@ -344,9 +344,7 @@ def update_multi_mode_option(doc, pos_profile) -> None:
payment.account = payment_mode.default_account
payment.type = payment_mode.type
# is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this
# shared helper doesn't raise AttributeError when called on a POS Invoice
mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos")
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
doc.set("payments", [])
invalid_modes = []

View File

@@ -121,65 +121,3 @@ class TestShareTransfer(ERPNextTestSuite):
}
)
self.assertRaises(ShareDontExists, doc.insert)
class TestShareTransferValidation(ERPNextTestSuite):
"""basic_validations() enforces the transfer's internal consistency. Exercised
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
are needed - it only reasons about the document's own fields."""
def make_transfer(self, **overrides):
doc = frappe.new_doc("Share Transfer")
doc.update(
{
"transfer_type": "Transfer",
"date": "2026-01-01",
"from_shareholder": "SH-A",
"to_shareholder": "SH-B",
"to_folio_no": "1",
"share_type": "Equity",
"from_no": 1,
"to_no": 100,
"no_of_shares": 100,
"rate": 10,
"amount": 1000,
"company": "_Test Company",
"equity_or_liability_account": "Creditors - _TC",
}
)
doc.update(overrides)
return doc
def test_baseline_transfer_is_consistent(self):
# the helper's defaults must pass, otherwise the negative cases prove nothing
self.make_transfer().basic_validations()
def test_seller_and_buyer_must_differ(self):
doc = self.make_transfer(to_shareholder="SH-A")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_share_count_must_match_the_number_range(self):
# 1..100 is 100 shares, not 50
doc = self.make_transfer(no_of_shares=50)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_must_equal_rate_times_shares(self):
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_amount_is_derived_when_left_blank(self):
doc = self.make_transfer(amount=0)
doc.basic_validations()
self.assertEqual(doc.amount, 1000)
def test_equity_or_liability_account_is_required(self):
doc = self.make_transfer(equity_or_liability_account=None)
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_issue_requires_a_to_shareholder(self):
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)
def test_purchase_requires_a_from_shareholder(self):
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
self.assertRaises(frappe.ValidationError, doc.basic_validations)

View File

@@ -79,9 +79,7 @@ def get_plan_rate(
start_date = getdate(start_date)
end_date = getdate(end_date)
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
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end

View File

@@ -1,54 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, 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):
"""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)
pass

View File

@@ -15,7 +15,7 @@
"label": "Banking",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 13:43:50.924019",
"modified": "2026-06-14 13:43:50.924019",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Banking",
@@ -43,7 +43,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "wrench",
"icon": "tool",
"indent": 0,
"keep_closed": 0,
"label": "Bank Reconciliation",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "wallet",
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Budgeting",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 04:24:48.116724",
"modified": "2026-07-02 04:24:48.116724",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Budgeting",
@@ -59,7 +59,7 @@
"child": 0,
"collapsible": 1,
"default_workspace": 0,
"icon": "wallet",
"icon": "accounting",
"indent": 0,
"keep_closed": 0,
"label": "Accounting Dimension",

View File

@@ -266,7 +266,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:44:08.095321",
"modified": "2026-06-14 13:44:08.095321",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -284,7 +284,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "wallet",
"icon": "accounting",
"indent": 1,
"keep_closed": 0,
"label": "Financial Reports",

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "wallet",
"icon": "accounting",
"idx": 4,
"indicator_color": "",
"is_hidden": 0,
@@ -587,7 +587,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:44:08.471142",
"modified": "2026-06-14 13:44:08.471142",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -622,7 +622,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "house",
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -635,7 +635,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart-column",
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -786,7 +786,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "coins",
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",

View File

@@ -15,7 +15,7 @@
"label": "Payments",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 13:43:50.184761",
"modified": "2026-06-14 13:43:50.184761",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -31,7 +31,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart-column",
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -44,7 +44,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "coins",
"icon": "money-coins-1",
"indent": 1,
"keep_closed": 0,
"label": "Payments",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "coins",
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Share Management",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 13:43:51.040978",
"modified": "2026-06-14 13:43:51.040978",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Share Management",
@@ -30,7 +30,7 @@
{
"child": 1,
"collapsible": 1,
"icon": "user",
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Shareholder",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "wallet",
"icon": "accounting",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Subscriptions",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 14:08:36.999272",
"modified": "2026-06-14 14:08:36.999272",
"modified_by": "Administrator",
"module": "Accounts",
"name": "Subscriptions",

View File

@@ -8,14 +8,14 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "coins",
"icon": "money-coins-1",
"idx": 0,
"indicator_color": "green",
"is_hidden": 0,
"label": "Taxes",
"link_type": "DocType",
"links": [],
"modified": "2026-07-03 13:43:50.894825",
"modified": "2026-06-14 13:43:50.894825",
"modified_by": "Administrator",
"module": "Accounts",
"module_onboarding": "Accounting Onboarding",
@@ -58,7 +58,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "package",
"icon": "stock",
"indent": 0,
"keep_closed": 0,
"label": "Item Tax Template",

View File

@@ -587,47 +587,3 @@ def get_actual_sle_dict(name):
}
return sle_dict
class TestAssetCapitalizationValidation(ERPNextTestSuite):
"""Row-level validations for the consumed/target items. Exercised on the document
directly (the integration tests above cover the full capitalization posting)."""
def make_capitalization(self, **fields):
doc = frappe.new_doc("Asset Capitalization")
doc.company = "_Test Company"
doc.update(fields)
return doc
def test_source_items_are_mandatory(self):
doc = self.make_capitalization()
self.assertRaises(frappe.ValidationError, doc.validate_source_mandatory)
def test_target_item_must_be_a_fixed_asset(self):
# _Test Item is a stock item, not a fixed asset
doc = self.make_capitalization(target_item_code="_Test Item")
self.assertRaises(frappe.ValidationError, doc.validate_target_item)
def test_consumed_stock_row_rejects_a_non_stock_item(self):
doc = self.make_capitalization()
doc.append("stock_items", {"item_code": "_Test Non Stock Item", "stock_qty": 1})
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
def test_consumed_stock_row_requires_positive_qty(self):
doc = self.make_capitalization()
doc.append("stock_items", {"item_code": "_Test Item", "stock_qty": 0})
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
def test_service_row_rejects_a_stock_item(self):
doc = self.make_capitalization()
doc.append("service_items", {"item_code": "_Test Item", "qty": 1, "rate": 100})
self.assertRaises(frappe.ValidationError, doc.validate_service_item)
def test_service_row_requires_positive_qty_and_rate(self):
zero_qty = self.make_capitalization()
zero_qty.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 0, "rate": 100})
self.assertRaises(frappe.ValidationError, zero_qty.validate_service_item)
zero_rate = self.make_capitalization()
zero_rate.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 1, "rate": 0})
self.assertRaises(frappe.ValidationError, zero_rate.validate_service_item)

View File

@@ -224,7 +224,7 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
)
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def add_node():
from frappe.desk.treeview import make_tree_args

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "archive",
"icon": "assets",
"idx": 0,
"is_hidden": 0,
"label": "Assets",
@@ -199,7 +199,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:44:08.417956",
"modified": "2026-06-14 13:44:08.417956",
"modified_by": "Administrator",
"module": "Assets",
"module_onboarding": "Asset Onboarding",
@@ -217,7 +217,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "house",
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -230,7 +230,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart-column",
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -295,7 +295,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "rocket",
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",

View File

@@ -55,7 +55,7 @@ def make_supplier_quotation_from_rfq(
# This method is used to make supplier quotation from supplier's portal.
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def create_supplier_quotation(doc: str | Document | dict):
doc = frappe.parse_json(doc)

View File

@@ -185,7 +185,7 @@ def refresh_scorecards():
frappe.get_doc("Supplier Scorecard", sc_name).save()
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_all_scorecards(docname: str):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "shopping-cart",
"icon": "buying",
"idx": 0,
"is_hidden": 0,
"label": "Buying",
@@ -501,7 +501,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:43:50.509039",
"modified": "2026-06-14 13:43:50.509039",
"modified_by": "Administrator",
"module": "Buying",
"module_onboarding": "Buying Onboarding",
@@ -532,7 +532,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "house",
"icon": "home",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -545,7 +545,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart-column",
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Dashboard",
@@ -610,7 +610,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "scale",
"icon": "liabilities",
"indent": 0,
"keep_closed": 0,
"label": "Purchase Invoice",

View File

@@ -1724,7 +1724,7 @@ def get_missing_company_details(doctype: str, docname: str):
}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def update_company_master_and_address(current_doctype: str, name: str, company: str, details: dict | str):
from frappe.utils import validate_email_address

View File

@@ -20,7 +20,7 @@ from frappe.query_builder.functions import (
Substring,
Sum,
)
from frappe.utils import cint, nowdate, today, unique
from frappe.utils import nowdate, today, unique
from pypika import Order
import erpnext
@@ -808,11 +808,7 @@ def get_filtered_dimensions(
query_filters.append(["company", "=", filters.get("company")])
for field in searchfields:
df = meta.get_field(field)
if df and df.fieldtype != "Check":
or_filters.append([field, "LIKE", "%%%s%%" % txt])
else:
or_filters.append([field, "=", cint(txt)])
or_filters.append([field, "LIKE", "%%%s%%" % txt])
fields.append(field)
if dimension_filters:

View File

@@ -653,7 +653,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
return [item for item in items if item.get("item_code") in inspection_required_items]
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_quality_inspections(
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
):

View File

@@ -17,7 +17,7 @@ frappe.ui.form.on("Campaign", {
frappe.route_options = { utm_source: "Campaign", utm_campaign: frm.doc.name };
frappe.set_route("List", "Lead");
},
null,
"fa fa-list",
true
);
}

View File

@@ -1,43 +1,8 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.crm.doctype.contract_template.contract_template import get_contract_template
from erpnext.tests.utils import ERPNextTestSuite
class TestContractTemplate(ERPNextTestSuite):
"""Contract Template validates its Jinja terms and renders them against a doc."""
def test_malformed_contract_terms_are_rejected(self):
doc = frappe.new_doc("Contract Template")
doc.contract_terms = "{% for x in %}" # invalid Jinja
self.assertRaises(frappe.ValidationError, doc.validate)
# a valid template, and no template at all, both pass
doc.contract_terms = "Party: {{ party_name }}"
doc.validate()
doc.contract_terms = None
doc.validate()
def test_get_contract_template_renders_terms(self):
template = frappe.get_doc(
{
"doctype": "Contract Template",
"title": "_Test Contract Template",
"contract_terms": "Party: {{ party_name }}",
}
).insert()
result = get_contract_template(template.name, {"party_name": "Acme"})
self.assertEqual(result["contract_terms"], "Party: Acme")
self.assertEqual(result["contract_template"].name, template.name)
def test_get_contract_template_without_terms_returns_none(self):
template = frappe.get_doc(
{"doctype": "Contract Template", "title": "_Test Empty Contract Template"}
).insert()
result = get_contract_template(template.name, {})
self.assertIsNone(result["contract_terms"])
pass

View File

@@ -1,39 +1,9 @@
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
# import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestCRMSettings(ERPNextTestSuite):
"""CRM Settings guards its Frappe-CRM sync and Contact-Us opportunity toggles."""
def make_settings(self, **fields):
doc = frappe.new_doc("CRM Settings")
doc.update(fields)
return doc
def test_data_sync_requires_at_least_one_allowed_user(self):
doc = self.make_settings(enable_frappe_crm_data_synchronization=1)
self.assertRaises(frappe.ValidationError, doc.validate_allowed_users)
# adding a user satisfies the check
doc.append("allowed_users", {"user": "Administrator"})
doc.validate_allowed_users()
def test_disabling_sync_clears_allowed_users(self):
doc = self.make_settings(enable_frappe_crm_data_synchronization=0)
doc.append("allowed_users", {"user": "Administrator"})
doc.clear_allowed_users()
self.assertEqual(doc.allowed_users, [])
# while sync is on, the rows are kept
enabled = self.make_settings(enable_frappe_crm_data_synchronization=1)
enabled.append("allowed_users", {"user": "Administrator"})
enabled.clear_allowed_users()
self.assertEqual(len(enabled.allowed_users), 1)
@ERPNextTestSuite.change_settings("Contact Us Settings", {"is_disabled": 1})
def test_opportunity_from_contact_us_needs_the_form_enabled(self):
doc = self.make_settings(enable_opportunity_creation_from_contact_us=1)
self.assertRaises(frappe.ValidationError, doc.validate_enable_opportunity_creation_from_contact_us)
pass

View File

@@ -380,7 +380,7 @@ def get_lead_with_phone_number(number):
return lead
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def add_lead_to_prospect(lead: str, prospect: str):
prospect = frappe.get_doc("Prospect", prospect)
prospect.append("leads", {"lead": lead})

View File

@@ -110,7 +110,7 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
return target_doc
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
"""raise a issue from email"""

View File

@@ -124,7 +124,7 @@ def make_supplier_quotation(source_name: str, target_doc: str | Document | None
return doclist
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def make_opportunity_from_communication(
communication: str, company: str, ignore_communication_links: bool = False
):

View File

@@ -389,7 +389,7 @@ def get_item_details(item_code: str):
}
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def set_multiple_status(names: str | list[str], status: str):
names = frappe.parse_json(names)
for name in names:

View File

@@ -13,7 +13,7 @@
"doctype": "Workspace",
"for_user": "",
"hide_custom": 0,
"icon": "handshake",
"icon": "crm",
"idx": 0,
"is_hidden": 0,
"label": "CRM",
@@ -421,7 +421,7 @@
"type": "Link"
}
],
"modified": "2026-07-03 13:44:08.297053",
"modified": "2026-06-14 13:44:08.297053",
"modified_by": "Administrator",
"module": "CRM",
"name": "CRM",
@@ -471,7 +471,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "chart-column",
"icon": "chart",
"indent": 0,
"keep_closed": 0,
"label": "Home",
@@ -510,7 +510,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "user",
"icon": "customer",
"indent": 0,
"keep_closed": 0,
"label": "Customer",
@@ -644,7 +644,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "rocket",
"icon": "getting-started",
"indent": 1,
"keep_closed": 1,
"label": "Maintenance",
@@ -776,7 +776,7 @@
{
"child": 0,
"collapsible": 1,
"icon": "store",
"icon": "sell",
"indent": 1,
"keep_closed": 1,
"label": "Campaign",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "wallet",
"icon": "accounting",
"icon_type": "Folder",
"idx": 1,
"label": "Accounting",
"link_to": "",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 17:04:04.351402",
"modified": "2026-01-27 17:04:04.351402",
"modified_by": "Administrator",
"name": "Accounting",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "archive",
"icon": "assets",
"icon_type": "Link",
"idx": 1,
"label": "Assets",
"link_to": "Assets",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.220411",
"modified": "2026-01-01 20:07:01.220411",
"modified_by": "Administrator",
"name": "Assets",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "chart-pie",
"icon": "expenses",
"icon_type": "Link",
"idx": 6,
"label": "Budget",
"link_to": "Budget",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 14:39:30.839274",
"modified": "2026-01-23 14:39:30.839274",
"modified_by": "Administrator",
"name": "Budget",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "shopping-cart",
"icon": "buying",
"icon_type": "Link",
"idx": 1,
"label": "Buying",
"link_to": "Buying",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.196163",
"modified": "2026-01-01 20:07:01.196163",
"modified_by": "Administrator",
"name": "Buying",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "handshake",
"icon": "crm",
"icon_type": "Link",
"idx": 1,
"label": "CRM",
"link_to": "CRM",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 14:54:05.112927",
"modified": "2026-01-06 14:54:05.112927",
"modified_by": "Administrator",
"name": "CRM",
"owner": "Administrator",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "settings",
"icon": "setting",
"icon_type": "Link",
"idx": 10,
"label": "ERPNext Settings",
"link_to": "ERPNext Settings",
"link_type": "Workspace Sidebar",
"logo_url": "",
"modified": "2026-07-03 14:59:56.044037",
"modified": "2026-01-09 14:59:56.044037",
"modified_by": "Administrator",
"name": "ERPNext Settings",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "house",
"icon": "home",
"icon_type": "Link",
"idx": 0,
"label": "Home",
"link_to": "Home",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.174950",
"modified": "2026-01-01 20:07:01.174950",
"modified_by": "Administrator",
"name": "Home",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "wallet",
"icon": "accounting",
"icon_type": "Link",
"idx": 0,
"label": "Invoicing",
"link_to": "Invoicing",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 15:17:23.564795",
"modified": "2026-01-23 15:17:23.564795",
"modified_by": "Administrator",
"name": "Invoicing",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "factory",
"icon": "organization",
"icon_type": "Link",
"idx": 1,
"label": "Manufacturing",
"link_to": "Manufacturing",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.246693",
"modified": "2026-01-01 20:07:01.246693",
"modified_by": "Administrator",
"name": "Manufacturing",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "folder-kanban",
"icon": "project",
"icon_type": "Link",
"idx": 1,
"label": "Projects",
"link_to": "Projects",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.226383",
"modified": "2026-01-01 20:07:01.226383",
"modified_by": "Administrator",
"name": "Projects",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "shield-check",
"icon": "quality",
"icon_type": "Link",
"idx": 1,
"label": "Quality",
"link_to": "Quality",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.239523",
"modified": "2026-01-01 20:07:01.239523",
"modified_by": "Administrator",
"name": "Quality",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "store",
"icon": "sell",
"icon_type": "Link",
"idx": 1,
"label": "Selling",
"link_to": "Selling",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.189446",
"modified": "2026-01-01 20:07:01.189446",
"modified_by": "Administrator",
"name": "Selling",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "package",
"icon": "stock",
"icon_type": "Link",
"idx": 1,
"label": "Stock",
"link_to": "Stock",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 20:07:01.212940",
"modified": "2026-01-01 20:07:01.212940",
"modified_by": "Administrator",
"name": "Stock",
"owner": "Administrator",

View File

@@ -4,14 +4,14 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 0,
"icon": "package-2",
"icon": "getting-started",
"icon_type": "Link",
"idx": 6,
"label": "Subcontracting",
"link_to": "Subcontracting",
"link_type": "Workspace Sidebar",
"logo_url": "/assets/erpnext/desktop_icons/subcontracting.svg",
"modified": "2026-07-03 20:07:01.323508",
"modified": "2026-01-01 20:07:01.323508",
"modified_by": "Administrator",
"name": "Subcontracting",
"owner": "Administrator",

View File

@@ -4,13 +4,13 @@
"docstatus": 0,
"doctype": "Desktop Icon",
"hidden": 1,
"icon": "headset",
"icon": "support",
"icon_type": "Link",
"idx": 1,
"label": "Support",
"link_to": "Support",
"link_type": "Workspace Sidebar",
"modified": "2026-07-03 14:53:54.100467",
"modified": "2026-01-06 14:53:54.100467",
"modified_by": "Administrator",
"name": "Support",
"owner": "Administrator",

View File

@@ -50,7 +50,7 @@ def get_plaid_configuration():
return "disabled"
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def add_institution(token: str, response: str | dict):
response = frappe.parse_json(response)
@@ -79,7 +79,7 @@ def add_institution(token: str, response: str | dict):
return bank
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
response = frappe.parse_json(response)
bank = frappe.parse_json(bank)

View File

@@ -272,7 +272,7 @@
"fieldtype": "Select",
"label": "Status",
"no_copy": 1,
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nTo Manufacture\nCancelled\nCompleted",
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
"read_only": 1
},
{
@@ -695,7 +695,7 @@
"grid_page_length": 50,
"is_submittable": 1,
"links": [],
"modified": "2026-06-20 17:39:42.293242",
"modified": "2026-06-19 17:39:42.293242",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "Job Card",

View File

@@ -131,7 +131,6 @@ class JobCard(Document):
"Material Transferred",
"On Hold",
"Submitted",
"To Manufacture",
"Cancelled",
"Completed",
]
@@ -1303,13 +1302,8 @@ class JobCard(Document):
self.update_workstation_status()
def set_finished_good_status(self):
# Only reached for a submitted job card (docstatus == 1) with a finished good, see set_status().
if (self.manufactured_qty + self.process_loss_qty) >= self.for_quantity:
self.status = "Completed"
elif (self.total_completed_qty + self.process_loss_qty) >= self.for_quantity:
# Production is done and the card is submitted, but the finished goods have not been
# booked into stock yet (Manufacture Stock Entry pending) — distinct from active WIP.
self.status = "To Manufacture"
elif self.transferred_qty > 0 or self.skip_material_transfer:
self.status = "Work In Progress"

View File

@@ -86,10 +86,6 @@ def make_material_request(source_name: str, target_doc: Document | str | None =
@frappe.whitelist()
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -129,7 +125,6 @@ def make_stock_entry(source_name: str, target_doc: Document | str | None = None)
wo_allows_alternate_item
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
)
set_previous_operation_serial_batch(target, item)
doclist = get_mapped_doc(
"Job Card",

View File

@@ -1061,9 +1061,6 @@ class TestJobCard(ERPNextTestSuite):
job_card.submit()
for row in fg_bom.items:
if row.item_code == sfg.name:
continue
make_stock_entry(
item_code=row.item_code,
target="Stores - _TC",
@@ -1074,301 +1071,9 @@ class TestJobCard(ERPNextTestSuite):
manufacturing_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
manufacturing_entry.submit()
sfg_row = next(row for row in manufacturing_entry.items if row.item_code == sfg.name)
self.assertEqual(flt(sfg_row.basic_rate, 3), 95.0)
self.assertEqual(manufacturing_entry.items[2].item_code, scrap2.name)
self.assertEqual(manufacturing_entry.items[2].qty, 9)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
def test_semi_fg_batch_auto_pull_on_manufacture(self):
"""Batch produced by an operation should auto-pull into the next operation's
semi-finished consumption row (skip-transfer Manufacture entry)."""
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("Auto Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("Auto Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("Auto Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"Auto Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "AP-SFG-.#####",
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "Auto Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "Auto Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=rm2, target=warehouse, qty=10, basic_rate=100)
# Operation A -> produces the SFG batch
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-01-01 08:00:00",
"to_time": "2024-01-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
self.assertTrue(sfg_fg_row.serial_and_batch_bundle)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
# Operation B -> consumes the SFG; its batch should be auto-pulled from Operation A
jc_b = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "Auto Pull Op B"}, "name"
),
)
jc_b.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_b.for_quantity,
},
)
jc_b.submit()
me_b = frappe.get_doc(jc_b.make_stock_entry_for_semi_fg_item())
sfg_consume_row = next(r for r in me_b.items if r.item_code == sfg and r.s_warehouse)
self.assertTrue(
sfg_consume_row.serial_and_batch_bundle,
"Previous operation's batch was not auto-pulled into the semi-finished consumption row",
)
consumed_batches = get_batches_from_bundle(sfg_consume_row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
def test_semi_fg_auto_pull_with_uom_conversion(self):
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
from erpnext.stock.serial_batch_bundle import get_batches_from_bundle
frappe.db.set_value("UOM", "Nos", "must_be_whole_number", 0)
frappe.db.set_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order", 0)
warehouse = "Stores - _TC"
rm1 = make_item("UOM Pull RM 1", {"is_stock_item": 1}).name
rm2 = make_item("UOM Pull RM 2", {"is_stock_item": 1}).name
fg1 = make_item("UOM Pull FG 1", {"is_stock_item": 1}).name
sfg = make_item(
"UOM Pull SFG 1",
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "UP-SFG-.#####",
"uoms": [{"uom": "Box", "conversion_factor": 5}],
},
).name
sfg_bom = frappe.new_doc("BOM", company="_Test Company", item=sfg, quantity=1)
sfg_bom.append("items", {"item_code": rm1, "qty": 1})
sfg_bom.insert()
sfg_bom.submit()
fg_bom = frappe.new_doc(
"BOM",
company="_Test Company",
item=fg1,
quantity=1,
with_operations=1,
track_semi_finished_goods=1,
)
fg_bom.append("items", {"item_code": rm2, "qty": 1})
operation1 = {
"operation": "UOM Pull Op A",
"workstation": "_Test Workstation A",
"finished_good": sfg,
"bom_no": sfg_bom.name,
"finished_good_qty": 1,
"sequence_id": 1,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
operation2 = {
"operation": "UOM Pull Op B",
"workstation": "_Test Workstation A",
"finished_good": fg1,
"finished_good_qty": 1,
"is_final_finished_good": 1,
"sequence_id": 2,
"time_in_mins": 60,
"source_warehouse": warehouse,
"fg_warehouse": warehouse,
"skip_material_transfer": 1,
}
make_workstation(operation1)
make_operation(operation1)
make_operation(operation2)
fg_bom.append("operations", operation1)
fg_bom.append("operations", operation2)
fg_bom.append("items", {"item_code": sfg, "qty": 1, "uom": "Nos", "operation_row_id": 2})
fg_bom.insert()
fg_bom.submit()
work_order = make_wo_order_test_record(
item=fg1,
qty=5,
source_warehouse=warehouse,
fg_warehouse=warehouse,
bom_no=fg_bom.name,
skip_transfer=1,
do_not_save=True,
)
work_order.operations[0].time_in_mins = 60
work_order.operations[1].time_in_mins = 60
work_order.save()
work_order.submit()
make_stock_entry(item_code=rm1, target=warehouse, qty=10, basic_rate=100)
make_stock_entry(item_code=sfg, target=warehouse, qty=5, basic_rate=100, posting_date="2024-01-01")
jc_a = frappe.get_doc(
"Job Card",
frappe.db.get_value(
"Job Card", {"work_order": work_order.name, "operation": "UOM Pull Op A"}, "name"
),
)
jc_a.append(
"time_logs",
{
"from_time": "2024-02-01 08:00:00",
"to_time": "2024-02-01 09:00:00",
"completed_qty": jc_a.for_quantity,
},
)
jc_a.submit()
me_a = frappe.get_doc(jc_a.make_stock_entry_for_semi_fg_item())
me_a.submit()
me_a.reload()
sfg_fg_row = next(r for r in me_a.items if r.is_finished_item and r.item_code == sfg)
produced_batches = get_batches_from_bundle(sfg_fg_row.serial_and_batch_bundle)
se = frappe.new_doc("Stock Entry")
se.company = "_Test Company"
se.purpose = "Material Transfer"
se.work_order = work_order.name
se.set_stock_entry_type()
row = se.append(
"items",
{
"item_code": sfg,
"qty": 1,
"uom": "Box",
"conversion_factor": 5,
"s_warehouse": warehouse,
"t_warehouse": "_Test Warehouse - _TC",
},
)
set_previous_operation_serial_batch(se, row)
self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(
abs(frappe.db.get_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")),
5.0,
)
se.save()
se.submit()
se.reload()
row = se.items[0]
consumed_batches = get_batches_from_bundle(row.serial_and_batch_bundle)
self.assertEqual(set(consumed_batches.keys()), set(produced_batches.keys()))
self.assertEqual(abs(sum(consumed_batches.values())), 5.0)
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.556)
def test_secondary_items_without_sfg(self):
for row in frappe.get_doc("BOM", self.work_order.bom_no).items:

View File

@@ -20,8 +20,7 @@
"sub_operations",
"total_operation_time",
"section_break_4",
"description",
"work_instruction"
"description"
],
"fields": [
{
@@ -44,12 +43,6 @@
"in_preview": 1,
"label": "Description"
},
{
"description": "Shown to operators on the Shop Floor. Supports rich text and embedded images for step-by-step guidance.",
"fieldname": "work_instruction",
"fieldtype": "Text Editor",
"label": "Work Instructions"
},
{
"collapsible": 1,
"fieldname": "sub_operations_section",

View File

@@ -25,7 +25,6 @@ class Operation(Document):
quality_inspection_template: DF.Link | None
sub_operations: DF.Table[SubOperation]
total_operation_time: DF.Float
work_instruction: DF.TextEditor | None
workstation: DF.Link | None
# end: auto-generated types

View File

@@ -169,29 +169,6 @@ class OperationsService:
self.doc.set("operations", operations)
self.calculate_time()
self.set_operation_warehouses()
def set_operation_warehouses(self):
"""For semi-finished goods tracking, default each operation's warehouses from the Work
Order and chain them: the first operation pulls from the WO source warehouse and every
later operation pulls from the previous operation's output; intermediate outputs go to the
WIP warehouse while the final operation outputs to the WO finished goods warehouse.
Only empty fields are filled, so values configured on the BOM/operation are preserved."""
if not self.doc.track_semi_finished_goods or not self.doc.operations:
return
operations = self.doc.operations
last_idx = len(operations) - 1
for idx, op in enumerate(operations):
if not op.source_warehouse:
op.source_warehouse = self.doc.source_warehouse
if not op.fg_warehouse:
op.fg_warehouse = self.doc.fg_warehouse if idx == last_idx else self.doc.source_warehouse
if not op.wip_warehouse:
op.wip_warehouse = self.doc.wip_warehouse
def _collect_bom_operations(self):
operations = []

View File

@@ -691,28 +691,6 @@ class TestWorkOrder(ERPNextTestSuite):
ste.save()
self.assertEqual(ste.get("items")[0].get("cost_center"), "_Test Cost Center - _TC")
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"make_serial_no_batch_from_work_order": 0})
def test_cost_center_for_manufacture_falls_back_to_item_group_default(self):
# "_Test Item Group" is master data with buying_cost_center already set to
# "_Test Cost Center 2 - _TC" for "_Test Company"; only the FG item and its
# BOM need to be created, since no existing item in that group has one.
fg_item = make_item(
"_Test FG Item For Item Group Cost Center",
{"is_stock_item": 1, "item_group": "_Test Item Group", "include_item_in_manufacturing": 1},
)
if not frappe.db.exists("BOM", {"item": fg_item.name, "is_active": 1, "is_default": 1}):
make_bom(item=fg_item.name, raw_materials=["_Test Item"])
wo_order = make_wo_order_test_record(
production_item=fg_item.name, skip_transfer=1, source_warehouse="_Test Warehouse - _TC"
)
ste = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", wo_order.qty))
ste.insert()
fg_row = next(d for d in ste.items if d.is_finished_item)
self.assertEqual(fg_row.cost_center, "_Test Cost Center 2 - _TC")
def test_operation_time_with_batch_size(self):
fg_item = "Test Batch Size Item For BOM"
rm1 = "Test Batch Size Item RM 1 For BOM"

View File

@@ -203,15 +203,6 @@ frappe.ui.form.on("Work Order", {
}
}
let pending_ops = frm.doc?.operations?.filter((op) => op.completed_qty < frm.doc.qty);
// Jump to the operator Shop Floor view, pre-filtered to this work order.
if (frm.doc.docstatus === 1 && frm.doc.status !== "Closed" && pending_ops && pending_ops.length > 0) {
frm.add_custom_button(__("Operator Dashboard"), () => {
frappe.route_options = { work_order: frm.doc.name };
frappe.set_route("shop-floor");
});
}
if (frm.doc.status == "Completed") {
if (frm.doc.__onload.backflush_raw_materials_based_on == "Material Transferred for Manufacture") {
frm.add_custom_button(

View File

@@ -288,7 +288,6 @@ class WorkOrder(Document):
self.validate_sales_order()
self.set_default_warehouse()
self.set_operation_warehouses()
self.validate_warehouse_belongs_to_company()
self.check_wip_warehouse_skip()
self.calculate_operating_cost()
@@ -976,9 +975,6 @@ class WorkOrder(Document):
def set_work_order_operations(self):
return OperationsService(self).set_work_order_operations()
def set_operation_warehouses(self):
return OperationsService(self).set_operation_warehouses()
def update_operation_status(self):
return OperationsService(self).update_operation_status()
@@ -1074,7 +1070,7 @@ def get_bom_operations(doctype: str, txt: str, searchfield: str, start: int, pag
return frappe.get_all("BOM Operation", filters=filters, fields=["operation"], as_list=1)
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def set_work_order_ops(name: str):
po = frappe.get_doc("Work Order", name)
po.set_work_order_operations()

View File

@@ -12,14 +12,17 @@ frappe.ui.form.on("Workstation", {
refresh(frm) {
frm.trigger("set_illustration_image");
frm.trigger("prepapre_dashboard");
},
if (!frm.is_new()) {
// Operator workflow now lives on the Shop Floor page; jump there filtered to this machine.
frm.add_custom_button(__("Shop Floor"), () => {
frappe.route_options = { workstation: frm.doc.name };
frappe.set_route("shop-floor");
});
}
prepapre_dashboard(frm) {
let $parent = $(frm.fields_dict["workstation_dashboard"].wrapper);
$parent.empty();
let workstation_dashboard = new WorkstationDashboard({
wrapper: $parent,
frm: frm,
});
},
onload(frm) {
@@ -77,3 +80,533 @@ frappe.tour["Workstation"] = [
),
},
];
class WorkstationDashboard {
constructor({ wrapper, frm }) {
this.$wrapper = $(wrapper);
this.frm = frm;
this.prepapre_dashboard();
}
prepapre_dashboard() {
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.get_job_cards",
args: {
workstation: this.frm.doc.name,
},
callback: (r) => {
if (r.message) {
this.job_cards = r.message;
this.render_job_cards();
}
},
});
}
render_job_cards() {
this.template = frappe.render_template("workstation_job_card", {
data: this.job_cards,
});
this.timer_job_cards = {};
this.$wrapper.html(this.template);
this.setup_qrcode_fields();
this.prepare_timer();
this.setup_menu_actions();
this.toggle_job_card();
this.bind_events();
}
setup_qrcode_fields() {
this.start_job_qrcode = frappe.ui.form.make_control({
df: {
label: __("Start Job"),
fieldtype: "Data",
options: "Barcode",
placeholder: __("Scan Job Card Qrcode"),
},
parent: this.$wrapper.find(".qrcode-fields"),
render_input: true,
});
this.start_job_qrcode.$wrapper.addClass("form-column col-sm-6");
this.start_job_qrcode.$input.on("input", (e) => {
clearTimeout(this.start_job_qrcode_search);
this.start_job_qrcode_search = setTimeout(() => {
let job_card = this.start_job_qrcode.get_value();
if (job_card) {
this.validate_job_card(job_card, "Open", (job_card, qty) => {
this.start_job(job_card);
});
this.start_job_qrcode.set_value("");
}
}, 300);
});
this.complete_job_qrcode = frappe.ui.form.make_control({
df: {
label: __("Complete Job"),
fieldtype: "Data",
options: "Barcode",
placeholder: __("Scan Job Card Qrcode"),
},
parent: this.$wrapper.find(".qrcode-fields"),
render_input: true,
});
this.complete_job_qrcode.$input.on("input", (e) => {
clearTimeout(this.complete_job_qrcode_search);
this.complete_job_qrcode_search = setTimeout(() => {
let job_card = this.complete_job_qrcode.get_value();
if (job_card) {
this.validate_job_card(job_card, "Work In Progress", (job_card, qty) => {
this.complete_job(job_card, qty);
});
this.complete_job_qrcode.set_value("");
}
}, 300);
});
this.complete_job_qrcode.$wrapper.addClass("form-column col-sm-6");
}
validate_job_card(job_card, status, callback) {
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.validate_job_card",
args: {
job_card: job_card,
status: status,
},
callback(r) {
callback(job_card, r.message);
},
});
}
setup_menu_actions() {
let me = this;
this.job_cards.forEach((data) => {
me.menu_btns = me.$wrapper.find(`.job-card-link[data-name='${data.name}']`);
$(me.menu_btns).find(".btn-resume").hide();
$(me.menu_btns).find(".btn-pause").hide();
$(me.menu_btns).find(".btn-complete .btn").attr("disabled", true);
if (
data.for_quantity + data.process_loss_qty > data.total_completed_qty &&
(data.skip_material_transfer ||
data.transferred_qty >= data.for_quantity + data.process_loss_qty ||
!data.finished_good)
) {
if (!data.time_logs?.length) {
$(me.menu_btns).find(".btn-start").show();
} else if (data.is_paused) {
$(me.menu_btns).find(".btn-start").hide();
$(me.menu_btns).find(".btn-resume").show();
} else if (data.for_quantity - data.manufactured_qty > 0) {
$(me.menu_btns).find(".btn-start").hide();
if (!data.is_paused) {
$(me.menu_btns).find(".btn-pause").show();
}
$(me.menu_btns).find(".btn-complete").show();
$(me.menu_btns).find(".btn-complete .btn").attr("disabled", false);
}
}
});
}
toggle_job_card() {
this.$wrapper.find(".collapse-indicator-job").on("click", (e) => {
$(e.currentTarget)
.closest(".form-dashboard-section")
.find(".section-body-job-card")
.toggleClass("hide");
if (
$(e.currentTarget)
.closest(".form-dashboard-section")
.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"));
});
}
bind_events() {
let me = this;
this.$wrapper.find(".btn-transfer-materials").on("click", (e) => {
let job_card = $(e.currentTarget).closest("ul").attr("data-job-card");
this.make_material_request(job_card);
});
this.$wrapper.find(".btn-start").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
this.start_job(job_card);
});
this.$wrapper.find(".btn-pause").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
me.update_job_card(job_card, "pause_job", {
end_time: frappe.datetime.now_datetime(),
});
});
this.$wrapper.find(".btn-resume").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
me.update_job_card(job_card, "resume_job", {
start_time: frappe.datetime.now_datetime(),
});
});
this.$wrapper.find(".btn-complete").on("click", (e) => {
let job_card = $(e.currentTarget).closest("div").attr("data-job-card");
let for_quantity = $(e.currentTarget).attr("data-qty");
me.complete_job(job_card, for_quantity);
});
}
start_job(job_card) {
let me = this;
let fields = this.get_fields_for_employee();
this.employee_dialog = frappe.prompt(fields, (values) => {
me.update_job_card(job_card, "start_timer", values);
});
let default_employee = this.job_cards[0]?.user_employee;
if (default_employee) {
this.employee_dialog.fields_dict.employees.df.data.push({
employee: default_employee,
});
this.employee_dialog.fields_dict.employees.grid.refresh();
}
}
complete_job(job_card, for_quantity) {
frappe.prompt(
{
fieldname: "qty",
label: __("Completed Quantity"),
fieldtype: "Float",
reqd: 1,
default: flt(for_quantity || 0),
},
(data) => {
if (flt(data.qty) <= 0) {
frappe.throw(__("Quantity should be greater than 0"));
}
this.update_job_card(job_card, "complete_job_card", {
qty: flt(data.qty),
end_time: frappe.datetime.now_datetime(),
auto_submit: 1,
});
},
__("Enter Value"),
__("Submit")
);
}
get_fields_for_employee() {
let me = this;
return [
{
label: __("Start Time"),
fieldname: "start_time",
fieldtype: "Datetime",
default: frappe.datetime.now_datetime(),
},
{
label: __("Employee"),
fieldname: "employee",
fieldtype: "Link",
options: "Employee",
change() {
let employee = this.get_value();
let employees = me.employee_dialog.fields_dict.employees.df.data;
if (employee) {
let employee_exists = employees.find((d) => d.employee === employee);
if (!employee_exists) {
me.employee_dialog.fields_dict.employees.df.data.push({
employee: employee,
});
me.employee_dialog.fields_dict.employees.grid.refresh();
}
}
},
},
{ fieldtype: "Section Break" },
{
label: __("Employees"),
fieldname: "employees",
fieldtype: "Table",
data: [],
cannot_add_rows: 1,
cannot_delete_rows: 1,
fields: [
{
label: __("Employee"),
fieldname: "employee",
fieldtype: "Link",
options: "Employee",
in_list_view: 1,
},
],
},
];
}
update_job_card(job_card, method, data) {
let me = this;
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.update_job_card",
args: {
job_card: job_card,
method: method,
start_time: data.start_time || "",
employees: data.employees || [],
end_time: data.end_time || "",
qty: data.qty || 0,
auto_submit: data.auto_submit || 0,
},
callback: () => {
$.each(me.timer_job_cards, (index, value) => {
clearInterval(value);
});
me.frm.reload_doc();
},
});
}
make_material_request(job_card) {
let me = this;
frappe.call({
method: "erpnext.manufacturing.doctype.workstation.workstation.get_raw_materials",
args: {
job_card: job_card,
},
callback: (r) => {
if (r.message) {
me.prepare_materials_modal(r.message, job_card, (job_card) => {
frappe.call({
method: "erpnext.manufacturing.doctype.job_card.mapper.make_stock_entry",
args: {
source_name: job_card,
},
callback: (r) => {
var doc = frappe.model.sync(r.message);
frappe.set_route("Form", doc[0].doctype, doc[0].name);
},
});
});
}
},
});
}
prepare_materials_modal(raw_materials, job_card, callback) {
let fields = this.get_raw_material_fields(raw_materials);
this.materials_dialog = new frappe.ui.Dialog({
title: "Raw Materials",
fields: fields,
size: "large",
primary_action_label: __("Make Transfer Entry"),
primary_action: () => {
this.materials_dialog.hide();
callback(job_card);
},
});
raw_materials.forEach((row) => {
this.materials_dialog.fields_dict.items.df.data.push(row);
});
this.materials_dialog.fields_dict.items.grid.refresh();
this.materials_dialog.show();
}
get_raw_material_fields(raw_materials) {
return [
{
label: __("Warehouse"),
fieldname: "warehouse",
fieldtype: "Link",
options: "Warehouse",
read_only: 1,
default: raw_materials[0].warehouse,
},
{ fieldtype: "Column Break" },
{
label: __("Skip Material Transfer"),
fieldname: "skip_material_transfer",
fieldtype: "Check",
read_only: 1,
default: raw_materials[0].skip_material_transfer,
},
{ fieldtype: "Section Break" },
{
label: __("Raw Materials"),
fieldname: "items",
fieldtype: "Table",
cannot_add_rows: 1,
cannot_delete_rows: 1,
data: [],
size: "extra-large",
fields: [
{
label: __("Item Code"),
fieldname: "item_code",
fieldtype: "Link",
options: "Item",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("UOM"),
fieldname: "uom",
fieldtype: "Link",
options: "UOM",
in_list_view: 1,
read_only: 1,
columns: 1,
},
{
label: __("Reqired Qty"),
fieldname: "required_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Transferred Qty"),
fieldname: "transferred_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Available Qty"),
fieldname: "stock_qty",
fieldtype: "Float",
in_list_view: 1,
read_only: 1,
columns: 2,
},
{
label: __("Available"),
fieldname: "material_availability_status",
fieldtype: "Check",
in_list_view: 1,
read_only: 1,
columns: 1,
},
],
},
];
}
prepare_timer() {
this.job_cards.forEach((data) => {
if (data.time_logs?.length) {
data._current_time = this.get_current_time(data);
if (data.time_logs[cint(data.time_logs.length) - 1].to_time || data.is_paused) {
this.updateStopwatch(data);
} else {
this.initialiseTimer(data);
}
}
});
}
update_job_card_details() {
let color_map = {
Pending: "var(--bg-blue)",
"In Process": "var(--bg-yellow)",
Submitted: "var(--bg-blue)",
Open: "var(--bg-gray)",
Closed: "var(--bg-green)",
"Work In Progress": "var(--bg-orange)",
};
this.job_cards.forEach((data) => {
let job_card_selector = this.$wrapper.find(`
[data-name='${data.name}']`);
$(job_card_selector).find(".job-card-status").text(data.status);
["blue", "gray", "green", "orange", "yellow"].forEach((color) => {
$(job_card_selector).find(".job-card-status").removeClass(color);
});
$(job_card_selector).find(".job-card-status").addClass(data.status_color);
$(job_card_selector).find(".job-card-status").css("backgroundColor", color_map[data.status]);
});
}
initialiseTimer(data) {
let timeout = setInterval(() => {
data._current_time += 1;
this.updateStopwatch(data);
}, 1000);
this.timer_job_cards[data.name] = timeout;
}
updateStopwatch(data) {
let increment = data._current_time;
let hours = Math.floor(increment / 3600);
let minutes = Math.floor((increment - hours * 3600) / 60);
let seconds = cint(increment - hours * 3600 - minutes * 60);
let job_card_selector = `[data-job-card='${data.name}']`;
let timer_selector = this.$wrapper.find(job_card_selector);
$(timer_selector)
.find(".hours")
.text(hours < 10 ? "0" + hours.toString() : hours.toString());
$(timer_selector)
.find(".minutes")
.text(minutes < 10 ? "0" + minutes.toString() : minutes.toString());
$(timer_selector)
.find(".seconds")
.text(seconds < 10 ? "0" + seconds.toString() : seconds.toString());
}
get_current_time(data) {
let current_time = 0.0;
data.time_logs.forEach((d) => {
if (d.to_time) {
if (d.time_in_mins) {
current_time += flt(d.time_in_mins, 2) * 60;
} else {
current_time += this.get_seconds_diff(d.to_time, d.from_time);
}
} else {
current_time += this.get_seconds_diff(frappe.datetime.now_datetime(), d.from_time);
}
});
return current_time;
}
get_seconds_diff(d1, d2) {
return moment(d1).diff(d2, "seconds");
}
}

View File

@@ -8,6 +8,9 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"dashboard_tab",
"section_break_mqqv",
"workstation_dashboard",
"details_tab",
"workstation_name",
"workstation_type",
@@ -170,6 +173,12 @@
"fieldtype": "Float",
"label": "Total Working Hours"
},
{
"depends_on": "eval:!doc.__islocal",
"fieldname": "dashboard_tab",
"fieldtype": "Tab Break",
"label": "Job Cards"
},
{
"fieldname": "details_tab",
"fieldtype": "Tab Break",
@@ -181,6 +190,16 @@
"label": "Connections",
"show_dashboard": 1
},
{
"fieldname": "workstation_dashboard",
"fieldtype": "HTML",
"label": "Workstation Dashboard"
},
{
"fieldname": "section_break_mqqv",
"fieldtype": "Section Break",
"hide_border": 1
},
{
"default": "0",
"fieldname": "disabled",

View File

@@ -223,7 +223,7 @@ class Workstation(Document):
return schedule_date
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def start_job(self, job_card: str, from_time: DateTimeLikeObject, employee: str):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("write")
@@ -233,7 +233,7 @@ class Workstation(Document):
return doc
@frappe.whitelist(methods=["POST"])
@frappe.whitelist()
def complete_job(self, job_card: str, qty: float, to_time: DateTimeLikeObject):
doc = frappe.get_doc("Job Card", job_card)
doc.check_permission("submit")
@@ -250,6 +250,77 @@ class Workstation(Document):
return doc
@frappe.whitelist()
def get_job_cards(workstation: str):
if frappe.has_permission("Job Card", "read"):
jc_data = frappe.get_all(
"Job Card",
fields=[
"name",
"production_item",
"work_order",
"operation",
"total_completed_qty",
"for_quantity",
"process_loss_qty",
"finished_good",
"transferred_qty",
"status",
"expected_start_date",
"expected_end_date",
"time_required",
"wip_warehouse",
"skip_material_transfer",
"backflush_from_wip_warehouse",
"is_paused",
"manufactured_qty",
],
filters={
"workstation": workstation,
"is_subcontracted": 0,
"docstatus": ("<", 2),
"status": ["not in", ["Completed", "Stopped"]],
},
order_by="expected_start_date, expected_end_date",
limit=10,
)
job_cards = [row.name for row in jc_data]
time_logs = get_time_logs(job_cards)
allow_excess_transfer = frappe.db.get_single_value(
"Manufacturing Settings", "job_card_excess_transfer"
)
user_employee = frappe.db.get_value("Employee", {"user_id": frappe.session.user}, "name")
for row in jc_data:
if row.status == "Open":
row.status = "Not Started"
item_code = row.finished_good or row.production_item
row.fg_uom = frappe.get_cached_value("Item", item_code, "stock_uom")
row.status_colour = get_status_color(row.status)
row.job_card_link = f"""
<a class="ellipsis" data-doctype="Job Card" data-name="{row.name}" href="/app/job-card/{row.name}" title="" data-original-title="{row.name}">{row.name}</a>
"""
row.operation_link = f"""
<a class="ellipsis" data-doctype="Operation" data-name="{row.operation}" href="/app/operation/{row.operation}" title="" data-original-title="{row.operation}">{row.operation}</a>
"""
row.work_order_link = get_link_to_form("Work Order", row.work_order)
row.time_logs = time_logs.get(row.name, [])
row.make_material_request = False
if row.for_quantity > row.transferred_qty or allow_excess_transfer:
row.make_material_request = True
row.user_employee = user_employee
return jc_data
def get_status_color(status):
color_map = {
"Pending": "blue",
@@ -257,9 +328,7 @@ def get_status_color(status):
"Submitted": "blue",
"Open": "gray",
"Closed": "green",
"Completed": "green",
"Work In Progress": "orange",
"To Manufacture": "purple",
}
return color_map.get(status, "blue")

View File

@@ -80,7 +80,7 @@
<span>
<span class="menu-btn-group-label">
<svg class="icon icon-sm">
<use href="#icon-ellipsis-vertical">
<use href="#icon-dot-vertical">
</use>
</svg>
</span>

View File

@@ -1,26 +0,0 @@
frappe.pages["shop-floor"].on_page_load = function (wrapper) {
const page = frappe.ui.make_app_page({
parent: wrapper,
title: __("Shop Floor"),
single_column: true,
// Kiosk feel — drop the desk sidebar so the floor view owns the screen.
hide_sidebar: true,
});
frappe.shop_floor = new frappe.ui.ShopFloor(
{ wrapper: $(wrapper).find(".layout-main-section") },
wrapper.page
);
};
// Pick up filters passed in via frappe.route_options (e.g. the "Shop Floor" button on Work Order)
// and switch the body into immersive (full-screen) mode while the page is shown.
// on_page_show fires on every navigation, so it also works when the page is already cached.
// (Frappe has no on_page_hide hook — the class itself drops the body class + keyboard binding
// on the next route change, see ShopFloor.bind_lifecycle.)
frappe.pages["shop-floor"].on_page_show = function () {
$(document.body).addClass("shop-floor-active");
if (frappe.shop_floor && frappe.shop_floor.on_show) {
frappe.shop_floor.on_show();
}
};

View File

@@ -1,32 +0,0 @@
{
"content": null,
"creation": "2026-05-30 10:00:00",
"docstatus": 0,
"doctype": "Page",
"idx": 0,
"modified": "2026-06-05 00:24:12.148085",
"modified_by": "Administrator",
"module": "Manufacturing",
"name": "shop-floor",
"owner": "Administrator",
"page_name": "shop-floor",
"roles": [
{
"role": "Manufacturing User"
},
{
"role": "Manufacturing Manager"
},
{
"role": "Shop Floor Manager"
},
{
"role": "Shop Floor User"
}
],
"script": null,
"standard": "Yes",
"style": null,
"system_page": 0,
"title": "Shop Floor"
}

Some files were not shown because too many files have changed in this diff Show More