Compare commits

...

94 Commits

Author SHA1 Message Date
Nabin Hait
ca9dcbf2d7 fix: reuse the existing UTM Campaign mirror when campaign_name is edited 2026-07-04 18:45:58 +05:30
Nabin Hait
9fdcfd5f58 fix: link UTM Campaign to the Campaign's document name, not campaign_name 2026-07-04 17:40:16 +05:30
Nabin Hait
5eaafd3025 test: add coverage for Campaign naming and UTM mirroring 2026-07-04 17:33:35 +05:30
Nabin Hait
807c52e32b Merge pull request #56855 from frappe/chore/test-stock-reservation-entry-dark-paths
test: cover Stock Reservation Entry validations and helper
2026-07-04 16:43:55 +05:30
Nabin Hait
38b91a2800 Merge pull request #56846 from frappe/chore/test-bisect-accounting-statements
test: add coverage for Bisect Accounting Statements
2026-07-04 16:43:28 +05:30
Nabin Hait
0f3b77a344 Merge pull request #56841 from frappe/chore/test-exchange-rate-revaluation
test: cover Exchange Rate Revaluation validation and gain/loss paths
2026-07-04 16:42:22 +05:30
ruthra kumar
66e70e312d Merge pull request #56852 from ruthra-kumar/race_condition_in_process_pcv
fix: race condition in process pcv
2026-07-04 16:22:16 +05:30
Mihir Kandoi
ac57e389a4 Merge pull request #56871 from mihir-kandoi/cast-error-postgres
fix: type cast error on postgres
2026-07-04 14:24:20 +05:30
Mihir Kandoi
093bbb07a7 fix: type cast error on postgres 2026-07-04 14:11:29 +05:30
Mihir Kandoi
57e7ceae24 Merge pull request #56859 from aerele/fix/item-form-permission-errors
Fix(stock): item form permission errors
2026-07-04 14:07:54 +05:30
rohitwaghchaure
8093e44746 feat: shop floor interface for operators (#55551)
* feat: shop floor interface for operators

* fix: documentation

* fix: UI/UX for shop floor

* fix: shop floor query and OEE edge cases from review

- Push the draft / To Manufacture condition into the Job Card query
  (or_filters) so a busy workstation's submitted history cannot fill
  the row limit and hide active drafts
- Clamp the OEE quality factor at zero when process loss exceeds
  completed qty

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 13:31:37 +05:30
Nabin Hait
55c27d3dde Merge pull request #56853 from frappe/chore/test-payment-entry-dark-paths
test: cover untested Payment Entry field validations
2026-07-04 11:52:16 +05:30
Nabin Hait
b85776c00b Merge pull request #56843 from frappe/chore/test-share-transfer
test: cover Share Transfer consistency validations
2026-07-03 23:44:40 +05:30
Nabin Hait
0f7ee3a843 Merge pull request #56844 from frappe/chore/test-process-statement-of-accounts
test: cover Process Statement Of Accounts validation
2026-07-03 23:44:29 +05:30
Nabin Hait
e546132ac3 Merge pull request #56845 from frappe/chore/test-chart-of-accounts-importer
test: add coverage for Chart of Accounts Importer parsing
2026-07-03 23:44:20 +05:30
Nabin Hait
24a13d16bb Merge pull request #56847 from frappe/chore/test-asset-capitalization
test: cover Asset Capitalization row validations
2026-07-03 23:43:52 +05:30
Nabin Hait
17e7f91690 Merge pull request #56850 from frappe/chore/test-packing-slip
test: cover Packing Slip package-number and item validations
2026-07-03 23:43:43 +05:30
Nabin Hait
ef7fb1084d Merge pull request #56854 from frappe/chore/test-serial-batch-bundle-dark-paths
test: cover Serial and Batch Bundle helpers and validations
2026-07-03 23:43:04 +05:30
Nabin Hait
18d16fa5cf Merge pull request #56851 from frappe/chore/test-email-digest
test: cover Email Digest date-window calculations
2026-07-03 23:42:25 +05:30
Nikhil Kothari
dc09362454 fix: replace all old icons (#56864) 2026-07-03 23:03:28 +05:30
Nabin Hait
2000a9db36 Merge pull request #56842 from frappe/chore/test-bank-reconciliation-tool
test: cover Bank Reconciliation Tool date filter and message helper
2026-07-03 22:08:23 +05:30
Nabin Hait
dd35d977f7 Merge pull request #56829 from frappe/chore/test-process-subscription
test: add coverage for Process Subscription
2026-07-03 22:06:19 +05:30
Mihir Kandoi
4545dd939a Merge pull request #56837 from aerele/fix/manufacture-stock-entry-cost-center-default
fix: remove company default on cost center in stock entry detail
2026-07-03 21:11:52 +05:30
rohitwaghchaure
7b0c35caaf fix: auto fetch serial no from previous operation output (#56445)
* fix: auto fetch serial no from previous operation output

* fix: order by

* fix: warehouse for operations
2026-07-03 20:23:33 +05:30
rohitwaghchaure
341a07dffa fix: restrict state-changing whitelisted endpoints to POST (#56858)
Add methods=["POST"] to 50 whitelisted functions that create or modify
documents (get_doc followed by insert/save/submit), so they can no
longer be invoked via GET requests.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 18:47:13 +05:30
pandiyan
ef794f390c fix: skip item prices tab render for users without item price read access 2026-07-03 18:25:08 +05:30
pandiyan
8c7b2f4d3c fix: clear stray permission message when item dashboard has no warehouse access 2026-07-03 18:25:01 +05:30
rohitwaghchaure
9c911438f1 fix: do not rebook standard cost variance on non-update-stock purchase invoice (#56799) 2026-07-03 17:17:38 +05:30
ruthra kumar
a9ffdac806 chore: linter fix 2026-07-03 17:16:06 +05:30
ruthra kumar
dbc409736a refactor(test): row name based utility methods 2026-07-03 17:16:03 +05:30
Nabin Hait
008742bdbe test: exercise every mandatory field in the Stock Reservation Entry check 2026-07-03 17:08:27 +05:30
Nabin Hait
ed77392741 test: also accept a mid-range rounding loss allowance 2026-07-03 17:07:31 +05:30
Nabin Hait
3240411876 test: drop unused timedelta import 2026-07-03 17:05:53 +05:30
Nabin Hait
8f96e5f2aa test: replace lambda with nested def (ruff E731) 2026-07-03 17:03:51 +05:30
Nabin Hait
7b2f38cd6f test: cover Stock Reservation Entry validations and helper 2026-07-03 16:23:52 +05:30
Nabin Hait
8456e88d93 test: cover Serial and Batch Bundle helpers and in-memory validations 2026-07-03 16:20:41 +05:30
ruthra kumar
21f4603144 refactor: prevent whole table scan while scheduling next date
- helps in concurrency isolation
2026-07-03 16:19:35 +05:30
Nabin Hait
e0ea8eee1a test: cover untested Payment Entry field validations 2026-07-03 16:15:24 +05:30
Nabin Hait
c5ab9958ff test: cover Email Digest date-window calculations 2026-07-03 16:01:10 +05:30
Nabin Hait
113d914b9c test: cover Packing Slip package-number and item validations 2026-07-03 15:59:13 +05:30
Nabin Hait
ebd8547629 test: cover Asset Capitalization row validations 2026-07-03 15:54:17 +05:30
ruthra kumar
7e4045e828 fix: prevent repeatable read related concurrency errors
Process Period Closing Voucher and Process Period Closing Voucher
Details are trackers how the jobs are processed. Keep transactions on
them very short.
2026-07-03 15:28:28 +05:30
ruthra kumar
ff6881764b fix: race condition and repeatable read in process pcv
- Update using child table name to avoid scanning whole table, which
eventually leads to mariadb 1020 (REPEATABLE READ).
 - Avoid race condition in final summarization
2026-07-03 15:28:26 +05:30
Nabin Hait
28367f75e9 test: add coverage for Bisect Accounting Statements bisection 2026-07-03 14:57:47 +05:30
Nabin Hait
740c5a07ff test: add coverage for Chart of Accounts Importer parsing 2026-07-03 14:55:31 +05:30
Nabin Hait
3dfb3f385b test: cover Process Statement Of Accounts validation defaults 2026-07-03 14:53:22 +05:30
Nabin Hait
dae90e90df test: cover Share Transfer consistency validations 2026-07-03 14:50:52 +05:30
Nabin Hait
d1d592cf0c test: cover bank reconciliation date filter and auto-reconcile message 2026-07-03 14:48:21 +05:30
Nabin Hait
65500d5102 test: cover Exchange Rate Revaluation validation and gain/loss paths 2026-07-03 14:45:06 +05:30
Nabin Hait
23e3dd94c0 Merge pull request #56831 from frappe/chore/test-process-payment-reconciliation
fix: Process Payment Reconciliation drops bank/cash and cost center filters
2026-07-03 14:31:17 +05:30
Nabin Hait
6255d99fda Merge pull request #56827 from frappe/chore/test-subscription-plan
fix: Subscription Plan Monthly Rate under-bills across a year boundary
2026-07-03 14:30:25 +05:30
Nabin Hait
81d5eac0ea Merge pull request #56824 from frappe/chore/test-journal-entry-template
fix: Journal Entry Template rows must belong to its company
2026-07-03 14:29:28 +05:30
Nabin Hait
0b20438da9 test: mirror subscription test setup for known settings 2026-07-03 14:24:58 +05:30
Nabin Hait
8632019d2a Merge pull request #56830 from frappe/chore/test-account-closing-balance
test: add coverage for Account Closing Balance
2026-07-03 14:22:40 +05:30
Nabin Hait
c9960b4d51 fix: carry bank/cash account and cost center into Payment Reconciliation 2026-07-03 14:16:46 +05:30
Nabin Hait
2cc02e61d9 fix: validate Journal Entry Template rows belong to its company 2026-07-03 14:15:19 +05:30
Nabin Hait
6fc28edde9 Merge pull request #56828 from frappe/chore/test-cashier-closing
test: add coverage for Cashier Closing
2026-07-03 14:08:55 +05:30
Nabin Hait
196730c535 fix: bill all months across a year boundary in Monthly Rate plans 2026-07-03 14:08:20 +05:30
Nabin Hait
4d39f698bd Merge pull request #56823 from frappe/chore/test-party-link
test: add coverage for Party Link
2026-07-03 14:07:04 +05:30
Nabin Hait
0a7abe7144 Merge pull request #56822 from frappe/chore/test-mode-of-payment
test: add coverage for Mode of Payment
2026-07-03 14:06:50 +05:30
pandiyan
a168bb7ea4 test: cover cost center fallback to item group default in manufacture entry
the existing test_cost_center_for_manufacture only checks a raw material
row against an item-level override, which is set independently of the
":company" default guard and never exercised the bug.
2026-07-03 14:06:22 +05:30
pandiyan
edfa0a7a1d fix: remove company default on cost center in stock entry detail
the ":company" default pre-filled every row before set_default_cost_center()
ran, so its "if not row.cost_center" guard was always false and the
project/item group/brand priority chain in get_default_cost_center()
never ran.
2026-07-03 14:06:22 +05:30
Nabin Hait
5888cdf3a0 Merge pull request #56821 from frappe/chore/test-item-tax-template
test: add coverage for Item Tax Template
2026-07-03 14:04:03 +05:30
Nabin Hait
a1f413e8a8 Merge pull request #56820 from frappe/chore/test-monthly-distribution
test: add coverage for Monthly Distribution
2026-07-03 14:03:36 +05:30
Nabin Hait
cd167bdd40 Merge pull request #56819 from frappe/chore/test-bank-guarantee
test: add coverage for Bank Guarantee
2026-07-03 14:02:53 +05:30
Mihir Kandoi
5d48c44bbb Merge pull request #56826 from frappe/fix/stock-ledger-invariant-check-report
fix: FIFO queue checks and incorrect entries filter in stock ledger reports
2026-07-03 13:19:29 +05:30
rohitwaghchaure
ecc8ec672b fix: replay immutable SLE qty for serial/batch bundle valuation (#56814) 2026-07-03 12:15:07 +05:30
Mihir Kandoi
3b1e57966e test: drop redundant cleanup, db rolls back after each test 2026-07-03 12:12:46 +05:30
Nabin Hait
974571aba7 test: guard account lookups and cover dropped pr_instance filters 2026-07-03 12:08:11 +05:30
Nabin Hait
7d917e497a test: assert account-currency sums carry through the merge 2026-07-03 12:06:56 +05:30
Nabin Hait
e041e33860 test: reload invoice for outstanding and cover equal-time boundary 2026-07-03 12:06:20 +05:30
Nabin Hait
832b5a56bf test: lock current cross-year monthly-rate underbilling value 2026-07-03 12:05:31 +05:30
Nabin Hait
abded56174 test: guard account lookup and lock missing company-check behaviour 2026-07-03 12:04:42 +05:30
Nabin Hait
6f866545b9 test: complete supplier-primary assertions and lock uniqueness gap 2026-07-03 12:03:56 +05:30
Nabin Hait
147e1539dc test: guard account lookup and lock dead POS guard behaviour 2026-07-03 12:02:50 +05:30
Nabin Hait
f58ea8e17d test: guard account lookup and lock current tax-rate behaviour 2026-07-03 12:01:47 +05:30
Nabin Hait
9980d47524 test: lock current end-date behaviour and assert persisted state 2026-07-03 12:00:45 +05:30
Nabin Hait
97794b7ded test: add coverage for Process Payment Reconciliation 2026-07-03 11:41:21 +05:30
Mihir Kandoi
ef5f47fafd fix: address review comments
- restore mutated SLE after test via addCleanup
- explicit return False in has_difference
- comment the fifo_stock_diff guard for non-queue predecessors
2026-07-03 11:39:51 +05:30
Nabin Hait
c51edbd88e test: add coverage for Account Closing Balance 2026-07-03 11:39:50 +05:30
Nabin Hait
745f657a0e test: add coverage for Process Subscription 2026-07-03 11:37:02 +05:30
Nabin Hait
5c87e2e398 test: add coverage for Cashier Closing 2026-07-03 11:34:27 +05:30
Nabin Hait
3167e8ba77 test: add coverage for Subscription Plan 2026-07-03 11:31:30 +05:30
Mihir Kandoi
94ab09e4a3 fix: FIFO queue checks and incorrect entries filter in stock ledger reports
- 'Show Incorrect Entries' always returned an empty result (regression
  from #43619); now returns entries from one row before the first
  incorrect one
- FIFO queue columns were computed for serialized/batched SLEs that
  don't maintain a stock queue, showing false differences; left empty
  for such rows
- compare value/valuation differences at currency precision, qty at
  float precision
2026-07-03 11:29:44 +05:30
Nabin Hait
83d821d8c4 test: add coverage for Journal Entry Template 2026-07-03 11:28:56 +05:30
Nabin Hait
22dc51a57a test: add coverage for Party Link 2026-07-03 11:25:32 +05:30
Nabin Hait
df54382727 test: add coverage for Mode of Payment 2026-07-03 11:23:08 +05:30
Nabin Hait
3e9843059e test: add coverage for Item Tax Template 2026-07-03 11:19:56 +05:30
Nabin Hait
ccd2aae481 test: add coverage for Monthly Distribution 2026-07-03 11:18:11 +05:30
Nabin Hait
41000ea109 test: add coverage for Bank Guarantee 2026-07-03 11:14:54 +05:30
Khushi Rawat
344f58b98a Merge pull request #56811 from khushi8112/fix/letterhead-footer-print-formats
fix: render letter head footer in print formats
2026-07-03 02:43:26 +05:30
Khushi Rawat
c9145c5ece Merge branch 'develop' into fix/letterhead-footer-print-formats 2026-07-03 02:27:58 +05:30
khushi8112
2d0c0a8c09 fix: add page numbers to print format footer 2026-07-03 02:26:52 +05:30
khushi8112
e60a467972 fix: render letter head footer in print formats 2026-07-03 02:16:59 +05:30
208 changed files with 6786 additions and 1369 deletions

View File

@@ -1,10 +1,59 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
aggregate_with_last_account_closing_balance,
generate_key,
)
from erpnext.tests.utils import ERPNextTestSuite
def entry(**overrides):
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
row.update(overrides)
return row
class TestAccountClosingBalance(ERPNextTestSuite):
pass
"""The closing-balance snapshot is built by merging this period's entries with the
previous period's. These lock the merge/key logic that drives that carry-forward."""
def test_matching_entries_are_summed(self):
# this is how a prior-period balance carries forward into the current one
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
entry(
account="Cash - _TC",
debit=50,
credit=20,
debit_in_account_currency=50,
credit_in_account_currency=20,
),
],
[],
)
self.assertEqual(len(merged), 1)
row = next(iter(merged.values()))
self.assertEqual(row["debit"], 150)
self.assertEqual(row["credit"], 20)
# the account-currency columns are accumulated in the same pass
self.assertEqual(row["debit_in_account_currency"], 150)
self.assertEqual(row["credit_in_account_currency"], 20)
def test_entries_are_kept_separate_per_dimension(self):
merged = aggregate_with_last_account_closing_balance(
[
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
],
[],
)
self.assertEqual(len(merged), 2)
def test_period_closing_flag_is_part_of_the_key(self):
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
# account must not merge, so the flag has to distinguish their keys
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
self.assertNotEqual(key_reversal, key_closing)

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>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
</p>
</td></tr>

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()
@frappe.whitelist(methods=["POST"])
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
"""
Set the closing balance as per statement for a bank account and date

View File

@@ -1,8 +1,76 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import flt
from erpnext.accounts.doctype.bank_guarantee.bank_guarantee import get_voucher_details
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.tests.utils import ERPNextTestSuite
BANK = "_Test BG Bank"
class TestBankGuarantee(ERPNextTestSuite):
pass
"""Bank Guarantee records a guarantee issued/received against a customer or
supplier. validate() needs a party; on_submit() needs the bank details filled in."""
def setUp(self):
frappe.set_user("Administrator")
if not frappe.db.exists("Bank", BANK):
frappe.get_doc({"doctype": "Bank", "bank_name": BANK}).insert()
def make_bg(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Bank Guarantee")
doc.bg_type = args.bg_type or "Receiving"
doc.amount = args.amount if args.amount is not None else 1000
doc.start_date = args.start_date or "2026-06-01"
if args.end_date:
doc.end_date = args.end_date
doc.customer = args.get("customer", "_Test Customer")
doc.supplier = args.get("supplier")
# fields on_submit requires — present by default, cleared per-test to assert the guard
doc.bank_guarantee_number = args.get("bank_guarantee_number", "BG-001")
doc.name_of_beneficiary = args.get("name_of_beneficiary", "Test Beneficiary")
doc.bank = args.get("bank", BANK)
return doc
def test_validate_requires_customer_or_supplier(self):
doc = self.make_bg(customer=None)
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_requires_guarantee_number(self):
doc = self.make_bg(bank_guarantee_number="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_beneficiary_name(self):
doc = self.make_bg(name_of_beneficiary="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_submit_requires_bank(self):
doc = self.make_bg(bank="")
doc.insert()
self.assertRaises(frappe.ValidationError, doc.submit)
def test_valid_guarantee_submits(self):
doc = self.make_bg()
doc.insert()
doc.submit()
self.assertEqual(frappe.db.get_value("Bank Guarantee", doc.name, "docstatus"), 1)
def test_get_voucher_details_for_receiving(self):
so = make_sales_order()
details = get_voucher_details("Receiving", so.name)
self.assertEqual(details.customer, so.customer)
self.assertEqual(flt(details.grand_total), flt(so.grand_total))
def test_end_date_before_start_date_is_not_validated(self):
# SUSPECTED BUG: validate() never checks that end_date >= start_date, so a
# guarantee that expires before it starts saves cleanly. Locking the current
# (wrong) behaviour so a future fix that adds the check trips this test.
doc = self.make_bg(start_date="2026-06-30", end_date="2026-06-01")
doc.insert()
self.assertTrue(frappe.db.exists("Bank Guarantee", doc.name))

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

View File

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

View File

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

View File

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

View File

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

View File

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

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()
@frappe.whitelist(methods=["POST"])
def revise_budget(budget_name: str):
old_budget = frappe.get_doc("Budget", budget_name)

View File

@@ -1,8 +1,67 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.tests.utils import ERPNextTestSuite
DATE = "2026-06-15"
class TestCashierClosing(ERPNextTestSuite):
pass
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
date/time window and rolls payments, expense, custody and returns into net_amount."""
def setUp(self):
frappe.set_user("Administrator")
def make_invoice_in_window(self, rate=100):
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
si.posting_time = "10:30:00"
si.submit()
si.reload() # read outstanding_amount as persisted after submit
return si
def make_closing(self, user="Administrator", payments=None, **args):
doc = frappe.new_doc("Cashier Closing")
doc.user = user
doc.date = args.get("date", DATE)
doc.from_time = args.get("from_time", "09:00:00")
doc.time = args.get("time", "18:00:00")
for amount in payments or []:
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
doc.expense = args.get("expense", 0)
doc.custody = args.get("custody", 0)
doc.returns = args.get("returns", 0)
return doc
def test_from_time_must_be_before_to_time(self):
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_equal_from_and_to_time_is_rejected(self):
# validate_time uses >=, so a zero-length window is also blocked
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
self.assertRaises(frappe.ValidationError, doc.save)
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
si = self.make_invoice_in_window(rate=100)
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
doc.save()
# the in-window invoice is picked up as outstanding
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
# net = payments + outstanding + expense - custody + returns
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
def test_outstanding_is_scoped_to_the_invoice_owner(self):
# The invoice is created by Administrator; a closing for a different user does
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
# creator) rather than an explicit cashier/POS-user field, which is fragile when
# invoices are created by a shared or system user.
self.make_invoice_in_window(rate=100)
doc = self.make_closing(user="Guest", payments=[500])
doc.save()
self.assertEqual(doc.outstanding_amount, 0)
self.assertEqual(doc.net_amount, 500)

View File

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

View File

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

View File

@@ -298,3 +298,65 @@ 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

@@ -1,8 +1,62 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
TAX_ACCOUNT = "_Test Account VAT - _TC"
RECEIVABLE_ACCOUNT = "Debtors - _TC"
class TestItemTaxTemplate(ERPNextTestSuite):
pass
"""Item Tax Template validates its tax rows: each account must belong to the
company, be a tax-like account type, and appear only once."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, title="_Test ITT"):
doc = frappe.new_doc("Item Tax Template")
doc.title = f"{title} {frappe.generate_hash(length=6)}"
doc.company = COMPANY
for account, rate, not_applicable in rows:
doc.append(
"taxes",
{"tax_type": account, "tax_rate": rate, "not_applicable": not_applicable},
)
return doc
def test_valid_template_saves_and_is_named_with_abbr(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0)])
doc.insert()
self.assertTrue(doc.name.endswith(" - _TC"))
self.assertTrue(doc.name.startswith(doc.title))
def test_duplicate_tax_type_throws(self):
doc = self.make_template([(TAX_ACCOUNT, 9, 0), (TAX_ACCOUNT, 5, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
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_template([(other_account, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disallowed_account_type_throws(self):
# a Receivable account is not Tax/Chargeable/Income/Expense
doc = self.make_template([(RECEIVABLE_ACCOUNT, 9, 0)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_not_applicable_row_has_rate_zeroed(self):
doc = self.make_template([(TAX_ACCOUNT, 18, 1)])
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.
doc = self.make_template([(TAX_ACCOUNT, -5, 0)])
doc.insert()
self.assertEqual(doc.taxes[0].tax_rate, -5)

View File

@@ -45,6 +45,20 @@ class JournalEntryTemplate(Document):
def validate(self):
self.validate_party()
self.validate_account_company()
def validate_account_company(self):
"""Each row's account must belong to the template's company."""
for account in self.accounts:
if (
account.account
and frappe.get_cached_value("Account", account.account, "company") != self.company
):
frappe.throw(
_("Row {0}: Account {1} does not belong to company {2}").format(
account.idx, account.account, self.company
)
)
def validate_party(self):
"""

View File

@@ -1,9 +1,45 @@
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestJournalEntryTemplate(ERPNextTestSuite):
pass
"""Journal Entry Template's only real rule is validate_party: party_type is
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
def setUp(self):
frappe.set_user("Administrator")
def make_template(self, rows, company=COMPANY):
doc = frappe.new_doc("Journal Entry Template")
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
doc.company = company
doc.voucher_type = "Journal Entry"
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
for row in rows:
doc.append("accounts", row)
return doc
def test_party_type_only_on_receivable_or_payable_account(self):
# Cash is neither Receivable nor Payable, so a party_type here is invalid
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_party_requires_party_type(self):
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
self.assertRaises(frappe.ValidationError, doc.validate)
def test_account_from_other_company_is_rejected(self):
other_receivable = frappe.db.get_value(
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
)
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
doc = self.make_template(
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
)
self.assertRaises(frappe.ValidationError, doc.insert)

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>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>

View File

@@ -5,9 +5,59 @@ import frappe
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestModeofPayment(ERPNextTestSuite):
pass
"""Mode of Payment validates its per-company default accounts (account company
must match the row, no company twice) and blocks disabling while a POS Profile
still references it."""
def setUp(self):
frappe.set_user("Administrator")
def make_mop(self, accounts=None, enabled=1):
doc = frappe.new_doc("Mode of Payment")
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
doc.type = "General"
doc.enabled = enabled
for company, account in accounts or []:
doc.append("accounts", {"company": company, "default_account": account})
return doc
def test_valid_mode_of_payment_saves(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
doc.insert()
self.assertTrue(doc.name)
def test_account_of_wrong_company_throws(self):
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
doc = self.make_mop(accounts=[(COMPANY, other_account)])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_repeating_company_throws(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
self.assertRaises(frappe.ValidationError, doc.insert)
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
# and a mode still referenced by a POS Profile disables without complaint.
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
make_pos_profile() # its payments row references the "Cash" mode of payment
cash = frappe.get_doc("Mode of Payment", "Cash")
cash.enabled = 0
cash.save()
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
def test_disabling_unreferenced_mode_succeeds(self):
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
doc.insert()
self.assertEqual(doc.enabled, 0)
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):

View File

@@ -1,8 +1,67 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from frappe.utils import getdate
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
get_percentage,
get_periodwise_distribution_data,
)
from erpnext.tests.utils import ERPNextTestSuite
class TestMonthlyDistribution(ERPNextTestSuite):
pass
"""Monthly Distribution spreads an amount across months. validate() enforces a
100% total; get_percentage() sums the months that fall inside a period window."""
def setUp(self):
frappe.set_user("Administrator")
def make_distribution(self, allocations):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
for month, pct in allocations:
doc.append("percentages", {"month": month, "percentage_allocation": pct})
return doc
def test_get_months_populates_twelve_even_rows(self):
doc = frappe.new_doc("Monthly Distribution")
doc.distribution_id = "_Test MD Even"
doc.get_months()
self.assertEqual(len(doc.percentages), 12)
self.assertEqual(doc.percentages[0].month, "January")
self.assertEqual(doc.percentages[-1].month, "December")
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
for d in doc.percentages:
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
# the auto-populated rows round to exactly 100 and pass validation
doc.validate()
def test_validate_rejects_total_other_than_100(self):
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
self.assertRaises(frappe.ValidationError, doc.insert)
def test_get_percentage_sums_period_window(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert() # total is 100, so validate passes
# a quarter starting in January covers Jan+Feb+Mar
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
# a single month picks up only that month
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
# months with no row simply contribute 0 (there is no guard that all 12 exist)
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
def test_periodwise_distribution_maps_each_period(self):
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
doc.insert()
period_list = [
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
]
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation

View File

@@ -1,9 +1,67 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.party_link.party_link import create_party_link
from erpnext.tests.utils import ERPNextTestSuite
CUSTOMER = "_Test Customer"
SUPPLIER = "_Test Supplier"
SUPPLIER_2 = "_Test Supplier 1"
class TestPartyLink(ERPNextTestSuite):
pass
"""Party Link ties a Customer and a Supplier together as one underlying party.
validate() constrains the primary role and blocks duplicate links."""
def setUp(self):
frappe.set_user("Administrator")
def test_create_party_link_with_customer_primary(self):
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
self.assertEqual(link.primary_role, "Customer")
self.assertEqual(link.secondary_role, "Supplier")
self.assertEqual(link.primary_party, CUSTOMER)
self.assertEqual(link.secondary_party, SUPPLIER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_create_party_link_with_supplier_primary(self):
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
self.assertEqual(link.primary_role, "Supplier")
self.assertEqual(link.secondary_role, "Customer")
self.assertEqual(link.primary_party, SUPPLIER)
self.assertEqual(link.secondary_party, CUSTOMER)
self.assertTrue(frappe.db.exists("Party Link", link.name))
def test_primary_role_must_be_customer_or_supplier(self):
doc = frappe.new_doc("Party Link")
doc.primary_role = "Employee"
doc.primary_party = CUSTOMER
doc.secondary_role = "Supplier"
doc.secondary_party = SUPPLIER
# validate() alone isolates the role rule from the dynamic-link checks
self.assertRaises(frappe.ValidationError, doc.validate)
def test_duplicate_link_throws(self):
create_party_link("Customer", CUSTOMER, SUPPLIER)
dup = frappe.new_doc("Party Link")
dup.primary_role = "Customer"
dup.primary_party = CUSTOMER
dup.secondary_role = "Supplier"
dup.secondary_party = SUPPLIER
self.assertRaises(frappe.ValidationError, dup.insert)
def test_party_can_wrongly_be_primary_in_two_links(self):
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
# *primary* in another link isn't blocked, so one customer can be linked to two
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
# behaviour so a fix that blocks primary reuse trips this test.
create_party_link("Customer", CUSTOMER, SUPPLIER)
link2 = frappe.new_doc("Party Link")
link2.primary_role = "Customer"
link2.primary_party = CUSTOMER
link2.secondary_role = "Supplier"
link2.secondary_party = SUPPLIER_2
link2.insert()
self.assertTrue(frappe.db.exists("Party Link", link2.name))

View File

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

View File

@@ -2317,3 +2317,65 @@ 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()
@frappe.whitelist(methods=["POST"])
def make_payment_request(**args):
"""Make payment request"""

View File

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

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>
<i class="fa fa-hand-right"></i>
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
${__("Notes")}
</h4>
<ul>
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
</ul>
</td></tr>
<tr><td>
<h4><i class="fa fa-question-sign"></i>
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
${__("How Pricing Rule is applied?")}
</h4>
<ol>

View File

@@ -106,6 +106,8 @@ def get_pr_instance(doc: str):
"party",
"receivable_payable_account",
"default_advance_account",
"bank_cash_account",
"cost_center",
"from_invoice_date",
"to_invoice_date",
"from_payment_date",

View File

@@ -1,11 +1,73 @@
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
get_pr_instance,
)
from erpnext.tests.utils import ERPNextTestSuite
COMPANY = "_Test Company"
class TestProcessPaymentReconciliation(ERPNextTestSuite):
pass
"""Process Payment Reconciliation validates its accounts against the company,
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
def setUp(self):
frappe.set_user("Administrator")
def make_ppr(self, **args):
args = frappe._dict(args)
doc = frappe.new_doc("Process Payment Reconciliation")
doc.company = COMPANY
doc.party_type = "Customer"
doc.party = "_Test Customer"
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
doc.bank_cash_account = args.get("bank_cash_account")
doc.from_invoice_date = args.get("from_invoice_date")
doc.to_invoice_date = args.get("to_invoice_date")
return doc
def other_company_account(self, **extra):
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
account = frappe.db.get_value("Account", filters, "name")
self.assertTrue(account, "need a matching account in _Test Company 1")
return account
def test_receivable_account_must_belong_to_company(self):
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
self.assertRaises(frappe.ValidationError, doc.insert)
def test_bank_cash_account_must_belong_to_company(self):
doc = self.make_ppr(bank_cash_account=self.other_company_account())
self.assertRaises(frappe.ValidationError, doc.insert)
def test_submit_sets_status_to_queued(self):
doc = self.make_ppr()
doc.insert()
doc.submit()
self.assertEqual(doc.status, "Queued")
def test_get_pr_instance_copies_filters_and_caps_limits(self):
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.company, COMPANY)
self.assertEqual(pr.party, "_Test Customer")
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
# the tool run is capped so a single process can't fetch unbounded rows
self.assertEqual(pr.invoice_limit, 1000)
self.assertEqual(pr.payment_limit, 1000)
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
doc = self.make_ppr(bank_cash_account="Cash - _TC")
doc.cost_center = "_Test Cost Center - _TC"
doc.insert()
pr = get_pr_instance(doc.name)
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")

View File

@@ -89,50 +89,55 @@ 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)
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")
initialize_parallel_threads(docname)
@frappe.whitelist()
@@ -250,11 +255,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")
if to_process := (
qb.from_(ppcvd)
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
.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(1)
@@ -264,15 +269,15 @@ def schedule_next_date(docname: str):
if not is_scheduler_inactive():
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{
"processing_date": to_process[0].processing_date,
"parent": docname,
"report_type": to_process[0].report_type,
"parentfield": to_process[0].parentfield,
},
to_process[0].name,
"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",
@@ -280,6 +285,7 @@ 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,
@@ -444,6 +450,11 @@ 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")
@@ -529,10 +540,10 @@ def build_dimension_wise_balance_dict(gl_entries):
return dimension_balances
def process_individual_date(docname: str, date, report_type, parentfield):
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
current_date_status = frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
row_name,
"status",
)
if current_date_status != "Running":
@@ -580,17 +591,20 @@ def process_individual_date(docname: str, date, report_type, parentfield):
# save results
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"closing_balance",
frappe.json.dumps(res),
)
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
row_name,
"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,18 +48,27 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
ppcv.save()
return ppcv
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
def set_processing_date_status(self, row_name, status):
frappe.db.set_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"status",
status,
)
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
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):
return frappe.db.get_value(
"Process Period Closing Voucher Detail",
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
row_name,
"closing_balance",
)
@@ -97,11 +106,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
parentfield = "normal_balances"
rpt_type = "Profit and Loss"
# status has to be set to 'Running' for logic to run
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)
)
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.assertEqual(len(bal), 1)
expected_pl = {
"account": "Sales - _TC",
@@ -117,11 +125,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Balance sheet balance
rpt_type = "Balance Sheet"
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)
)
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.assertEqual(len(bal), 1)
expected_bs = {
"account": "Debtors - _TC",
@@ -138,11 +145,10 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
# Opening balance
parentfield = "z_opening_balances"
rpt_type = "Balance Sheet"
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)
)
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.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,3 +24,10 @@ 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,3 +113,38 @@ def create_process_soa(**args):
process_soa.update(soa_dict)
process_soa.save()
return process_soa
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
"""validate() fills in default subject/body/pdf templates and enforces the
basic constraints. Exercised on the document directly (no email/PDF flow)."""
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
doc = frappe.new_doc("Process Statement Of Accounts")
doc.report = report
doc.company = "_Test Company"
if with_customer:
doc.append("customers", {"customer": "_Test Customer"})
doc.update(overrides)
return doc
def test_customers_are_required(self):
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
def test_general_ledger_body_uses_a_date_range(self):
doc = self.make_soa(report="General Ledger")
doc.validate()
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
# subject and pdf name are also defaulted
self.assertTrue(doc.subject)
self.assertTrue(doc.pdf_name)
def test_receivable_body_uses_the_posting_date(self):
doc = self.make_soa(report="Accounts Receivable")
doc.validate()
self.assertIn("until {{ doc.posting_date }}", doc.body)
def test_account_must_belong_to_company(self):
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
self.assertTrue(other, "need an account in _Test Company 1")
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)

View File

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

View File

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

View File

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

View File

@@ -79,7 +79,9 @@ def get_plan_rate(
start_date = getdate(start_date)
end_date = getdate(end_date)
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
delta = relativedelta.relativedelta(end_date, start_date)
# include the years component so cross-year spans aren't under-counted
no_of_months = delta.years * 12 + delta.months + 1
cost = plan.cost * no_of_months
# Adjust cost if start or end date is not month start or end

View File

@@ -1,8 +1,54 @@
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import frappe
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
from erpnext.tests.utils import ERPNextTestSuite
class TestSubscriptionPlan(ERPNextTestSuite):
pass
"""Subscription Plan validates its interval and computes a rate. The Monthly
Rate branch multiplies cost by the number of months in the billing window."""
def setUp(self):
frappe.set_user("Administrator")
def make_plan(self, **args):
args = frappe._dict(args)
plan = frappe.new_doc("Subscription Plan")
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
plan.item = args.item or "_Test Item"
plan.currency = args.currency or "INR"
plan.price_determination = args.price_determination
plan.cost = args.cost or 0
plan.billing_interval = args.billing_interval or "Month"
plan.billing_interval_count = (
args.billing_interval_count if args.billing_interval_count is not None else 1
)
return plan
def test_billing_interval_count_must_be_positive(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
self.assertRaises(frappe.ValidationError, plan.insert)
def test_fixed_rate_applies_prorate_factor(self):
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
plan.insert()
self.assertEqual(get_plan_rate(plan.name), 100)
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
def test_monthly_rate_within_year(self):
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
self.assertEqual(rate, 300)
def test_monthly_rate_across_year_boundary(self):
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
# 2-month remainder that relativedelta.months alone would give
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
plan.insert()
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
self.assertEqual(rate, 1400)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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()
@frappe.whitelist(methods=["POST"])
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()
@frappe.whitelist(methods=["POST"])
def make_all_scorecards(docname: str):
sc = frappe.get_doc("Supplier Scorecard", docname)
supplier = frappe.get_doc("Supplier", sc.supplier)

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -20,7 +20,7 @@ from frappe.query_builder.functions import (
Substring,
Sum,
)
from frappe.utils import nowdate, today, unique
from frappe.utils import cint, nowdate, today, unique
from pypika import Order
import erpnext
@@ -808,7 +808,11 @@ def get_filtered_dimensions(
query_filters.append(["company", "=", filters.get("company")])
for field in searchfields:
or_filters.append([field, "LIKE", "%%%s%%" % txt])
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)])
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()
@frappe.whitelist(methods=["POST"])
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");
},
"fa fa-list",
null,
true
);
}

View File

@@ -26,23 +26,23 @@ class Campaign(Document):
# end: auto-generated types
def after_insert(self):
try:
mc = frappe.get_doc("UTM Campaign", self.campaign_name)
except frappe.DoesNotExistError:
mc = frappe.new_doc("UTM Campaign")
mc.name = self.campaign_name
mc.campaign_description = self.description
mc.crm_campaign = self.campaign_name
mc.save(ignore_permissions=True)
self.sync_utm_campaign()
def on_change(self):
self.sync_utm_campaign()
def sync_utm_campaign(self):
# look up the existing mirror by the stable Campaign link first, so editing
# campaign_name updates that mirror instead of creating a duplicate
existing = frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}) or self.campaign_name
try:
mc = frappe.get_doc("UTM Campaign", self.campaign_name)
mc = frappe.get_doc("UTM Campaign", existing)
except frappe.DoesNotExistError:
mc = frappe.new_doc("UTM Campaign")
mc.name = self.campaign_name
mc.campaign_description = self.description
mc.crm_campaign = self.campaign_name
# link by the document name, which differs from campaign_name when a naming series is used
mc.crm_campaign = self.name
mc.save(ignore_permissions=True)
def autoname(self):

View File

@@ -1,9 +1,52 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
# import frappe
import frappe
from erpnext.tests.utils import ERPNextTestSuite
class TestCampaign(ERPNextTestSuite):
pass
"""Campaign names itself from the campaign name (or a naming series) and mirrors
itself into a UTM Campaign."""
def setUp(self):
frappe.set_user("Administrator")
def make_campaign(self, **fields):
doc = frappe.new_doc("Campaign")
doc.campaign_name = fields.pop("campaign_name", f"_Test Campaign {frappe.generate_hash(length=6)}")
doc.update(fields)
return doc.insert()
def test_autoname_uses_the_campaign_name_by_default(self):
campaign = self.make_campaign(campaign_name="_Test Campaign Named")
self.assertEqual(campaign.name, "_Test Campaign Named")
def test_autoname_uses_naming_series_when_configured(self):
# regression: with a naming series the document name differs from campaign_name,
# and the UTM sync must still link back to a valid Campaign (self.name)
original = frappe.defaults.get_global_default("campaign_naming_by")
frappe.defaults.set_global_default("campaign_naming_by", "Naming Series")
try:
campaign = self.make_campaign(naming_series="SAL-CAM-.YYYY.-")
self.assertTrue(campaign.name.startswith("SAL-CAM-"))
utm = frappe.get_doc("UTM Campaign", campaign.campaign_name)
self.assertEqual(utm.crm_campaign, campaign.name)
finally:
frappe.defaults.set_global_default("campaign_naming_by", original or "")
def test_inserting_mirrors_into_a_utm_campaign(self):
campaign = self.make_campaign(campaign_name="_Test Campaign UTM", description="Spring push")
self.assertTrue(frappe.db.exists("UTM Campaign", campaign.campaign_name))
utm = frappe.get_doc("UTM Campaign", campaign.campaign_name)
self.assertEqual(utm.campaign_description, "Spring push")
self.assertEqual(utm.crm_campaign, campaign.name)
def test_editing_campaign_name_reuses_the_same_utm_campaign(self):
campaign = self.make_campaign(campaign_name="_Test Campaign Rename A")
campaign.campaign_name = "_Test Campaign Rename B"
campaign.save()
# the edit updates the existing mirror rather than creating a second one
mirrors = frappe.get_all("UTM Campaign", filters={"crm_campaign": campaign.name})
self.assertEqual(len(mirrors), 1)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -131,6 +131,7 @@ class JobCard(Document):
"Material Transferred",
"On Hold",
"Submitted",
"To Manufacture",
"Cancelled",
"Completed",
]
@@ -1302,8 +1303,13 @@ 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,6 +86,10 @@ def make_material_request(source_name: str, target_doc: Document | str | None =
@frappe.whitelist()
def make_stock_entry(source_name: str, target_doc: Document | str | None = None):
from erpnext.stock.doctype.stock_entry.services.manufacturing import (
set_previous_operation_serial_batch,
)
def update_item(source, target, source_parent):
target.t_warehouse = source_parent.wip_warehouse
@@ -125,6 +129,7 @@ def make_stock_entry(source_name: str, target_doc: Document | str | None = None)
wo_allows_alternate_item
and frappe.get_cached_value("Item", item.item_code, "allow_alternative_item")
)
set_previous_operation_serial_batch(target, item)
doclist = get_mapped_doc(
"Job Card",

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,17 +12,14 @@ frappe.ui.form.on("Workstation", {
refresh(frm) {
frm.trigger("set_illustration_image");
frm.trigger("prepapre_dashboard");
},
prepapre_dashboard(frm) {
let $parent = $(frm.fields_dict["workstation_dashboard"].wrapper);
$parent.empty();
let workstation_dashboard = new WorkstationDashboard({
wrapper: $parent,
frm: frm,
});
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");
});
}
},
onload(frm) {
@@ -80,533 +77,3 @@ 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,9 +8,6 @@
"document_type": "Setup",
"engine": "InnoDB",
"field_order": [
"dashboard_tab",
"section_break_mqqv",
"workstation_dashboard",
"details_tab",
"workstation_name",
"workstation_type",
@@ -173,12 +170,6 @@
"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",
@@ -190,16 +181,6 @@
"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",

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