mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-05 14:40:52 +00:00
Compare commits
1 Commits
develop
...
chore/test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db67e4ca0d |
28
.github/POSTGRES_COMPATIBILITY.md
vendored
28
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -150,34 +150,6 @@ Add it only when it is functionally dependent on the existing select columns; ot
|
||||
SQL `ORDER BY` and **sort in Python** (`key=str.casefold`, per §2) so the distinct row set is
|
||||
unchanged.
|
||||
|
||||
### 3.1 Second-order traps — when the `Max()`/`Min()` wrap itself is the bug
|
||||
|
||||
The wrap is only a no-op when the column is provably single-valued per group (**"`Max()` means
|
||||
provably constant"**). When the column can genuinely vary, the wrap is a decision, and a full
|
||||
audit of these fixes found four recurring mistakes:
|
||||
|
||||
- **Incoherent pair** — two semantically-coupled columns (a flag + a link:
|
||||
`is_phantom_item` + `bom_no`; a discriminator + its value) aggregated with *independent*
|
||||
`Max()`/`Min()` can pair values from **different rows** — a chimera row that never existed.
|
||||
MariaDB's loose pick was at least row-coherent. Fix: group by the pair (when consumers
|
||||
tolerate the extra rows), or select one **representative row** (`Min(child.name)` subquery +
|
||||
join-back) so every column comes from the same line.
|
||||
- **NULL-skipping** — `MAX`/`MIN` ignore NULLs, so `Max()` over a mostly-NULL discriminator
|
||||
(an `original_item`-style column) *deterministically* returns the non-NULL value where
|
||||
MariaDB could return NULL — deterministically wrong where the old behavior was only
|
||||
intermittently wrong. Flag it wherever "no value" is a meaningful state (fallback gates,
|
||||
dict keys).
|
||||
- **Fabricated arithmetic** — `Sum(x) * Max(y)` where `y` varies within the group invents a
|
||||
number no row ever had (and `Max` biases it upward) — poisonous when it feeds validation,
|
||||
budgets, valuation, or GL/stock values. Fix per-row: `Sum(x * y)`.
|
||||
- **Wrong bound** — where the value has a semantic, pick the bound deliberately:
|
||||
`Min(schedule_date)` for a "required by", `Min(idx)` for first-line ordering, a qty-weighted
|
||||
average for a rate. A blind `Max` can understate urgency or overstate a figure.
|
||||
|
||||
Review heuristic: **if choosing between `Max` and `Min` would change the answer, the column is
|
||||
not functionally dependent** — wrapping either is the wrong fix. Group by it, restructure, or
|
||||
pick a bound for a stated reason, and cover the varying-group case with a test.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,59 +1,10 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
aggregate_with_last_account_closing_balance,
|
||||
generate_key,
|
||||
)
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def entry(**overrides):
|
||||
row = {"debit": 0, "credit": 0, "debit_in_account_currency": 0, "credit_in_account_currency": 0}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class TestAccountClosingBalance(ERPNextTestSuite):
|
||||
"""The closing-balance snapshot is built by merging this period's entries with the
|
||||
previous period's. These lock the merge/key logic that drives that carry-forward."""
|
||||
|
||||
def test_matching_entries_are_summed(self):
|
||||
# this is how a prior-period balance carries forward into the current one
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", debit=100, debit_in_account_currency=100),
|
||||
entry(
|
||||
account="Cash - _TC",
|
||||
debit=50,
|
||||
credit=20,
|
||||
debit_in_account_currency=50,
|
||||
credit_in_account_currency=20,
|
||||
),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 1)
|
||||
row = next(iter(merged.values()))
|
||||
self.assertEqual(row["debit"], 150)
|
||||
self.assertEqual(row["credit"], 20)
|
||||
# the account-currency columns are accumulated in the same pass
|
||||
self.assertEqual(row["debit_in_account_currency"], 150)
|
||||
self.assertEqual(row["credit_in_account_currency"], 20)
|
||||
|
||||
def test_entries_are_kept_separate_per_dimension(self):
|
||||
merged = aggregate_with_last_account_closing_balance(
|
||||
[
|
||||
entry(account="Cash - _TC", cost_center="CC1", debit=100, debit_in_account_currency=100),
|
||||
entry(account="Cash - _TC", cost_center="CC2", debit=40, debit_in_account_currency=40),
|
||||
],
|
||||
[],
|
||||
)
|
||||
self.assertEqual(len(merged), 2)
|
||||
|
||||
def test_period_closing_flag_is_part_of_the_key(self):
|
||||
# a P&L reversal (flag 0) and a closing-account entry (flag 1) for the same
|
||||
# account must not merge, so the flag has to distinguish their keys
|
||||
key_reversal, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=0), [])
|
||||
key_closing, _ = generate_key(entry(account="Sales - _TC", is_period_closing_voucher_entry=1), [])
|
||||
self.assertNotEqual(key_reversal, key_closing)
|
||||
pass
|
||||
|
||||
@@ -6,7 +6,7 @@ frappe.ui.form.on("Accounting Dimension Filter", {
|
||||
let help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<p>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
{{__('Note: On checking Is Mandatory the accounting dimension will become mandatory against that specific account for all accounting transactions')}}
|
||||
</p>
|
||||
</td></tr>
|
||||
|
||||
@@ -188,7 +188,7 @@ def get_closing_balance_as_per_statement(bank_account: str, date: str):
|
||||
return {"balance": 0, "date": None}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_closing_balance_as_per_statement(bank_account: str, date: str | datetime.date, balance: float):
|
||||
"""
|
||||
Set the closing balance as per statement for a bank account and date
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate
|
||||
|
||||
|
||||
class BankGuarantee(Document):
|
||||
@@ -46,6 +47,9 @@ class BankGuarantee(Document):
|
||||
if not (self.customer or self.supplier):
|
||||
frappe.throw(_("Select the customer or supplier."))
|
||||
|
||||
if self.end_date and getdate(self.end_date) < getdate(self.start_date):
|
||||
frappe.throw(_("End Date cannot be before Start Date."))
|
||||
|
||||
def on_submit(self):
|
||||
if not self.bank_guarantee_number:
|
||||
frappe.throw(_("Enter the Bank Guarantee Number before submitting."))
|
||||
|
||||
@@ -67,10 +67,6 @@ class TestBankGuarantee(ERPNextTestSuite):
|
||||
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.
|
||||
def test_end_date_before_start_date_is_rejected(self):
|
||||
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))
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
@@ -116,7 +116,7 @@ def get_account_balance(bank_account: str, till_date: str | date, company: str):
|
||||
return flt(balance_as_per_system) - flt(total_debit) + flt(total_credit) + amounts_not_reflected_in_system
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_bank_transaction(
|
||||
bank_transaction_name: str, reference_number: str, party_type: str | None = None, party: str | None = None
|
||||
):
|
||||
@@ -146,7 +146,7 @@ def update_bank_transaction(
|
||||
)[0]
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_journal_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -305,7 +305,7 @@ def create_journal_entry_bts(
|
||||
return reconcile_vouchers(bank_transaction_name, vouchers, is_new_voucher=True)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_payment_entry_bts(
|
||||
bank_transaction_name: str,
|
||||
reference_number: str | None = None,
|
||||
@@ -500,7 +500,7 @@ def create_bulk_internal_transfer(bank_transaction_names: list[str | int], bank_
|
||||
return output
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_internal_transfer(
|
||||
bank_transaction_name: str | int,
|
||||
posting_date: str | date,
|
||||
@@ -1057,7 +1057,7 @@ def get_auto_reconcile_message(partially_reconciled, reconciled):
|
||||
return alert_message, indicator
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def reconcile_vouchers(bank_transaction_name: str | int, vouchers: str | list, is_new_voucher: bool = False):
|
||||
# updated clear date of all the vouchers based on the bank transaction
|
||||
vouchers = frappe.parse_json(vouchers)
|
||||
|
||||
@@ -8,7 +8,6 @@ from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool import (
|
||||
auto_reconcile_vouchers,
|
||||
get_auto_reconcile_message,
|
||||
get_bank_transactions,
|
||||
)
|
||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||
@@ -98,40 +97,3 @@ class TestBankReconciliationTool(ERPNextTestSuite, AccountsTestMixin):
|
||||
# assert API output post reconciliation
|
||||
transactions = get_bank_transactions(self.bank_account, from_date, to_date)
|
||||
self.assertEqual(len(transactions), 0)
|
||||
|
||||
def make_bank_transaction(self, date, deposit=100):
|
||||
return (
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Bank Transaction",
|
||||
"date": date,
|
||||
"deposit": deposit,
|
||||
"bank_account": self.bank_account,
|
||||
"currency": "INR",
|
||||
}
|
||||
)
|
||||
.save()
|
||||
.submit()
|
||||
)
|
||||
|
||||
def test_get_bank_transactions_excludes_dates_after_to_date(self):
|
||||
self.make_bank_transaction(date=today())
|
||||
names = [t.name for t in get_bank_transactions(self.bank_account, to_date=add_days(today(), -1))]
|
||||
self.assertEqual(names, [])
|
||||
|
||||
def test_auto_reconcile_message_for_no_matches(self):
|
||||
message, indicator = get_auto_reconcile_message([], [])
|
||||
self.assertEqual(indicator, "blue")
|
||||
self.assertIn("No matches", message)
|
||||
|
||||
def test_auto_reconcile_message_counts_and_pluralizes(self):
|
||||
# reconciled count is reported and the indicator turns green
|
||||
message, indicator = get_auto_reconcile_message([], ["t1", "t2"])
|
||||
self.assertEqual(indicator, "green")
|
||||
self.assertIn("2 Transaction(s) Reconciled", message)
|
||||
|
||||
# partially-reconciled label is singular for one, plural for many
|
||||
singular, _ = get_auto_reconcile_message(["p1"], [])
|
||||
self.assertIn("1 Transaction Partially Reconciled", singular)
|
||||
plural, _ = get_auto_reconcile_message(["p1", "p2"], [])
|
||||
self.assertIn("2 Transactions Partially Reconciled", plural)
|
||||
|
||||
@@ -397,7 +397,7 @@ def unreconcile_transaction(transaction_name: str | int):
|
||||
frappe.get_doc(voucher["doctype"], voucher["name"]).cancel()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def unreconcile_transaction_entry(bank_transaction_id: str | int, voucher_type: str, voucher_id: str | int):
|
||||
"""
|
||||
Removes a single payment entry from a bank transaction - for example only undoing one voucher instead of undoing the entire transaction
|
||||
|
||||
@@ -34,7 +34,7 @@ def upload_bank_statement():
|
||||
return {"columns": columns, "data": data}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_bank_entries(columns: str, data: str | list, bank_account: str):
|
||||
header_map = get_header_mapping(columns, bank_account)
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ class BisectAccountingStatements(Document):
|
||||
self.get_report_summary()
|
||||
self.update_node()
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_left(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -198,7 +198,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Left"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def bisect_right(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
@@ -212,7 +212,7 @@ class BisectAccountingStatements(Document):
|
||||
else:
|
||||
frappe.msgprint(_("No more children on Right"))
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def move_up(self):
|
||||
if self.current_node is not None:
|
||||
cur_node = frappe.get_doc("Bisect Nodes", self.current_node)
|
||||
|
||||
@@ -1,47 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import datetime
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBisectAccountingStatements(ERPNextTestSuite):
|
||||
"""The tool bisects a date range into a tree of Bisect Nodes down to single days.
|
||||
These cover the date validation and that the bisection cleanly partitions the range."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
frappe.db.delete("Bisect Nodes")
|
||||
|
||||
def _leaf_days(self):
|
||||
leaves = frappe.get_all(
|
||||
"Bisect Nodes",
|
||||
filters={"left_child": ["is", "not set"]},
|
||||
fields=["period_from_date", "period_to_date"],
|
||||
)
|
||||
# every leaf spans a single day
|
||||
for leaf in leaves:
|
||||
self.assertEqual(getdate(leaf.period_from_date), getdate(leaf.period_to_date))
|
||||
return sorted(getdate(leaf.period_from_date) for leaf in leaves)
|
||||
|
||||
def test_validate_dates_rejects_reversed_range(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.from_date = "2026-01-08"
|
||||
doc.to_date = "2026-01-01"
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_bfs_partitions_range_into_single_days(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.bfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
|
||||
# the 8-day span Jan 1..Jan 8 becomes exactly 8 contiguous single-day leaves
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
|
||||
def test_dfs_produces_the_same_partition_as_bfs(self):
|
||||
doc = frappe.new_doc("Bisect Accounting Statements")
|
||||
doc.dfs(datetime.datetime(2026, 1, 1), datetime.datetime(2026, 1, 8))
|
||||
self.assertEqual(self._leaf_days(), [getdate(f"2026-01-0{n}") for n in range(1, 9)])
|
||||
pass
|
||||
|
||||
@@ -878,7 +878,7 @@ def get_fiscal_year_date_range(from_fiscal_year, to_fiscal_year):
|
||||
return from_year.year_start_date, to_year.year_end_date
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def revise_budget(budget_name: str):
|
||||
old_budget = frappe.get_doc("Budget", budget_name)
|
||||
|
||||
|
||||
@@ -1,67 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
DATE = "2026-06-15"
|
||||
|
||||
|
||||
class TestCashierClosing(ERPNextTestSuite):
|
||||
"""Cashier Closing reconciles a shift: it pulls outstanding invoices in a
|
||||
date/time window and rolls payments, expense, custody and returns into net_amount."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_invoice_in_window(self, rate=100):
|
||||
si = create_sales_invoice(rate=rate, qty=1, posting_date=DATE, do_not_submit=True)
|
||||
si.posting_time = "10:30:00"
|
||||
si.submit()
|
||||
si.reload() # read outstanding_amount as persisted after submit
|
||||
return si
|
||||
|
||||
def make_closing(self, user="Administrator", payments=None, **args):
|
||||
doc = frappe.new_doc("Cashier Closing")
|
||||
doc.user = user
|
||||
doc.date = args.get("date", DATE)
|
||||
doc.from_time = args.get("from_time", "09:00:00")
|
||||
doc.time = args.get("time", "18:00:00")
|
||||
for amount in payments or []:
|
||||
doc.append("payments", {"mode_of_payment": "Cash", "amount": amount})
|
||||
doc.expense = args.get("expense", 0)
|
||||
doc.custody = args.get("custody", 0)
|
||||
doc.returns = args.get("returns", 0)
|
||||
return doc
|
||||
|
||||
def test_from_time_must_be_before_to_time(self):
|
||||
doc = self.make_closing(from_time="18:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_equal_from_and_to_time_is_rejected(self):
|
||||
# validate_time uses >=, so a zero-length window is also blocked
|
||||
doc = self.make_closing(from_time="09:00:00", time="09:00:00")
|
||||
self.assertRaises(frappe.ValidationError, doc.save)
|
||||
|
||||
def test_net_amount_rolls_up_outstanding_and_adjustments(self):
|
||||
si = self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(payments=[500], expense=50, custody=30, returns=20)
|
||||
doc.save()
|
||||
|
||||
# the in-window invoice is picked up as outstanding
|
||||
self.assertEqual(doc.outstanding_amount, si.outstanding_amount)
|
||||
# net = payments + outstanding + expense - custody + returns
|
||||
self.assertEqual(doc.net_amount, 500 + si.outstanding_amount + 50 - 30 + 20)
|
||||
|
||||
def test_outstanding_is_scoped_to_the_invoice_owner(self):
|
||||
# The invoice is created by Administrator; a closing for a different user does
|
||||
# not see it. NOTE: get_outstanding keys on Sales Invoice.owner (the document
|
||||
# creator) rather than an explicit cashier/POS-user field, which is fragile when
|
||||
# invoices are created by a shared or system user.
|
||||
self.make_invoice_in_window(rate=100)
|
||||
doc = self.make_closing(user="Guest", payments=[500])
|
||||
doc.save()
|
||||
self.assertEqual(doc.outstanding_amount, 0)
|
||||
self.assertEqual(doc.net_amount, 500)
|
||||
pass
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.chart_of_accounts_importer.chart_of_accounts_importer import (
|
||||
build_forest,
|
||||
validate_columns,
|
||||
validate_missing_roots,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
# columns: account_name, parent_account, account_number, parent_account_number,
|
||||
# is_group, account_type, root_type, account_currency
|
||||
ROOT = ["Assets", "Assets", "", "", 1, "", "Asset", "INR"]
|
||||
CHILD = ["Cash", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
|
||||
|
||||
class TestChartofAccountsImporter(ERPNextTestSuite):
|
||||
"""The importer parses an uploaded CoA into a nested tree and validates its
|
||||
shape. These cover the parsing/validation helpers without a file upload."""
|
||||
|
||||
def test_validate_columns_rejects_blank_file(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [])
|
||||
|
||||
def test_validate_columns_requires_eight_columns(self):
|
||||
self.assertRaises(frappe.ValidationError, validate_columns, [["a", "b", "c"]])
|
||||
# the standard template width passes
|
||||
validate_columns([ROOT])
|
||||
|
||||
def test_build_forest_nests_child_under_parent(self):
|
||||
forest = build_forest([ROOT, CHILD])
|
||||
self.assertIn("Assets", forest)
|
||||
self.assertIn("Cash", forest["Assets"])
|
||||
|
||||
def test_build_forest_rejects_unknown_parent(self):
|
||||
orphan = ["Cash", "Missing Parent", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [orphan])
|
||||
|
||||
def test_build_forest_requires_account_name(self):
|
||||
nameless = ["", "Assets", "", "", 0, "Cash", "Asset", "INR"]
|
||||
self.assertRaises(frappe.ValidationError, build_forest, [ROOT, nameless])
|
||||
|
||||
def test_validate_missing_roots_requires_all_root_types(self):
|
||||
present = ("Asset", "Liability", "Expense", "Income") # Equity missing
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
validate_missing_roots,
|
||||
[{"root_type": rt} for rt in present],
|
||||
)
|
||||
# all five root types present -> no error
|
||||
validate_missing_roots(
|
||||
[{"root_type": rt} for rt in ("Asset", "Liability", "Expense", "Income", "Equity")]
|
||||
)
|
||||
pass
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChequePrintTemplate(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_or_update_cheque_print_format(template_name: str):
|
||||
frappe.only_for("System Manager")
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from erpnext.accounts.doctype.sales_invoice.mapper import (
|
||||
create_dunning as create_dunning_from_sales_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import (
|
||||
create_sales_invoice,
|
||||
create_sales_invoice_against_cost_center,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
@@ -153,37 +152,6 @@ class TestDunning(ERPNextTestSuite):
|
||||
dunning.reload()
|
||||
self.assertEqual(dunning.status, "Unresolved")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": 1}
|
||||
)
|
||||
def test_dunning_outstanding_uses_transaction_currency(self):
|
||||
"""
|
||||
Regression for #56006: dunning outstanding must be in the invoice transaction
|
||||
currency, not in the party account currency.
|
||||
|
||||
A USD invoice posted against an INR receivable account stores
|
||||
outstanding_amount in INR (party account currency). The overdue payment
|
||||
row on the resulting Dunning must carry the USD amount, not the INR amount.
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
posting_date=add_days(today(), -10),
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
rate=100,
|
||||
debit_to="Debtors - _TC",
|
||||
)
|
||||
|
||||
# Sanity-check the invoice state before creating the dunning
|
||||
self.assertEqual(si.currency, "USD")
|
||||
self.assertEqual(si.outstanding_amount, 5000.0) # INR (party account currency)
|
||||
self.assertEqual(si.payment_schedule[0].outstanding, 100.0) # USD (transaction currency)
|
||||
|
||||
dunning = create_dunning_from_sales_invoice(si.name)
|
||||
|
||||
self.assertEqual(len(dunning.overdue_payments), 1)
|
||||
# Must reflect 100 USD, not 5000 INR mislabelled as USD
|
||||
self.assertEqual(dunning.overdue_payments[0].outstanding, 100.0)
|
||||
|
||||
def test_dunning_not_affected_by_standalone_credit_note(self):
|
||||
"""
|
||||
Test that dunning is NOT resolved when a credit note has update_outstanding_for_self checked.
|
||||
|
||||
@@ -298,65 +298,3 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
|
||||
class TestExchangeRateRevaluationValidation(ERPNextTestSuite):
|
||||
"""Validation and gain/loss calculation paths, exercised on the document directly
|
||||
so they don't need the multi-currency GL setup the integration tests above build."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
self.company = "_Test Company"
|
||||
|
||||
def _revaluation_with_rows(self, rows, rounding_loss_allowance=0.05):
|
||||
doc = frappe.new_doc("Exchange Rate Revaluation")
|
||||
doc.company = self.company
|
||||
doc.posting_date = today()
|
||||
doc.rounding_loss_allowance = rounding_loss_allowance
|
||||
for row in rows:
|
||||
doc.append("accounts", row)
|
||||
return doc
|
||||
|
||||
def test_rounding_loss_allowance_must_be_between_0_and_1(self):
|
||||
for bad in (-0.1, 1, 1.5):
|
||||
doc = self._revaluation_with_rows([], rounding_loss_allowance=bad)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
# values inside [0, 1) are accepted, at the lower bound and mid-range
|
||||
for good in (0.0, 0.5):
|
||||
self._revaluation_with_rows([], rounding_loss_allowance=good).validate()
|
||||
|
||||
def test_gain_loss_computed_and_split_by_zero_balance(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[
|
||||
# open (unbooked) row: base balance moved 1000 -> 1100, a 100 gain
|
||||
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
|
||||
# already-settled (zero_balance) row carries a booked loss of 40
|
||||
{"zero_balance": 1, "gain_loss": -40},
|
||||
]
|
||||
)
|
||||
doc.validate()
|
||||
|
||||
# gain_loss is derived only for open rows; the zero-balance row keeps its value
|
||||
self.assertEqual(doc.accounts[0].gain_loss, 100)
|
||||
self.assertEqual(doc.gain_loss_unbooked, 100)
|
||||
self.assertEqual(doc.gain_loss_booked, -40)
|
||||
self.assertEqual(doc.total_gain_loss, 60)
|
||||
|
||||
def test_before_submit_drops_rows_without_gain_loss(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[
|
||||
{"zero_balance": 0, "balance_in_base_currency": 1000, "new_balance_in_base_currency": 1100},
|
||||
{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500},
|
||||
]
|
||||
)
|
||||
doc.validate() # second row nets to a 0 gain_loss
|
||||
doc.remove_accounts_without_gain_loss()
|
||||
self.assertEqual(len(doc.accounts), 1)
|
||||
self.assertEqual(doc.accounts[0].gain_loss, 100)
|
||||
|
||||
def test_before_submit_requires_at_least_one_gain_loss_row(self):
|
||||
doc = self._revaluation_with_rows(
|
||||
[{"zero_balance": 0, "balance_in_base_currency": 500, "new_balance_in_base_currency": 500}]
|
||||
)
|
||||
doc.validate()
|
||||
self.assertRaises(frappe.ValidationError, doc.remove_accounts_without_gain_loss)
|
||||
|
||||
@@ -1,62 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
TAX_ACCOUNT = "_Test Account VAT - _TC"
|
||||
RECEIVABLE_ACCOUNT = "Debtors - _TC"
|
||||
|
||||
|
||||
class TestItemTaxTemplate(ERPNextTestSuite):
|
||||
"""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)
|
||||
pass
|
||||
|
||||
@@ -45,20 +45,6 @@ class JournalEntryTemplate(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_party()
|
||||
self.validate_account_company()
|
||||
|
||||
def validate_account_company(self):
|
||||
"""Each row's account must belong to the template's company."""
|
||||
for account in self.accounts:
|
||||
if (
|
||||
account.account
|
||||
and frappe.get_cached_value("Account", account.account, "company") != self.company
|
||||
):
|
||||
frappe.throw(
|
||||
_("Row {0}: Account {1} does not belong to company {2}").format(
|
||||
account.idx, account.account, self.company
|
||||
)
|
||||
)
|
||||
|
||||
def validate_party(self):
|
||||
"""
|
||||
|
||||
@@ -1,45 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestJournalEntryTemplate(ERPNextTestSuite):
|
||||
"""Journal Entry Template's only real rule is validate_party: party_type is
|
||||
allowed only on Receivable/Payable accounts, and a party needs a party_type."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_template(self, rows, company=COMPANY):
|
||||
doc = frappe.new_doc("Journal Entry Template")
|
||||
doc.template_title = f"_Test JET {frappe.generate_hash(length=6)}"
|
||||
doc.company = company
|
||||
doc.voucher_type = "Journal Entry"
|
||||
doc.naming_series = frappe.get_meta("Journal Entry").get_field("naming_series").options.split("\n")[0]
|
||||
for row in rows:
|
||||
doc.append("accounts", row)
|
||||
return doc
|
||||
|
||||
def test_party_type_only_on_receivable_or_payable_account(self):
|
||||
# Cash is neither Receivable nor Payable, so a party_type here is invalid
|
||||
doc = self.make_template([{"account": "Cash - _TC", "party_type": "Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_party_requires_party_type(self):
|
||||
doc = self.make_template([{"account": "Debtors - _TC", "party": "_Test Customer"}])
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_account_from_other_company_is_rejected(self):
|
||||
other_receivable = frappe.db.get_value(
|
||||
"Account", {"company": "_Test Company 1", "account_type": "Receivable", "is_group": 0}, "name"
|
||||
)
|
||||
self.assertTrue(other_receivable, "need a receivable account in _Test Company 1")
|
||||
doc = self.make_template(
|
||||
[{"account": other_receivable, "party_type": "Customer", "party": "_Test Customer"}]
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
pass
|
||||
|
||||
@@ -8,7 +8,7 @@ frappe.ui.form.on("Loyalty Program", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
|
||||
@@ -5,59 +5,9 @@ import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestModeofPayment(ERPNextTestSuite):
|
||||
"""Mode of Payment validates its per-company default accounts (account company
|
||||
must match the row, no company twice) and blocks disabling while a POS Profile
|
||||
still references it."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_mop(self, accounts=None, enabled=1):
|
||||
doc = frappe.new_doc("Mode of Payment")
|
||||
doc.mode_of_payment = f"_Test MoP {frappe.generate_hash(length=6)}"
|
||||
doc.type = "General"
|
||||
doc.enabled = enabled
|
||||
for company, account in accounts or []:
|
||||
doc.append("accounts", {"company": company, "default_account": account})
|
||||
return doc
|
||||
|
||||
def test_valid_mode_of_payment_saves(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")])
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_account_of_wrong_company_throws(self):
|
||||
other_account = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other_account, "need a non-group account in _Test Company 1")
|
||||
doc = self.make_mop(accounts=[(COMPANY, other_account)])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_repeating_company_throws(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC"), (COMPANY, "Debtors - _TC")])
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_disabling_mode_referenced_by_pos_profile_is_not_blocked(self):
|
||||
# SUSPECTED BUG: validate_pos_mode_of_payment queries "Sales Invoice Payment"
|
||||
# rows with parenttype "POS Profile", but a POS Profile's payments are stored
|
||||
# as "POS Payment Method" rows. The filter never matches, so the guard is dead
|
||||
# and a mode still referenced by a POS Profile disables without complaint.
|
||||
# Locking the current (wrong) behaviour so a fix to the guard trips this test.
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
|
||||
make_pos_profile() # its payments row references the "Cash" mode of payment
|
||||
cash = frappe.get_doc("Mode of Payment", "Cash")
|
||||
cash.enabled = 0
|
||||
cash.save()
|
||||
self.assertEqual(frappe.db.get_value("Mode of Payment", "Cash", "enabled"), 0)
|
||||
|
||||
def test_disabling_unreferenced_mode_succeeds(self):
|
||||
doc = self.make_mop(accounts=[(COMPANY, "Cash - _TC")], enabled=0)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.enabled, 0)
|
||||
pass
|
||||
|
||||
|
||||
def set_default_account_for_mode_of_payment(mode_of_payment, company, account):
|
||||
|
||||
@@ -1,67 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext.accounts.doctype.monthly_distribution.monthly_distribution import (
|
||||
get_percentage,
|
||||
get_periodwise_distribution_data,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestMonthlyDistribution(ERPNextTestSuite):
|
||||
"""Monthly Distribution spreads an amount across months. validate() enforces a
|
||||
100% total; get_percentage() sums the months that fall inside a period window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_distribution(self, allocations):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = f"_Test MD {frappe.generate_hash(length=6)}"
|
||||
for month, pct in allocations:
|
||||
doc.append("percentages", {"month": month, "percentage_allocation": pct})
|
||||
return doc
|
||||
|
||||
def test_get_months_populates_twelve_even_rows(self):
|
||||
doc = frappe.new_doc("Monthly Distribution")
|
||||
doc.distribution_id = "_Test MD Even"
|
||||
doc.get_months()
|
||||
|
||||
self.assertEqual(len(doc.percentages), 12)
|
||||
self.assertEqual(doc.percentages[0].month, "January")
|
||||
self.assertEqual(doc.percentages[-1].month, "December")
|
||||
self.assertEqual([d.idx for d in doc.percentages], list(range(1, 13)))
|
||||
for d in doc.percentages:
|
||||
self.assertAlmostEqual(d.percentage_allocation, 100.0 / 12, places=4)
|
||||
# the auto-populated rows round to exactly 100 and pass validation
|
||||
doc.validate()
|
||||
|
||||
def test_validate_rejects_total_other_than_100(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30)]) # sums to 80
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_get_percentage_sums_period_window(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert() # total is 100, so validate passes
|
||||
|
||||
# a quarter starting in January covers Jan+Feb+Mar
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-01-01"), 3), 100)
|
||||
# a single month picks up only that month
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-02-01"), 1), 30)
|
||||
# months with no row simply contribute 0 (there is no guard that all 12 exist)
|
||||
self.assertEqual(get_percentage(doc, getdate("2026-04-01"), 1), 0)
|
||||
|
||||
def test_periodwise_distribution_maps_each_period(self):
|
||||
doc = self.make_distribution([("January", 50), ("February", 30), ("March", 20)])
|
||||
doc.insert()
|
||||
|
||||
period_list = [
|
||||
frappe._dict(key="q1", from_date=getdate("2026-01-01")),
|
||||
frappe._dict(key="q2", from_date=getdate("2026-04-01")),
|
||||
]
|
||||
data = get_periodwise_distribution_data(doc.name, period_list, "Quarterly")
|
||||
self.assertEqual(data["q1"], 100) # Jan+Feb+Mar
|
||||
self.assertEqual(data["q2"], 0) # Apr+May+Jun carry no allocation
|
||||
pass
|
||||
|
||||
@@ -1,67 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.party_link.party_link import create_party_link
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
CUSTOMER = "_Test Customer"
|
||||
SUPPLIER = "_Test Supplier"
|
||||
SUPPLIER_2 = "_Test Supplier 1"
|
||||
|
||||
|
||||
class TestPartyLink(ERPNextTestSuite):
|
||||
"""Party Link ties a Customer and a Supplier together as one underlying party.
|
||||
validate() constrains the primary role and blocks duplicate links."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def test_create_party_link_with_customer_primary(self):
|
||||
link = create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
self.assertEqual(link.primary_role, "Customer")
|
||||
self.assertEqual(link.secondary_role, "Supplier")
|
||||
self.assertEqual(link.primary_party, CUSTOMER)
|
||||
self.assertEqual(link.secondary_party, SUPPLIER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_create_party_link_with_supplier_primary(self):
|
||||
link = create_party_link("Supplier", SUPPLIER, CUSTOMER)
|
||||
self.assertEqual(link.primary_role, "Supplier")
|
||||
self.assertEqual(link.secondary_role, "Customer")
|
||||
self.assertEqual(link.primary_party, SUPPLIER)
|
||||
self.assertEqual(link.secondary_party, CUSTOMER)
|
||||
self.assertTrue(frappe.db.exists("Party Link", link.name))
|
||||
|
||||
def test_primary_role_must_be_customer_or_supplier(self):
|
||||
doc = frappe.new_doc("Party Link")
|
||||
doc.primary_role = "Employee"
|
||||
doc.primary_party = CUSTOMER
|
||||
doc.secondary_role = "Supplier"
|
||||
doc.secondary_party = SUPPLIER
|
||||
# validate() alone isolates the role rule from the dynamic-link checks
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
def test_duplicate_link_throws(self):
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
dup = frappe.new_doc("Party Link")
|
||||
dup.primary_role = "Customer"
|
||||
dup.primary_party = CUSTOMER
|
||||
dup.secondary_role = "Supplier"
|
||||
dup.secondary_party = SUPPLIER
|
||||
self.assertRaises(frappe.ValidationError, dup.insert)
|
||||
|
||||
def test_party_can_wrongly_be_primary_in_two_links(self):
|
||||
# SUSPECTED BUG: the uniqueness checks are asymmetric - a party already a
|
||||
# *primary* in another link isn't blocked, so one customer can be linked to two
|
||||
# different suppliers, breaking the 1:1 mapping. Locking the current (wrong)
|
||||
# behaviour so a fix that blocks primary reuse trips this test.
|
||||
create_party_link("Customer", CUSTOMER, SUPPLIER)
|
||||
link2 = frappe.new_doc("Party Link")
|
||||
link2.primary_role = "Customer"
|
||||
link2.primary_party = CUSTOMER
|
||||
link2.secondary_role = "Supplier"
|
||||
link2.secondary_party = SUPPLIER_2
|
||||
link2.insert()
|
||||
self.assertTrue(frappe.db.exists("Party Link", link2.name))
|
||||
pass
|
||||
|
||||
@@ -414,17 +414,21 @@ frappe.ui.form.on("Payment Entry", {
|
||||
|
||||
show_general_ledger: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.posting_date,
|
||||
to_date: moment(frm.doc.modified).format("YYYY-MM-DD"),
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -246,62 +246,6 @@ class TestPaymentEntry(ERPNextTestSuite):
|
||||
outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount"))
|
||||
self.assertEqual(outstanding_amount, 0)
|
||||
|
||||
def test_pay_multiple_purchase_invoices_in_one_entry(self):
|
||||
pi1 = make_purchase_invoice() # outstanding 250
|
||||
pi2 = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi1.name, bank_account="_Test Cash - _TC")
|
||||
pe.append(
|
||||
"references",
|
||||
{
|
||||
"reference_doctype": "Purchase Invoice",
|
||||
"reference_name": pi2.name,
|
||||
"total_amount": pi2.grand_total,
|
||||
"outstanding_amount": pi2.outstanding_amount,
|
||||
"allocated_amount": pi2.outstanding_amount,
|
||||
},
|
||||
)
|
||||
pe.paid_amount = pe.received_amount = (
|
||||
pe.references[0].allocated_amount + pe.references[1].allocated_amount
|
||||
)
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.total_allocated_amount, 500)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi1.name, "outstanding_amount"), 0)
|
||||
self.assertEqual(frappe.db.get_value("Purchase Invoice", pi2.name, "outstanding_amount"), 0)
|
||||
|
||||
def test_unallocated_amount_on_overpaid_purchase_payment(self):
|
||||
pi = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
pe.paid_amount = pe.references[0].allocated_amount + 200 # overpay -> 200 advance
|
||||
pe.received_amount = pe.paid_amount
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
|
||||
self.assertEqual(pe.docstatus, 1)
|
||||
self.assertEqual(pe.unallocated_amount, 200)
|
||||
|
||||
# end-to-end: submitting posts a balanced GL for the full paid amount (250
|
||||
# settling the invoice + 200 advance)
|
||||
gl_entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={"voucher_no": pe.name, "is_cancelled": 0},
|
||||
fields=["debit", "credit"],
|
||||
)
|
||||
self.assertTrue(gl_entries, "Submitted payment produced no GL entries")
|
||||
self.assertEqual(flt(sum(e.debit for e in gl_entries)), flt(sum(e.credit for e in gl_entries)))
|
||||
self.assertEqual(flt(sum(e.debit for e in gl_entries)), 450)
|
||||
|
||||
def test_overallocation_against_purchase_invoice_throws(self):
|
||||
pi = make_purchase_invoice() # outstanding 250
|
||||
|
||||
pe = get_payment_entry("Purchase Invoice", pi.name, bank_account="_Test Cash - _TC")
|
||||
pe.references[0].allocated_amount += 100 # 350 > 250 outstanding
|
||||
pe.paid_amount = pe.received_amount = pe.references[0].allocated_amount
|
||||
self.assertRaises(frappe.ValidationError, pe.insert)
|
||||
|
||||
def test_payment_against_sales_invoice_to_check_status(self):
|
||||
si = create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
@@ -2373,65 +2317,3 @@ def create_customer(name="_Test Customer 2 USD", currency="USD"):
|
||||
customer.save()
|
||||
customer = customer.name
|
||||
return customer
|
||||
|
||||
|
||||
class TestPaymentEntryValidation(ERPNextTestSuite):
|
||||
"""Field-level validations invoked on the document directly, covering branches the
|
||||
integration suite above doesn't reach (no GL / reconciliation setup needed)."""
|
||||
|
||||
def make_pe(self, **fields):
|
||||
doc = frappe.new_doc("Payment Entry")
|
||||
doc.update(fields)
|
||||
return doc
|
||||
|
||||
def test_payment_type_must_be_a_known_value(self):
|
||||
self.assertRaises(frappe.ValidationError, self.make_pe(payment_type="Foo").validate_payment_type)
|
||||
self.make_pe(payment_type="Receive").validate_payment_type() # valid value passes
|
||||
|
||||
def test_nonexistent_party_is_rejected(self):
|
||||
doc = self.make_pe(party_type="Customer", party="__No Such Customer__")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_party_details)
|
||||
|
||||
def test_amount_and_exchange_rate_fields_are_mandatory(self):
|
||||
# every field but target_exchange_rate is set, so that missing one raises
|
||||
doc = self.make_pe(
|
||||
paid_amount=100, received_amount=100, source_exchange_rate=1, target_exchange_rate=0
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_mandatory)
|
||||
|
||||
def test_received_amount_cannot_exceed_paid_in_same_currency(self):
|
||||
doc = self.make_pe(
|
||||
paid_from_account_currency="INR",
|
||||
paid_to_account_currency="INR",
|
||||
paid_amount=100,
|
||||
received_amount=150,
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_received_amount)
|
||||
# received <= paid is fine
|
||||
doc.received_amount = 50
|
||||
doc.validate_received_amount()
|
||||
|
||||
def test_duplicate_reference_rows_are_rejected(self):
|
||||
doc = self.make_pe()
|
||||
for _ in range(2):
|
||||
doc.append(
|
||||
"references",
|
||||
{"reference_doctype": "Sales Invoice", "reference_name": "SI-X", "allocated_amount": 100},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_duplicate_entry)
|
||||
|
||||
def test_receive_from_customer_against_negative_outstanding_is_rejected(self):
|
||||
doc = self.make_pe(party_type="Customer", payment_type="Receive")
|
||||
doc.append(
|
||||
"references",
|
||||
{"reference_doctype": "Sales Invoice", "reference_name": "SI-Y", "allocated_amount": -100},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_payment_type_with_outstanding)
|
||||
|
||||
def test_bank_transaction_requires_a_reference_number(self):
|
||||
doc = self.make_pe(payment_type="Pay", paid_from="_Test Bank - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_transaction_reference)
|
||||
# supplying the reference details clears the requirement
|
||||
doc.reference_no = "TXN-1"
|
||||
doc.reference_date = "2026-06-15"
|
||||
doc.validate_transaction_reference()
|
||||
|
||||
@@ -718,7 +718,7 @@ class PaymentRequest(Document):
|
||||
row_number += TO_SKIP_NEW_ROW
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_payment_request(**args):
|
||||
"""Make payment request"""
|
||||
|
||||
|
||||
@@ -41,17 +41,21 @@ frappe.ui.form.on("Period Closing Voucher", {
|
||||
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus > 0) {
|
||||
frm.add_custom_button(__("Ledger"), function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
});
|
||||
frm.add_custom_button(
|
||||
__("Ledger"),
|
||||
function () {
|
||||
frappe.route_options = {
|
||||
voucher_no: frm.doc.name,
|
||||
from_date: frm.doc.period_start_date,
|
||||
to_date: frm.doc.period_end_date,
|
||||
company: frm.doc.company,
|
||||
categorize_by: "",
|
||||
show_cancelled_entries: frm.doc.docstatus === 2,
|
||||
};
|
||||
frappe.set_route("query-report", "General Ledger");
|
||||
},
|
||||
"fa fa-table"
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# Regression test for https://github.com/frappe/erpnext/issues/56501
|
||||
# AttributeError: 'POSInvoice' object has no attribute 'is_created_using_pos'
|
||||
# when calling reset_mode_of_payments on a draft POS Invoice.
|
||||
|
||||
from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import (
|
||||
POSInvoiceTestMixin,
|
||||
create_pos_invoice,
|
||||
)
|
||||
from erpnext.accounts.doctype.pos_opening_entry.test_pos_opening_entry import create_opening_entry
|
||||
|
||||
|
||||
class TestPOSInvoiceResetModeOfPayments(POSInvoiceTestMixin):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
create_opening_entry(self.pos_profile, self.test_user.name)
|
||||
|
||||
def test_reset_mode_of_payments_does_not_raise_attribute_error(self):
|
||||
"""Calling reset_mode_of_payments on a draft POS Invoice must not raise
|
||||
AttributeError for the missing is_created_using_pos attribute.
|
||||
|
||||
update_multi_mode_option accesses doc.is_created_using_pos, which is a
|
||||
field on SalesInvoice but does not exist on POSInvoice, causing the error
|
||||
reported in #56501 when a user tries to edit a saved draft order.
|
||||
"""
|
||||
inv = create_pos_invoice(do_not_submit=True)
|
||||
|
||||
# This call must not raise AttributeError on the missing field.
|
||||
inv.reset_mode_of_payments()
|
||||
|
||||
# Payments should have been repopulated from the POS profile.
|
||||
self.assertTrue(len(inv.payments) > 0, "Payments should be populated after reset")
|
||||
@@ -40,7 +40,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
var help_content = `<table class="table table-bordered" style="background-color: var(--scrollbar-track-color);">
|
||||
<tr><td>
|
||||
<h4>
|
||||
<svg class="icon icon-sm"><use href="#icon-info"></use></svg>
|
||||
<i class="fa fa-hand-right"></i>
|
||||
${__("Notes")}
|
||||
</h4>
|
||||
<ul>
|
||||
@@ -63,7 +63,7 @@ frappe.ui.form.on("Pricing Rule", {
|
||||
</ul>
|
||||
</td></tr>
|
||||
<tr><td>
|
||||
<h4><svg class="icon icon-sm"><use href="#icon-circle-question-mark"></use></svg>
|
||||
<h4><i class="fa fa-question-sign"></i>
|
||||
${__("How Pricing Rule is applied?")}
|
||||
</h4>
|
||||
<ol>
|
||||
|
||||
@@ -106,8 +106,6 @@ def get_pr_instance(doc: str):
|
||||
"party",
|
||||
"receivable_payable_account",
|
||||
"default_advance_account",
|
||||
"bank_cash_account",
|
||||
"cost_center",
|
||||
"from_invoice_date",
|
||||
"to_invoice_date",
|
||||
"from_payment_date",
|
||||
|
||||
@@ -1,73 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
|
||||
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||
get_pr_instance,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestProcessPaymentReconciliation(ERPNextTestSuite):
|
||||
"""Process Payment Reconciliation validates its accounts against the company,
|
||||
moves to Queued on submit, and hands its filters to a Payment Reconciliation run."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_ppr(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Process Payment Reconciliation")
|
||||
doc.company = COMPANY
|
||||
doc.party_type = "Customer"
|
||||
doc.party = "_Test Customer"
|
||||
doc.receivable_payable_account = args.get("receivable_payable_account", "Debtors - _TC")
|
||||
doc.bank_cash_account = args.get("bank_cash_account")
|
||||
doc.from_invoice_date = args.get("from_invoice_date")
|
||||
doc.to_invoice_date = args.get("to_invoice_date")
|
||||
return doc
|
||||
|
||||
def other_company_account(self, **extra):
|
||||
filters = {"company": "_Test Company 1", "is_group": 0, **extra}
|
||||
account = frappe.db.get_value("Account", filters, "name")
|
||||
self.assertTrue(account, "need a matching account in _Test Company 1")
|
||||
return account
|
||||
|
||||
def test_receivable_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(receivable_payable_account=self.other_company_account(account_type="Receivable"))
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_bank_cash_account_must_belong_to_company(self):
|
||||
doc = self.make_ppr(bank_cash_account=self.other_company_account())
|
||||
self.assertRaises(frappe.ValidationError, doc.insert)
|
||||
|
||||
def test_submit_sets_status_to_queued(self):
|
||||
doc = self.make_ppr()
|
||||
doc.insert()
|
||||
doc.submit()
|
||||
self.assertEqual(doc.status, "Queued")
|
||||
|
||||
def test_get_pr_instance_copies_filters_and_caps_limits(self):
|
||||
doc = self.make_ppr(from_invoice_date="2026-01-01", to_invoice_date="2026-06-30")
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.company, COMPANY)
|
||||
self.assertEqual(pr.party, "_Test Customer")
|
||||
self.assertEqual(pr.receivable_payable_account, "Debtors - _TC")
|
||||
self.assertEqual(str(pr.from_invoice_date), "2026-01-01")
|
||||
# the tool run is capped so a single process can't fetch unbounded rows
|
||||
self.assertEqual(pr.invoice_limit, 1000)
|
||||
self.assertEqual(pr.payment_limit, 1000)
|
||||
|
||||
def test_get_pr_instance_copies_bank_cash_and_cost_center(self):
|
||||
doc = self.make_ppr(bank_cash_account="Cash - _TC")
|
||||
doc.cost_center = "_Test Cost Center - _TC"
|
||||
doc.insert()
|
||||
|
||||
pr = get_pr_instance(doc.name)
|
||||
self.assertEqual(pr.bank_cash_account, "Cash - _TC")
|
||||
self.assertEqual(pr.cost_center, "_Test Cost Center - _TC")
|
||||
pass
|
||||
|
||||
@@ -89,55 +89,50 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
def initialize_parallel_threads(docname: str):
|
||||
threads = 4
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(threads)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
x.name,
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=x.name,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.has_permission("Process Period Closing Voucher", "write", doc=docname, throw=True)
|
||||
initialize_parallel_threads(docname)
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if normal_balances := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(4)
|
||||
.for_update(skip_locked=True)
|
||||
.run(as_dict=True)
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout=timeout,
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@@ -255,11 +250,11 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
timeout = frappe.db.get_single_value("Accounts Settings", "pcv_job_timeout") or 3600
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
if to_process := (
|
||||
qb.from_(ppcvd)
|
||||
.select(ppcvd.name, ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||
.limit(1)
|
||||
@@ -269,15 +264,15 @@ def schedule_next_date(docname: str):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
to_process[0].name,
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
@@ -285,7 +280,6 @@ def schedule_next_date(docname: str):
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
row_name=to_process[0].name,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
@@ -450,11 +444,6 @@ def summarize_and_post_ledger_entries(docname):
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
# keep transaction on PPCV and PPCVD short
|
||||
# prevents concurrency errors - REPEATABLE READ
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
@@ -540,10 +529,10 @@ def build_dimension_wise_balance_dict(gl_entries):
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, row_name, date, report_type, parentfield):
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
@@ -591,20 +580,17 @@ def process_individual_date(docname: str, row_name, date, report_type, parentfie
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
# commit heavy computation before touching PPCV or PPCVD
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
|
||||
@@ -48,27 +48,18 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
ppcv.save()
|
||||
return ppcv
|
||||
|
||||
def set_processing_date_status(self, row_name, status):
|
||||
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"status",
|
||||
status,
|
||||
)
|
||||
|
||||
def get_row_name(self, ppcv_name, rpt_type, parentfield):
|
||||
return frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": ppcv_name, "report_type": rpt_type, "parentfield": parentfield},
|
||||
order_by="report_type, idx",
|
||||
pluck="name",
|
||||
limit=1,
|
||||
)[0]
|
||||
|
||||
def get_processing_date_closing_balance(self, row_name):
|
||||
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
|
||||
return frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
row_name,
|
||||
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
)
|
||||
|
||||
@@ -106,10 +97,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
parentfield = "normal_balances"
|
||||
rpt_type = "Profit and Loss"
|
||||
# status has to be set to 'Running' for logic to run
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_pl = {
|
||||
"account": "Sales - _TC",
|
||||
@@ -125,10 +117,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
# Balance sheet balance
|
||||
rpt_type = "Balance Sheet"
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 1)
|
||||
expected_bs = {
|
||||
"account": "Debtors - _TC",
|
||||
@@ -145,10 +138,11 @@ class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||
# Opening balance
|
||||
parentfield = "z_opening_balances"
|
||||
rpt_type = "Balance Sheet"
|
||||
row_name = self.get_row_name(ppcv.name, rpt_type, parentfield)
|
||||
self.set_processing_date_status(row_name, "Running")
|
||||
process_individual_date(ppcv.name, row_name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(self.get_processing_date_closing_balance(row_name))
|
||||
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||
bal = frappe.parse_json(
|
||||
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||
)
|
||||
self.assertEqual(len(bal), 2)
|
||||
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
|
||||
expected_opening_cash = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
@@ -24,10 +24,3 @@ class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def on_doctype_update():
|
||||
frappe.db.add_index(
|
||||
"Process Period Closing Voucher Detail",
|
||||
["parent", "status", "parentfield", "idx", "processing_date"],
|
||||
)
|
||||
|
||||
@@ -113,38 +113,3 @@ def create_process_soa(**args):
|
||||
process_soa.update(soa_dict)
|
||||
process_soa.save()
|
||||
return process_soa
|
||||
|
||||
|
||||
class TestProcessStatementOfAccountsValidation(ERPNextTestSuite):
|
||||
"""validate() fills in default subject/body/pdf templates and enforces the
|
||||
basic constraints. Exercised on the document directly (no email/PDF flow)."""
|
||||
|
||||
def make_soa(self, report="Accounts Receivable", with_customer=True, **overrides):
|
||||
doc = frappe.new_doc("Process Statement Of Accounts")
|
||||
doc.report = report
|
||||
doc.company = "_Test Company"
|
||||
if with_customer:
|
||||
doc.append("customers", {"customer": "_Test Customer"})
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_customers_are_required(self):
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(with_customer=False).validate)
|
||||
|
||||
def test_general_ledger_body_uses_a_date_range(self):
|
||||
doc = self.make_soa(report="General Ledger")
|
||||
doc.validate()
|
||||
self.assertIn("from {{ doc.from_date }} to {{ doc.to_date }}", doc.body)
|
||||
# subject and pdf name are also defaulted
|
||||
self.assertTrue(doc.subject)
|
||||
self.assertTrue(doc.pdf_name)
|
||||
|
||||
def test_receivable_body_uses_the_posting_date(self):
|
||||
doc = self.make_soa(report="Accounts Receivable")
|
||||
doc.validate()
|
||||
self.assertIn("until {{ doc.posting_date }}", doc.body)
|
||||
|
||||
def test_account_must_belong_to_company(self):
|
||||
other = frappe.db.get_value("Account", {"company": "_Test Company 1", "is_group": 0}, "name")
|
||||
self.assertTrue(other, "need an account in _Test Company 1")
|
||||
self.assertRaises(frappe.ValidationError, self.make_soa(account=other).validate)
|
||||
|
||||
@@ -1,56 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2023, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
# import frappe
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.process_subscription.process_subscription import (
|
||||
create_subscription_process,
|
||||
)
|
||||
from erpnext.accounts.doctype.subscription.test_subscription import (
|
||||
create_parties,
|
||||
create_subscription,
|
||||
make_plans,
|
||||
reset_settings,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcessSubscription(ERPNextTestSuite):
|
||||
"""Process Subscription is a batch driver: on submit it enqueues subscription.process_all
|
||||
for every non-cancelled Subscription (or just one when a subscription is named)."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
# mirror TestSubscription setup so subscriptions build against known settings
|
||||
make_plans()
|
||||
create_parties()
|
||||
reset_settings()
|
||||
frappe.db.set_value("Company", "_Test Company", "accounts_frozen_till_date", None)
|
||||
|
||||
def enqueued_subscriptions(self, subscription=None):
|
||||
"""Submit a Process Subscription while capturing what gets enqueued."""
|
||||
calls = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
with patch("frappe.enqueue", side_effect=capture):
|
||||
create_subscription_process(subscription=subscription, posting_date="2026-06-15")
|
||||
|
||||
# each enqueue is handed a batch (list) of subscription names
|
||||
return [name for call in calls for name in call.get("subscription", [])]
|
||||
|
||||
def test_named_subscription_is_the_only_one_enqueued(self):
|
||||
sub = create_subscription(start_date="2026-01-01")
|
||||
self.assertEqual(self.enqueued_subscriptions(subscription=sub.name), [sub.name])
|
||||
|
||||
def test_cancelled_subscriptions_are_skipped(self):
|
||||
active = create_subscription(start_date="2026-01-01")
|
||||
cancelled = create_subscription(start_date="2026-01-01")
|
||||
cancelled.cancel_subscription()
|
||||
|
||||
enqueued = self.enqueued_subscriptions()
|
||||
self.assertIn(active.name, enqueued)
|
||||
self.assertNotIn(cancelled.name, enqueued)
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cint, flt, get_link_to_form
|
||||
|
||||
import erpnext
|
||||
@@ -130,6 +131,7 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
from erpnext.accounts.doctype.purchase_invoice.purchase_invoice import (
|
||||
get_purchase_document_details,
|
||||
)
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
doc = self.doc
|
||||
tax_service = TaxService(doc)
|
||||
@@ -329,25 +331,33 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
self.make_provisional_gl_entry(gl_entries, item)
|
||||
|
||||
if not doc.is_internal_transfer():
|
||||
# When Update Stock is disabled, this invoice has no stock impact: the linked
|
||||
# Purchase Receipt already booked the stock (at standard) and the Purchase Price
|
||||
# Variance. Here we only clear "Stock Received But Not Billed" at the full billed
|
||||
# amount against the supplier - booking PPV again would double count it and leave
|
||||
# SRBNB partially uncleared.
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
handled = False
|
||||
if (
|
||||
item.item_code
|
||||
and item.item_code in stock_items
|
||||
and item.get("purchase_receipt")
|
||||
and not doc.is_return
|
||||
and get_valuation_method(item.item_code, doc.company) == "Standard Cost"
|
||||
):
|
||||
handled = self.make_standard_cost_srbnb_split(
|
||||
gl_entries, item, expense_account, account_currency, base_amount
|
||||
)
|
||||
|
||||
if not handled:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": base_amount,
|
||||
"debit_in_transaction_currency": amount,
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# check if the exchange rate has changed
|
||||
if (
|
||||
@@ -520,6 +530,95 @@ class PurchaseInvoiceGLComposer(BaseGLComposer):
|
||||
},
|
||||
)
|
||||
|
||||
def make_standard_cost_srbnb_split(
|
||||
self, gl_entries, item, expense_account, account_currency, base_amount
|
||||
):
|
||||
"""For a Standard Cost item billed against a Purchase Receipt, clear SRBNB at the standard
|
||||
value the receipt actually booked and post the (Net Amount - standard) difference to the
|
||||
Purchase Price Variance account. Returns False (caller falls back) if the receipt value
|
||||
can't be resolved."""
|
||||
from erpnext.stock.doctype.item_standard_cost.item_standard_cost import (
|
||||
get_purchase_price_variance_account,
|
||||
)
|
||||
|
||||
doc = self.doc
|
||||
precision = item.precision("base_net_amount")
|
||||
standard_value = flt(self.get_pr_stock_value(item), precision)
|
||||
if not standard_value:
|
||||
return False
|
||||
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": expense_account,
|
||||
"against": doc.supplier,
|
||||
"debit": standard_value,
|
||||
"debit_in_transaction_currency": flt(standard_value / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Accounting Entry for Stock"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
account_currency,
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
variance = flt(base_amount - standard_value, precision)
|
||||
if variance:
|
||||
gl_entries.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": get_purchase_price_variance_account(item.item_code, doc.company),
|
||||
"against": doc.supplier,
|
||||
"debit": variance,
|
||||
"debit_in_transaction_currency": flt(variance / doc.conversion_rate, precision),
|
||||
"remarks": doc.get("remarks") or _("Purchase Price Variance"),
|
||||
"cost_center": item.cost_center,
|
||||
"project": item.project or doc.project,
|
||||
},
|
||||
item=item,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
def get_pr_stock_value(self, item):
|
||||
"""Stock value (at standard) the linked Purchase Receipt booked for the quantity this invoice
|
||||
row is billing.
|
||||
|
||||
Accepted and rejected stock for the same receipt row share `voucher_detail_no`, so the
|
||||
warehouse filter is required: without it the accepted warehouse's SRBNB would be cleared at
|
||||
accepted + rejected value and post the wrong Purchase Price Variance amount. The accepted
|
||||
warehouse is read from the receipt row itself (not the invoice row, which may be unset on a
|
||||
non-stock invoice).
|
||||
|
||||
The receipt's full accepted value is pro-rated to the invoiced quantity, so a partial bill
|
||||
clears SRBNB (and posts PPV) for only the units it covers, not the whole receipt row."""
|
||||
pr_detail = frappe.db.get_value(
|
||||
"Purchase Receipt Item", item.pr_detail, ["warehouse", "stock_qty"], as_dict=True
|
||||
)
|
||||
if not pr_detail or not pr_detail.warehouse:
|
||||
return 0.0
|
||||
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
result = (
|
||||
frappe.qb.from_(sle)
|
||||
.select(Sum(sle.stock_value_difference))
|
||||
.where(
|
||||
(sle.voucher_type == "Purchase Receipt")
|
||||
& (sle.voucher_no == item.purchase_receipt)
|
||||
& (sle.voucher_detail_no == item.pr_detail)
|
||||
& (sle.warehouse == pr_detail.warehouse)
|
||||
& (sle.is_cancelled == 0)
|
||||
)
|
||||
).run()
|
||||
accepted_value = flt(result[0][0]) if result and result[0][0] else 0.0
|
||||
if not accepted_value or not flt(pr_detail.stock_qty):
|
||||
return accepted_value
|
||||
|
||||
# Pro-rate to the quantity being billed by this invoice row (handles partial billing).
|
||||
return accepted_value * flt(item.stock_qty) / flt(pr_detail.stock_qty)
|
||||
|
||||
def get_stock_variance_account(self, item):
|
||||
"""For Standard Cost items the purchase-price-vs-standard difference is a Purchase Price
|
||||
Variance; for all other items it keeps the existing behaviour (default expense account)."""
|
||||
|
||||
@@ -1,55 +1,11 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company"
|
||||
|
||||
|
||||
class TestRepostPaymentLedger(ERPNextTestSuite):
|
||||
"""Repost Payment Ledger auto-selects submitted vouchers on/after a cutoff date
|
||||
(unless rows are added manually) and queues them for a ledger rebuild."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_repost(self, **args):
|
||||
args = frappe._dict(args)
|
||||
doc = frappe.new_doc("Repost Payment Ledger")
|
||||
doc.company = COMPANY
|
||||
doc.posting_date = args.get("posting_date", "2026-06-01")
|
||||
doc.voucher_type = args.get("voucher_type", "Sales Invoice")
|
||||
doc.add_manually = args.get("add_manually", 0)
|
||||
return doc
|
||||
|
||||
def test_loads_submitted_vouchers_on_or_after_cutoff(self):
|
||||
after_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
|
||||
on_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-06-01", rate=100, qty=1)
|
||||
before_cutoff = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
|
||||
|
||||
doc = self.make_repost(posting_date="2026-06-01", voucher_type="Sales Invoice")
|
||||
doc.save() # before_validate loads the vouchers and sets status
|
||||
|
||||
loaded = {v.voucher_no for v in doc.repost_vouchers}
|
||||
self.assertIn(after_cutoff.name, loaded)
|
||||
# the filter is >= so an invoice posted exactly on the cutoff is included
|
||||
self.assertIn(on_cutoff.name, loaded)
|
||||
self.assertNotIn(before_cutoff.name, loaded)
|
||||
self.assertEqual(doc.repost_status, "Queued")
|
||||
|
||||
def test_add_manually_preserves_user_rows(self):
|
||||
# manually add a BEFORE-cutoff invoice (which the filter would never load) while a
|
||||
# matching after-cutoff invoice also exists. If auto-loading wrongly ran it would
|
||||
# drop the manual row and pull the after-cutoff one, so this distinguishes the modes.
|
||||
manual_si = create_sales_invoice(company=COMPANY, posting_date="2026-01-15", rate=100, qty=1)
|
||||
create_sales_invoice(company=COMPANY, posting_date="2026-06-15", rate=100, qty=1)
|
||||
|
||||
doc = self.make_repost(add_manually=1, posting_date="2026-06-01")
|
||||
doc.append("repost_vouchers", {"voucher_type": "Sales Invoice", "voucher_no": manual_si.name})
|
||||
doc.save()
|
||||
|
||||
rows = [(v.voucher_type, v.voucher_no) for v in doc.repost_vouchers]
|
||||
self.assertEqual(rows, [("Sales Invoice", manual_si.name)])
|
||||
pass
|
||||
|
||||
@@ -594,15 +594,7 @@ def create_dunning(
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
for row in target.overdue_payments:
|
||||
if row.payment_schedule == source.payment_schedule[0].name:
|
||||
# outstanding_amount is in the party account currency, but the Overdue Payment
|
||||
# row is in the invoice's transaction currency. When they differ, use the
|
||||
# payment schedule's own outstanding — it is kept in transaction currency and
|
||||
# updated as payments are allocated, so it stays correct even when the invoice
|
||||
# and its payments post at different exchange rates (#56006).
|
||||
if source.party_account_currency and source.party_account_currency != source.currency:
|
||||
row.outstanding = source.payment_schedule[0].outstanding
|
||||
else:
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
row.outstanding = source.get("outstanding_amount")
|
||||
|
||||
target.validate()
|
||||
|
||||
|
||||
@@ -344,9 +344,7 @@ def update_multi_mode_option(doc, pos_profile) -> None:
|
||||
payment.account = payment_mode.default_account
|
||||
payment.type = payment_mode.type
|
||||
|
||||
# is_created_using_pos exists on Sales Invoice but not POS Invoice; use get() so this
|
||||
# shared helper doesn't raise AttributeError when called on a POS Invoice
|
||||
mop_refetched = bool(doc.payments) and not doc.get("is_created_using_pos")
|
||||
mop_refetched = bool(doc.payments) and not doc.is_created_using_pos
|
||||
|
||||
doc.set("payments", [])
|
||||
invalid_modes = []
|
||||
|
||||
@@ -121,65 +121,3 @@ class TestShareTransfer(ERPNextTestSuite):
|
||||
}
|
||||
)
|
||||
self.assertRaises(ShareDontExists, doc.insert)
|
||||
|
||||
|
||||
class TestShareTransferValidation(ERPNextTestSuite):
|
||||
"""basic_validations() enforces the transfer's internal consistency. Exercised
|
||||
directly (to_folio_no set to skip folio auto-naming) so no shareholder fixtures
|
||||
are needed - it only reasons about the document's own fields."""
|
||||
|
||||
def make_transfer(self, **overrides):
|
||||
doc = frappe.new_doc("Share Transfer")
|
||||
doc.update(
|
||||
{
|
||||
"transfer_type": "Transfer",
|
||||
"date": "2026-01-01",
|
||||
"from_shareholder": "SH-A",
|
||||
"to_shareholder": "SH-B",
|
||||
"to_folio_no": "1",
|
||||
"share_type": "Equity",
|
||||
"from_no": 1,
|
||||
"to_no": 100,
|
||||
"no_of_shares": 100,
|
||||
"rate": 10,
|
||||
"amount": 1000,
|
||||
"company": "_Test Company",
|
||||
"equity_or_liability_account": "Creditors - _TC",
|
||||
}
|
||||
)
|
||||
doc.update(overrides)
|
||||
return doc
|
||||
|
||||
def test_baseline_transfer_is_consistent(self):
|
||||
# the helper's defaults must pass, otherwise the negative cases prove nothing
|
||||
self.make_transfer().basic_validations()
|
||||
|
||||
def test_seller_and_buyer_must_differ(self):
|
||||
doc = self.make_transfer(to_shareholder="SH-A")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_share_count_must_match_the_number_range(self):
|
||||
# 1..100 is 100 shares, not 50
|
||||
doc = self.make_transfer(no_of_shares=50)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_must_equal_rate_times_shares(self):
|
||||
doc = self.make_transfer(amount=999) # 10 * 100 = 1000
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_amount_is_derived_when_left_blank(self):
|
||||
doc = self.make_transfer(amount=0)
|
||||
doc.basic_validations()
|
||||
self.assertEqual(doc.amount, 1000)
|
||||
|
||||
def test_equity_or_liability_account_is_required(self):
|
||||
doc = self.make_transfer(equity_or_liability_account=None)
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_issue_requires_a_to_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Issue", to_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
def test_purchase_requires_a_from_shareholder(self):
|
||||
doc = self.make_transfer(transfer_type="Purchase", from_shareholder="", asset_account="Cash - _TC")
|
||||
self.assertRaises(frappe.ValidationError, doc.basic_validations)
|
||||
|
||||
@@ -79,9 +79,7 @@ def get_plan_rate(
|
||||
start_date = getdate(start_date)
|
||||
end_date = getdate(end_date)
|
||||
|
||||
delta = relativedelta.relativedelta(end_date, start_date)
|
||||
# include the years component so cross-year spans aren't under-counted
|
||||
no_of_months = delta.years * 12 + delta.months + 1
|
||||
no_of_months = relativedelta.relativedelta(end_date, start_date).months + 1
|
||||
cost = plan.cost * no_of_months
|
||||
|
||||
# Adjust cost if start or end date is not month start or end
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSubscriptionPlan(ERPNextTestSuite):
|
||||
"""Subscription Plan validates its interval and computes a rate. The Monthly
|
||||
Rate branch multiplies cost by the number of months in the billing window."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_plan(self, **args):
|
||||
args = frappe._dict(args)
|
||||
plan = frappe.new_doc("Subscription Plan")
|
||||
plan.plan_name = f"_Test Plan {frappe.generate_hash(length=6)}"
|
||||
plan.item = args.item or "_Test Item"
|
||||
plan.currency = args.currency or "INR"
|
||||
plan.price_determination = args.price_determination
|
||||
plan.cost = args.cost or 0
|
||||
plan.billing_interval = args.billing_interval or "Month"
|
||||
plan.billing_interval_count = (
|
||||
args.billing_interval_count if args.billing_interval_count is not None else 1
|
||||
)
|
||||
return plan
|
||||
|
||||
def test_billing_interval_count_must_be_positive(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100, billing_interval_count=0)
|
||||
self.assertRaises(frappe.ValidationError, plan.insert)
|
||||
|
||||
def test_fixed_rate_applies_prorate_factor(self):
|
||||
plan = self.make_plan(price_determination="Fixed Rate", cost=100)
|
||||
plan.insert()
|
||||
self.assertEqual(get_plan_rate(plan.name), 100)
|
||||
self.assertEqual(get_plan_rate(plan.name, prorate_factor=0.5), 50)
|
||||
|
||||
def test_monthly_rate_within_year(self):
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
# Jan 1 - Mar 31 is 3 whole months; month-aligned so proration is 0
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2026-03-31")
|
||||
self.assertEqual(rate, 300)
|
||||
|
||||
def test_monthly_rate_across_year_boundary(self):
|
||||
# a 14-month span (Jan 2026 to Feb 2027) bills all 14 months, not just the
|
||||
# 2-month remainder that relativedelta.months alone would give
|
||||
plan = self.make_plan(price_determination="Monthly Rate", cost=100)
|
||||
plan.insert()
|
||||
rate = get_plan_rate(plan.name, start_date="2026-01-01", end_date="2027-02-28")
|
||||
self.assertEqual(rate, 1400)
|
||||
pass
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Banking",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.924019",
|
||||
"modified": "2026-06-14 13:43:50.924019",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Banking",
|
||||
@@ -43,7 +43,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wrench",
|
||||
"icon": "tool",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Bank Reconciliation",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Budgeting",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 04:24:48.116724",
|
||||
"modified": "2026-07-02 04:24:48.116724",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Budgeting",
|
||||
@@ -59,7 +59,7 @@
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"default_workspace": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Accounting Dimension",
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.095321",
|
||||
"modified": "2026-06-14 13:44:08.095321",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -284,7 +284,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Financial Reports",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 4,
|
||||
"indicator_color": "",
|
||||
"is_hidden": 0,
|
||||
@@ -587,7 +587,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.471142",
|
||||
"modified": "2026-06-14 13:44:08.471142",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -622,7 +622,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -635,7 +635,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -786,7 +786,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"label": "Payments",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.184761",
|
||||
"modified": "2026-06-14 13:43:50.184761",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -31,7 +31,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -44,7 +44,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"indent": 1,
|
||||
"keep_closed": 0,
|
||||
"label": "Payments",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Share Management",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:51.040978",
|
||||
"modified": "2026-06-14 13:43:51.040978",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Share Management",
|
||||
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"child": 1,
|
||||
"collapsible": 1,
|
||||
"icon": "user",
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Shareholder",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Subscriptions",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 14:08:36.999272",
|
||||
"modified": "2026-06-14 14:08:36.999272",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Subscriptions",
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "coins",
|
||||
"icon": "money-coins-1",
|
||||
"idx": 0,
|
||||
"indicator_color": "green",
|
||||
"is_hidden": 0,
|
||||
"label": "Taxes",
|
||||
"link_type": "DocType",
|
||||
"links": [],
|
||||
"modified": "2026-07-03 13:43:50.894825",
|
||||
"modified": "2026-06-14 13:43:50.894825",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"module_onboarding": "Accounting Onboarding",
|
||||
@@ -58,7 +58,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Item Tax Template",
|
||||
|
||||
@@ -587,47 +587,3 @@ def get_actual_sle_dict(name):
|
||||
}
|
||||
|
||||
return sle_dict
|
||||
|
||||
|
||||
class TestAssetCapitalizationValidation(ERPNextTestSuite):
|
||||
"""Row-level validations for the consumed/target items. Exercised on the document
|
||||
directly (the integration tests above cover the full capitalization posting)."""
|
||||
|
||||
def make_capitalization(self, **fields):
|
||||
doc = frappe.new_doc("Asset Capitalization")
|
||||
doc.company = "_Test Company"
|
||||
doc.update(fields)
|
||||
return doc
|
||||
|
||||
def test_source_items_are_mandatory(self):
|
||||
doc = self.make_capitalization()
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_source_mandatory)
|
||||
|
||||
def test_target_item_must_be_a_fixed_asset(self):
|
||||
# _Test Item is a stock item, not a fixed asset
|
||||
doc = self.make_capitalization(target_item_code="_Test Item")
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_target_item)
|
||||
|
||||
def test_consumed_stock_row_rejects_a_non_stock_item(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("stock_items", {"item_code": "_Test Non Stock Item", "stock_qty": 1})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
|
||||
|
||||
def test_consumed_stock_row_requires_positive_qty(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("stock_items", {"item_code": "_Test Item", "stock_qty": 0})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_consumed_stock_item)
|
||||
|
||||
def test_service_row_rejects_a_stock_item(self):
|
||||
doc = self.make_capitalization()
|
||||
doc.append("service_items", {"item_code": "_Test Item", "qty": 1, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_service_item)
|
||||
|
||||
def test_service_row_requires_positive_qty_and_rate(self):
|
||||
zero_qty = self.make_capitalization()
|
||||
zero_qty.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 0, "rate": 100})
|
||||
self.assertRaises(frappe.ValidationError, zero_qty.validate_service_item)
|
||||
|
||||
zero_rate = self.make_capitalization()
|
||||
zero_rate.append("service_items", {"item_code": "_Test Non Stock Item", "qty": 1, "rate": 0})
|
||||
self.assertRaises(frappe.ValidationError, zero_rate.validate_service_item)
|
||||
|
||||
@@ -224,7 +224,7 @@ def get_children(doctype: str, parent: str | None = None, location: str | None =
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_node():
|
||||
from frappe.desk.treeview import make_tree_args
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "archive",
|
||||
"icon": "assets",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Assets",
|
||||
@@ -199,7 +199,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.417956",
|
||||
"modified": "2026-06-14 13:44:08.417956",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"module_onboarding": "Asset Onboarding",
|
||||
@@ -217,7 +217,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -230,7 +230,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -295,7 +295,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
|
||||
@@ -55,7 +55,7 @@ def make_supplier_quotation_from_rfq(
|
||||
|
||||
|
||||
# This method is used to make supplier quotation from supplier's portal.
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def create_supplier_quotation(doc: str | Document | dict):
|
||||
doc = frappe.parse_json(doc)
|
||||
|
||||
|
||||
@@ -185,7 +185,7 @@ def refresh_scorecards():
|
||||
frappe.get_doc("Supplier Scorecard", sc_name).save()
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_all_scorecards(docname: str):
|
||||
sc = frappe.get_doc("Supplier Scorecard", docname)
|
||||
supplier = frappe.get_doc("Supplier", sc.supplier)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Min
|
||||
from frappe.query_builder.functions import Max
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
@@ -279,44 +279,39 @@ def get_po_entries(filters):
|
||||
parent = frappe.qb.DocType("Purchase Order")
|
||||
child = frappe.qb.DocType("Purchase Order Item")
|
||||
|
||||
# one coherent representative line per (PO, material_request_item): per-column Max() over the
|
||||
# old GROUP BY could stitch values from different PO lines into a row that never existed
|
||||
representative_lines = (
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.from_(child)
|
||||
.select(Min(child.name))
|
||||
.select(
|
||||
Max(child.name).as_("name"),
|
||||
Max(child.parent).as_("parent"),
|
||||
Max(child.cost_center).as_("cost_center"),
|
||||
Max(child.project).as_("project"),
|
||||
Max(child.warehouse).as_("warehouse"),
|
||||
Max(child.material_request).as_("material_request"),
|
||||
child.material_request_item,
|
||||
Max(child.item_code).as_("item_code"),
|
||||
Max(child.stock_uom).as_("stock_uom"),
|
||||
Max(child.qty).as_("qty"),
|
||||
Max(child.amount).as_("amount"),
|
||||
Max(child.base_amount).as_("base_amount"),
|
||||
Max(child.schedule_date).as_("schedule_date"),
|
||||
Max(parent.transaction_date).as_("transaction_date"),
|
||||
Max(parent.supplier).as_("supplier"),
|
||||
Max(parent.status).as_("status"),
|
||||
Max(parent.owner).as_("owner"),
|
||||
)
|
||||
.where(
|
||||
(parent.docstatus == 1)
|
||||
& (parent.name == child.parent)
|
||||
& (parent.status.notin(("Closed", "Completed", "Cancelled")))
|
||||
)
|
||||
.groupby(child.parent, child.material_request_item)
|
||||
)
|
||||
representative_lines = apply_filters_on_query(filters, parent, child, representative_lines)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.from_(child)
|
||||
.select(
|
||||
child.name,
|
||||
child.parent,
|
||||
child.cost_center,
|
||||
child.project,
|
||||
child.warehouse,
|
||||
child.material_request,
|
||||
child.material_request_item,
|
||||
child.item_code,
|
||||
child.stock_uom,
|
||||
child.qty,
|
||||
child.amount,
|
||||
child.base_amount,
|
||||
child.schedule_date,
|
||||
parent.transaction_date,
|
||||
parent.supplier,
|
||||
parent.status,
|
||||
parent.owner,
|
||||
)
|
||||
.where((parent.name == child.parent) & (child.name.isin(representative_lines)))
|
||||
# Group only by the PO and material_request_item (the pre-effort key) and aggregate the rest
|
||||
# with Max(): postgres requires every non-grouped column to be aggregated, and this keeps one
|
||||
# row per (PO, material_request_item) — matching the prior MariaDB row count. Adding the PO
|
||||
# Item PK to the GROUP BY would split a multi-line PO into one row per line.
|
||||
.groupby(parent.name, child.material_request_item)
|
||||
)
|
||||
query = apply_filters_on_query(filters, parent, child, query)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
|
||||
from frappe.utils import add_days, flt, nowdate
|
||||
from frappe.utils import add_days, nowdate
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestProcurementTracker(ERPNextTestSuite):
|
||||
def test_report_executes_and_lists_po(self):
|
||||
# get_po_entries returns one representative line per (Purchase Order, material_request_item);
|
||||
# this exercises that query so the report stays valid on Postgres.
|
||||
# get_po_entries groups by (Purchase Order, material_request_item) and Max()-aggregates the
|
||||
# other child columns; this exercises that GROUP BY so the report stays valid on Postgres
|
||||
# (which rejects selecting non-grouped columns).
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.procurement_tracker.procurement_tracker import execute
|
||||
|
||||
@@ -21,10 +22,11 @@ class TestProcurementTracker(ERPNextTestSuite):
|
||||
self.assertTrue(columns)
|
||||
self.assertIn(po.name, {row.get("purchase_order") for row in data})
|
||||
|
||||
def test_multi_line_po_stays_one_coherent_row(self):
|
||||
# Lines sharing the same (blank) material_request_item collapse to ONE row, matching the
|
||||
# pre-effort MariaDB row count — and that row must be a real PO line, not a per-column
|
||||
# Max() chimera mixing one line's item_code with another line's qty/amount.
|
||||
def test_multi_line_po_stays_one_row(self):
|
||||
# A PO can carry several lines that share the same (blank) material_request_item. get_po_entries
|
||||
# groups by (Purchase Order, material_request_item) and Max()-aggregates the rest, so such a PO
|
||||
# yields ONE row — matching the pre-effort MariaDB output. Adding the Purchase Order Item PK to
|
||||
# the GROUP BY (the regression) splits it into one row per line, changing the MariaDB row count.
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
from erpnext.buying.report.procurement_tracker.procurement_tracker import execute
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
@@ -48,10 +50,3 @@ class TestProcurementTracker(ERPNextTestSuite):
|
||||
|
||||
po_rows = [row for row in data if row.get("purchase_order") == po.name]
|
||||
self.assertEqual(len(po_rows), 1)
|
||||
|
||||
real_lines = {(d.item_code, flt(d.qty), flt(d.amount)) for d in po.items}
|
||||
row = po_rows[0]
|
||||
self.assertIn(
|
||||
(row.get("item_code"), flt(row.get("quantity")), flt(row.get("purchase_order_amt"))),
|
||||
real_lines,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import copy
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Coalesce, Max, Min, Sum
|
||||
from frappe.query_builder.functions import Coalesce, Max, Sum
|
||||
from frappe.utils import cint, date_diff, flt, getdate
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ def get_data(filters):
|
||||
# non-grouped columns are constant per grouped mr.name / item_code -> Max() keeps the
|
||||
# GROUP BY valid on postgres while returning the same value MySQL picked.
|
||||
Max(mr.transaction_date).as_("date"),
|
||||
Min(mr_item.schedule_date).as_("required_date"),
|
||||
Max(mr_item.schedule_date).as_("required_date"),
|
||||
mr_item.item_code.as_("item_code"),
|
||||
Sum(Coalesce(mr_item.qty, 0)).as_("qty"),
|
||||
Sum(Coalesce(mr_item.stock_qty, 0)).as_("stock_qty"),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
from frappe.utils import add_days, today
|
||||
|
||||
from erpnext.buying.doctype.purchase_order.mapper import make_purchase_receipt
|
||||
from erpnext.buying.report.requested_items_to_order_and_receive.requested_items_to_order_and_receive import (
|
||||
@@ -44,36 +44,6 @@ class TestRequestedItemsToOrderAndReceive(ERPNextTestSuite):
|
||||
self.assertEqual(data[0].ordered_qty, 0.0)
|
||||
self.assertEqual(data[1].ordered_qty, 57.0)
|
||||
|
||||
def test_required_date_is_earliest_schedule_date(self):
|
||||
create_item("Test MR Report Dup Item")
|
||||
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])
|
||||
mr.transaction_date = today()
|
||||
mr.schedule_date = add_days(today(), 5)
|
||||
mr.set("items", mr.items[:1])
|
||||
row = mr.items[0]
|
||||
row.item_code = "Test MR Report Dup Item"
|
||||
row.item_name = "Test MR Report Dup Item"
|
||||
row.description = "Test MR Report Dup Item"
|
||||
row.uom = "Nos"
|
||||
row.schedule_date = add_days(today(), 5)
|
||||
mr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": "Test MR Report Dup Item",
|
||||
"item_name": "Test MR Report Dup Item",
|
||||
"description": "Test MR Report Dup Item",
|
||||
"uom": "Nos",
|
||||
"qty": row.qty,
|
||||
"warehouse": row.warehouse,
|
||||
"schedule_date": add_days(today(), 1),
|
||||
},
|
||||
)
|
||||
mr.submit()
|
||||
|
||||
data = get_data(self.filters.update({"item_code": "Test MR Report Dup Item"}))
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(getdate(data[0].required_date), getdate(add_days(today(), 1)))
|
||||
|
||||
def setup_material_request(self, order=False, receive=False, days=0):
|
||||
po = None
|
||||
mr = frappe.copy_doc(self.globalTestRecords["Material Request"][0])
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "Buying",
|
||||
@@ -501,7 +501,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:43:50.509039",
|
||||
"modified": "2026-06-14 13:43:50.509039",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"module_onboarding": "Buying Onboarding",
|
||||
@@ -532,7 +532,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -545,7 +545,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Dashboard",
|
||||
@@ -610,7 +610,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "scale",
|
||||
"icon": "liabilities",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Purchase Invoice",
|
||||
|
||||
@@ -1724,7 +1724,7 @@ def get_missing_company_details(doctype: str, docname: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def update_company_master_and_address(current_doctype: str, name: str, company: str, details: dict | str):
|
||||
from frappe.utils import validate_email_address
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections import OrderedDict
|
||||
import frappe
|
||||
from frappe import _, qb
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import IfNull, Sum
|
||||
from frappe.query_builder.functions import IfNull, Max, Sum
|
||||
from frappe.utils import fmt_money
|
||||
|
||||
from erpnext.accounts.doctype.budget.budget import BudgetError, get_accumulated_monthly_budget
|
||||
@@ -260,10 +260,10 @@ class BudgetValidation:
|
||||
qb.from_(mr)
|
||||
.inner_join(mri)
|
||||
.on(mr.name == mri.parent)
|
||||
# rate is outside the Sum (no GROUP BY -> implicit aggregate); Max() keeps it valid on
|
||||
# postgres and matches MySQL's arbitrary single-rate choice for this aggregate.
|
||||
.select(
|
||||
Sum((IfNull(mri.stock_qty, 0) - IfNull(mri.ordered_qty, 0)) * IfNull(mri.rate, 0)).as_(
|
||||
"amount"
|
||||
)
|
||||
(Sum(IfNull(mri.stock_qty, 0) - IfNull(mri.ordered_qty, 0)) * Max(mri.rate)).as_("amount")
|
||||
)
|
||||
.where(Criterion.all(conditions))
|
||||
.run(as_dict=True)
|
||||
|
||||
@@ -20,7 +20,7 @@ from frappe.query_builder.functions import (
|
||||
Substring,
|
||||
Sum,
|
||||
)
|
||||
from frappe.utils import cint, nowdate, today, unique
|
||||
from frappe.utils import nowdate, today, unique
|
||||
from pypika import Order
|
||||
|
||||
import erpnext
|
||||
@@ -808,11 +808,7 @@ def get_filtered_dimensions(
|
||||
query_filters.append(["company", "=", filters.get("company")])
|
||||
|
||||
for field in searchfields:
|
||||
df = meta.get_field(field)
|
||||
if df and df.fieldtype != "Check":
|
||||
or_filters.append([field, "LIKE", "%%%s%%" % txt])
|
||||
else:
|
||||
or_filters.append([field, "=", cint(txt)])
|
||||
or_filters.append([field, "LIKE", "%%%s%%" % txt])
|
||||
fields.append(field)
|
||||
|
||||
if dimension_filters:
|
||||
|
||||
@@ -653,7 +653,7 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
|
||||
return [item for item in items if item.get("item_code") in inspection_required_items]
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_quality_inspections(
|
||||
company: str, doctype: str, docname: str, items: str | list, inspection_type: str
|
||||
):
|
||||
|
||||
@@ -203,9 +203,6 @@ def get_data(filters, conditions):
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
if not row1:
|
||||
continue
|
||||
|
||||
des[ind] = row[i][0]
|
||||
des[ind - 1] = row1[0][0]
|
||||
|
||||
|
||||
@@ -1,59 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import datetime
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAppointmentBookingSettings(ERPNextTestSuite):
|
||||
"""The settings validate each availability slot: from-time must precede to-time and
|
||||
the slot length must be a whole multiple of the appointment duration."""
|
||||
|
||||
def make_settings(self, appointment_duration=30):
|
||||
doc = frappe.new_doc("Appointment Booking Settings")
|
||||
doc.appointment_duration = appointment_duration
|
||||
return doc
|
||||
|
||||
def dt(self, hms):
|
||||
# the controller parses times against a fixed epoch date
|
||||
return datetime.datetime.strptime("01/01/1970 " + hms, "%d/%m/%Y %H:%M:%S")
|
||||
|
||||
def test_from_time_must_precede_to_time(self):
|
||||
doc = self.make_settings()
|
||||
record = frappe._dict(day_of_week="Monday")
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
doc.validate_from_and_to_time,
|
||||
self.dt("18:00:00"),
|
||||
self.dt("09:00:00"),
|
||||
record,
|
||||
)
|
||||
doc.validate_from_and_to_time(self.dt("09:00:00"), self.dt("18:00:00"), record) # valid order
|
||||
|
||||
def test_slot_length_must_be_a_multiple_of_the_duration(self):
|
||||
doc = self.make_settings(appointment_duration=30)
|
||||
# 60 minutes is two 30-minute appointments -> fine
|
||||
doc.duration_is_divisible(self.dt("09:00:00"), self.dt("10:00:00"))
|
||||
# 45 minutes leaves a partial appointment -> rejected
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, doc.duration_is_divisible, self.dt("09:00:00"), self.dt("09:45:00")
|
||||
)
|
||||
|
||||
def test_validate_checks_every_slot(self):
|
||||
bad = self.make_settings(appointment_duration=30)
|
||||
bad.append(
|
||||
"availability_of_slots",
|
||||
{"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "09:45:00"},
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, bad.validate)
|
||||
|
||||
# a clean 60-minute slot passes end to end
|
||||
good = self.make_settings(appointment_duration=30)
|
||||
good.append(
|
||||
"availability_of_slots",
|
||||
{"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "10:00:00"},
|
||||
)
|
||||
good.validate()
|
||||
pass
|
||||
|
||||
@@ -17,7 +17,7 @@ frappe.ui.form.on("Campaign", {
|
||||
frappe.route_options = { utm_source: "Campaign", utm_campaign: frm.doc.name };
|
||||
frappe.set_route("List", "Lead");
|
||||
},
|
||||
null,
|
||||
"fa fa-list",
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,34 +26,24 @@ class Campaign(Document):
|
||||
# end: auto-generated types
|
||||
|
||||
def after_insert(self):
|
||||
self.sync_utm_campaign()
|
||||
|
||||
def on_change(self):
|
||||
self.sync_utm_campaign()
|
||||
|
||||
def sync_utm_campaign(self):
|
||||
mc = self.get_utm_campaign_mirror()
|
||||
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
|
||||
# link by the document name, which differs from campaign_name when a naming series is used
|
||||
mc.crm_campaign = self.name
|
||||
mc.crm_campaign = self.campaign_name
|
||||
mc.save(ignore_permissions=True)
|
||||
|
||||
def get_utm_campaign_mirror(self):
|
||||
# the mirror already linked to this Campaign, if any (survives campaign_name edits)
|
||||
if owned := frappe.db.get_value("UTM Campaign", {"crm_campaign": self.name}):
|
||||
return frappe.get_doc("UTM Campaign", owned)
|
||||
|
||||
# reuse a same-named mirror only when it isn't already owned by another Campaign,
|
||||
# otherwise two Campaigns sharing a display name would hijack each other's mirror
|
||||
if frappe.db.exists("UTM Campaign", self.campaign_name):
|
||||
same_name = frappe.get_doc("UTM Campaign", self.campaign_name)
|
||||
if not same_name.crm_campaign or same_name.crm_campaign == self.name:
|
||||
return same_name
|
||||
|
||||
# create a fresh mirror, keeping its name unique when the display name is taken
|
||||
mc = frappe.new_doc("UTM Campaign")
|
||||
mc.name = self.name if frappe.db.exists("UTM Campaign", self.campaign_name) else self.campaign_name
|
||||
return mc
|
||||
def on_change(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)
|
||||
|
||||
def autoname(self):
|
||||
if frappe.defaults.get_global_default("campaign_naming_by") != "Naming Series":
|
||||
|
||||
@@ -1,70 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCampaign(ERPNextTestSuite):
|
||||
"""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)
|
||||
|
||||
def test_two_campaigns_sharing_a_name_do_not_hijack_each_others_mirror(self):
|
||||
# a naming series lets two Campaigns share a display name; each must keep its own mirror
|
||||
original = frappe.defaults.get_global_default("campaign_naming_by")
|
||||
frappe.defaults.set_global_default("campaign_naming_by", "Naming Series")
|
||||
try:
|
||||
first = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-")
|
||||
second = self.make_campaign(campaign_name="_Test Shared Mirror", naming_series="SAL-CAM-.YYYY.-")
|
||||
finally:
|
||||
frappe.defaults.set_global_default("campaign_naming_by", original or "")
|
||||
|
||||
# the first Campaign's mirror is untouched; the second gets a distinct one
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("UTM Campaign", "_Test Shared Mirror", "crm_campaign"), first.name
|
||||
)
|
||||
second_mirror = frappe.db.get_value("UTM Campaign", {"crm_campaign": second.name})
|
||||
self.assertTrue(second_mirror)
|
||||
self.assertNotEqual(second_mirror, "_Test Shared Mirror")
|
||||
pass
|
||||
|
||||
@@ -1,43 +1,8 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.crm.doctype.contract_template.contract_template import get_contract_template
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestContractTemplate(ERPNextTestSuite):
|
||||
"""Contract Template validates its Jinja terms and renders them against a doc."""
|
||||
|
||||
def test_malformed_contract_terms_are_rejected(self):
|
||||
doc = frappe.new_doc("Contract Template")
|
||||
doc.contract_terms = "{% for x in %}" # invalid Jinja
|
||||
self.assertRaises(frappe.ValidationError, doc.validate)
|
||||
|
||||
# a valid template, and no template at all, both pass
|
||||
doc.contract_terms = "Party: {{ party_name }}"
|
||||
doc.validate()
|
||||
doc.contract_terms = None
|
||||
doc.validate()
|
||||
|
||||
def test_get_contract_template_renders_terms(self):
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Contract Template",
|
||||
"title": "_Test Contract Template",
|
||||
"contract_terms": "Party: {{ party_name }}",
|
||||
}
|
||||
).insert()
|
||||
|
||||
result = get_contract_template(template.name, {"party_name": "Acme"})
|
||||
self.assertEqual(result["contract_terms"], "Party: Acme")
|
||||
self.assertEqual(result["contract_template"].name, template.name)
|
||||
|
||||
def test_get_contract_template_without_terms_returns_none(self):
|
||||
template = frappe.get_doc(
|
||||
{"doctype": "Contract Template", "title": "_Test Empty Contract Template"}
|
||||
).insert()
|
||||
|
||||
result = get_contract_template(template.name, {})
|
||||
self.assertIsNone(result["contract_terms"])
|
||||
pass
|
||||
|
||||
@@ -1,39 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestCRMSettings(ERPNextTestSuite):
|
||||
"""CRM Settings guards its Frappe-CRM sync and Contact-Us opportunity toggles."""
|
||||
|
||||
def make_settings(self, **fields):
|
||||
doc = frappe.new_doc("CRM Settings")
|
||||
doc.update(fields)
|
||||
return doc
|
||||
|
||||
def test_data_sync_requires_at_least_one_allowed_user(self):
|
||||
doc = self.make_settings(enable_frappe_crm_data_synchronization=1)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_allowed_users)
|
||||
# adding a user satisfies the check
|
||||
doc.append("allowed_users", {"user": "Administrator"})
|
||||
doc.validate_allowed_users()
|
||||
|
||||
def test_disabling_sync_clears_allowed_users(self):
|
||||
doc = self.make_settings(enable_frappe_crm_data_synchronization=0)
|
||||
doc.append("allowed_users", {"user": "Administrator"})
|
||||
doc.clear_allowed_users()
|
||||
self.assertEqual(doc.allowed_users, [])
|
||||
|
||||
# while sync is on, the rows are kept
|
||||
enabled = self.make_settings(enable_frappe_crm_data_synchronization=1)
|
||||
enabled.append("allowed_users", {"user": "Administrator"})
|
||||
enabled.clear_allowed_users()
|
||||
self.assertEqual(len(enabled.allowed_users), 1)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Contact Us Settings", {"is_disabled": 1})
|
||||
def test_opportunity_from_contact_us_needs_the_form_enabled(self):
|
||||
doc = self.make_settings(enable_opportunity_creation_from_contact_us=1)
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_enable_opportunity_creation_from_contact_us)
|
||||
pass
|
||||
|
||||
@@ -1,61 +1,9 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_days, getdate, today
|
||||
# import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestEmailCampaign(ERPNextTestSuite):
|
||||
"""Email Campaign derives its window from the linked Campaign schedule and
|
||||
guards the start date and the recipient's email."""
|
||||
|
||||
def setUp(self):
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
def make_email_template(self):
|
||||
name = "_Test EC Email Template"
|
||||
if not frappe.db.exists("Email Template", name):
|
||||
frappe.get_doc(
|
||||
{"doctype": "Email Template", "name": name, "subject": "Test", "response": "Hello"}
|
||||
).insert()
|
||||
return name
|
||||
|
||||
def make_campaign(self, schedules):
|
||||
campaign = frappe.new_doc("Campaign")
|
||||
campaign.campaign_name = f"_Test EC Campaign {frappe.generate_hash(length=6)}"
|
||||
for days in schedules:
|
||||
campaign.append(
|
||||
"campaign_schedules",
|
||||
{"send_after_days": days, "email_template": self.make_email_template()},
|
||||
)
|
||||
return campaign.insert()
|
||||
|
||||
def make_email_campaign(self, campaign_name, start_date=None):
|
||||
doc = frappe.new_doc("Email Campaign")
|
||||
doc.campaign_name = campaign_name
|
||||
doc.start_date = start_date or today()
|
||||
return doc
|
||||
|
||||
def test_start_date_cannot_be_in_the_past(self):
|
||||
doc = self.make_email_campaign("irrelevant", start_date=add_days(today(), -1))
|
||||
self.assertRaises(frappe.ValidationError, doc.set_date)
|
||||
|
||||
def test_end_date_is_start_plus_max_send_after_days(self):
|
||||
campaign = self.make_campaign(schedules=[0, 5])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
doc.set_date()
|
||||
self.assertEqual(getdate(doc.end_date), add_days(getdate(today()), 5))
|
||||
|
||||
def test_campaign_without_a_schedule_is_rejected(self):
|
||||
campaign = self.make_campaign(schedules=[])
|
||||
doc = self.make_email_campaign(campaign.name)
|
||||
self.assertRaises(frappe.ValidationError, doc.set_date)
|
||||
|
||||
def test_lead_without_an_email_is_rejected(self):
|
||||
lead = frappe.get_doc({"doctype": "Lead", "lead_name": "_Test Lead No Email"}).insert()
|
||||
doc = frappe.new_doc("Email Campaign")
|
||||
doc.email_campaign_for = "Lead"
|
||||
doc.recipient = lead.name
|
||||
self.assertRaises(frappe.ValidationError, doc.validate_lead)
|
||||
pass
|
||||
|
||||
@@ -380,7 +380,7 @@ def get_lead_with_phone_number(number):
|
||||
return lead
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_lead_to_prospect(lead: str, prospect: str):
|
||||
prospect = frappe.get_doc("Prospect", prospect)
|
||||
prospect.append("leads", {"lead": lead})
|
||||
|
||||
@@ -110,7 +110,7 @@ def make_quotation(source_name: str, target_doc: str | Document | None = None):
|
||||
return target_doc
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_lead_from_communication(communication: str, ignore_communication_links: bool = False):
|
||||
"""raise a issue from email"""
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def make_supplier_quotation(source_name: str, target_doc: str | Document | None
|
||||
return doclist
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def make_opportunity_from_communication(
|
||||
communication: str, company: str, ignore_communication_links: bool = False
|
||||
):
|
||||
|
||||
@@ -389,7 +389,7 @@ def get_item_details(item_code: str):
|
||||
}
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def set_multiple_status(names: str | list[str], status: str):
|
||||
names = frappe.parse_json(names)
|
||||
for name in names:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"doctype": "Workspace",
|
||||
"for_user": "",
|
||||
"hide_custom": 0,
|
||||
"icon": "handshake",
|
||||
"icon": "crm",
|
||||
"idx": 0,
|
||||
"is_hidden": 0,
|
||||
"label": "CRM",
|
||||
@@ -421,7 +421,7 @@
|
||||
"type": "Link"
|
||||
}
|
||||
],
|
||||
"modified": "2026-07-03 13:44:08.297053",
|
||||
"modified": "2026-06-14 13:44:08.297053",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM",
|
||||
@@ -471,7 +471,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "chart-column",
|
||||
"icon": "chart",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Home",
|
||||
@@ -510,7 +510,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "user",
|
||||
"icon": "customer",
|
||||
"indent": 0,
|
||||
"keep_closed": 0,
|
||||
"label": "Customer",
|
||||
@@ -644,7 +644,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "rocket",
|
||||
"icon": "getting-started",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Maintenance",
|
||||
@@ -776,7 +776,7 @@
|
||||
{
|
||||
"child": 0,
|
||||
"collapsible": 1,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"indent": 1,
|
||||
"keep_closed": 1,
|
||||
"label": "Campaign",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"icon_type": "Folder",
|
||||
"idx": 1,
|
||||
"label": "Accounting",
|
||||
"link_to": "",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 17:04:04.351402",
|
||||
"modified": "2026-01-27 17:04:04.351402",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Accounting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "archive",
|
||||
"icon": "assets",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Assets",
|
||||
"link_to": "Assets",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.220411",
|
||||
"modified": "2026-01-01 20:07:01.220411",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Assets",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "chart-pie",
|
||||
"icon": "expenses",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Budget",
|
||||
"link_to": "Budget",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:39:30.839274",
|
||||
"modified": "2026-01-23 14:39:30.839274",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Budget",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "shopping-cart",
|
||||
"icon": "buying",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Buying",
|
||||
"link_to": "Buying",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.196163",
|
||||
"modified": "2026-01-01 20:07:01.196163",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Buying",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "handshake",
|
||||
"icon": "crm",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "CRM",
|
||||
"link_to": "CRM",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:54:05.112927",
|
||||
"modified": "2026-01-06 14:54:05.112927",
|
||||
"modified_by": "Administrator",
|
||||
"name": "CRM",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "settings",
|
||||
"icon": "setting",
|
||||
"icon_type": "Link",
|
||||
"idx": 10,
|
||||
"label": "ERPNext Settings",
|
||||
"link_to": "ERPNext Settings",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "",
|
||||
"modified": "2026-07-03 14:59:56.044037",
|
||||
"modified": "2026-01-09 14:59:56.044037",
|
||||
"modified_by": "Administrator",
|
||||
"name": "ERPNext Settings",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "house",
|
||||
"icon": "home",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Home",
|
||||
"link_to": "Home",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.174950",
|
||||
"modified": "2026-01-01 20:07:01.174950",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Home",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "wallet",
|
||||
"icon": "accounting",
|
||||
"icon_type": "Link",
|
||||
"idx": 0,
|
||||
"label": "Invoicing",
|
||||
"link_to": "Invoicing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 15:17:23.564795",
|
||||
"modified": "2026-01-23 15:17:23.564795",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Invoicing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "factory",
|
||||
"icon": "organization",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Manufacturing",
|
||||
"link_to": "Manufacturing",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.246693",
|
||||
"modified": "2026-01-01 20:07:01.246693",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Manufacturing",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "folder-kanban",
|
||||
"icon": "project",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Projects",
|
||||
"link_to": "Projects",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.226383",
|
||||
"modified": "2026-01-01 20:07:01.226383",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Projects",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "shield-check",
|
||||
"icon": "quality",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Quality",
|
||||
"link_to": "Quality",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.239523",
|
||||
"modified": "2026-01-01 20:07:01.239523",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Quality",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "store",
|
||||
"icon": "sell",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Selling",
|
||||
"link_to": "Selling",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.189446",
|
||||
"modified": "2026-01-01 20:07:01.189446",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Selling",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "package",
|
||||
"icon": "stock",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Stock",
|
||||
"link_to": "Stock",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 20:07:01.212940",
|
||||
"modified": "2026-01-01 20:07:01.212940",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Stock",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 0,
|
||||
"icon": "package-2",
|
||||
"icon": "getting-started",
|
||||
"icon_type": "Link",
|
||||
"idx": 6,
|
||||
"label": "Subcontracting",
|
||||
"link_to": "Subcontracting",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"logo_url": "/assets/erpnext/desktop_icons/subcontracting.svg",
|
||||
"modified": "2026-07-03 20:07:01.323508",
|
||||
"modified": "2026-01-01 20:07:01.323508",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Subcontracting",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"docstatus": 0,
|
||||
"doctype": "Desktop Icon",
|
||||
"hidden": 1,
|
||||
"icon": "headset",
|
||||
"icon": "support",
|
||||
"icon_type": "Link",
|
||||
"idx": 1,
|
||||
"label": "Support",
|
||||
"link_to": "Support",
|
||||
"link_type": "Workspace Sidebar",
|
||||
"modified": "2026-07-03 14:53:54.100467",
|
||||
"modified": "2026-01-06 14:53:54.100467",
|
||||
"modified_by": "Administrator",
|
||||
"name": "Support",
|
||||
"owner": "Administrator",
|
||||
|
||||
@@ -50,7 +50,7 @@ def get_plaid_configuration():
|
||||
return "disabled"
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_institution(token: str, response: str | dict):
|
||||
response = frappe.parse_json(response)
|
||||
|
||||
@@ -79,7 +79,7 @@ def add_institution(token: str, response: str | dict):
|
||||
return bank
|
||||
|
||||
|
||||
@frappe.whitelist(methods=["POST"])
|
||||
@frappe.whitelist()
|
||||
def add_bank_accounts(response: str | dict, bank: str | dict, company: str):
|
||||
response = frappe.parse_json(response)
|
||||
bank = frappe.parse_json(bank)
|
||||
|
||||
@@ -1319,12 +1319,8 @@ def _add_secondary_item_columns(query, t, stock_item_condition):
|
||||
|
||||
|
||||
def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_semi_finished_goods):
|
||||
# Grouped also by bom_no/is_phantom_item: the pair MUST come from the same BOM Item row --
|
||||
# _add_bom_item_to_dict recurses into bom_no when is_phantom_item is set, so independent Max()
|
||||
# per column could pair one line's phantom flag with another line's bom_no and explode the
|
||||
# wrong sub-BOM (same fix as sub_assembly_queries). The remaining non-grouped columns are
|
||||
# constant per grouped item_code (+operation/operation_row_id) -> Max() keeps the GROUP BY
|
||||
# valid on postgres while returning the value MySQL picked arbitrarily.
|
||||
# non-grouped columns are constant per grouped item_code (+operation/operation_row_id) -> Max()
|
||||
# keeps the GROUP BY valid on postgres while returning the value MySQL picked arbitrarily.
|
||||
# NOTE: base_rate is aliased "rate" below and is what callers receive; bom_item.rate was selected
|
||||
# under the same alias and silently shadowed (last value wins in the dict), so it is dropped here
|
||||
# -- output is unchanged.
|
||||
@@ -1339,15 +1335,14 @@ def _add_normal_item_columns(query, t, amount_col, stock_item_condition, track_s
|
||||
Max(t.bom_item.description).as_("description"),
|
||||
Max(t.bom_item.base_rate).as_("rate"),
|
||||
Max(t.bom_item.operation_row_id).as_("operation_row_id"),
|
||||
t.bom_item.is_phantom_item,
|
||||
t.bom_item.bom_no,
|
||||
Max(t.bom_item.is_phantom_item).as_("is_phantom_item"),
|
||||
Max(t.bom_item.bom_no).as_("bom_no"),
|
||||
).where(stock_item_condition | (t.bom_item.is_phantom_item == 1))
|
||||
|
||||
if track_semi_finished_goods:
|
||||
group_by = [t.bom_item.item_code, t.bom_item.operation_row_id, t.item_doc.stock_uom]
|
||||
else:
|
||||
group_by = [t.bom_item.item_code, t.item_doc.stock_uom, t.bom_item.operation]
|
||||
group_by += [t.bom_item.bom_no, t.bom_item.is_phantom_item]
|
||||
|
||||
return query, group_by
|
||||
|
||||
|
||||
@@ -57,50 +57,6 @@ class TestBOM(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(len(get_bom_items(bom=get_default_bom(), company="_Test Company")), 3)
|
||||
|
||||
@timeout
|
||||
def test_get_items_keeps_bom_no_phantom_pair_coherent(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
for phantom_first in (True, False):
|
||||
rm_phantom = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
rm_normal = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
component = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
|
||||
# phantom sub-BOM created first -> smaller auto-name; the non-phantom one gets the
|
||||
# larger name, which is exactly what an independent Max(bom_no) would wrongly pick
|
||||
phantom_bom = make_bom(item=component, raw_materials=[rm_phantom], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
normal_bom = make_bom(item=component, raw_materials=[rm_normal])
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
first_bom, second_bom = (
|
||||
(phantom_bom.name, normal_bom.name) if phantom_first else (normal_bom.name, phantom_bom.name)
|
||||
)
|
||||
parent = make_bom(item=fg_item, raw_materials=[component], do_not_save=True)
|
||||
parent.items[0].bom_no = first_bom
|
||||
component_doc = frappe.get_doc("Item", component)
|
||||
parent.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": component,
|
||||
"qty": 1,
|
||||
"uom": component_doc.stock_uom,
|
||||
"stock_uom": component_doc.stock_uom,
|
||||
"bom_no": second_bom,
|
||||
},
|
||||
)
|
||||
parent.save()
|
||||
parent.submit()
|
||||
|
||||
items_dict = get_bom_items_as_dict(parent.name, "_Test Company", qty=1, fetch_exploded=0)
|
||||
self.assertIn(rm_phantom, items_dict)
|
||||
self.assertIn(component, items_dict)
|
||||
self.assertEqual(flt(items_dict[component].qty), 1.0)
|
||||
self.assertNotIn(rm_normal, items_dict)
|
||||
|
||||
@timeout
|
||||
def test_default_bom(self):
|
||||
def _get_default_bom_in_item():
|
||||
|
||||
@@ -272,7 +272,7 @@
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nTo Manufacture\nCancelled\nCompleted",
|
||||
"options": "Open\nWork In Progress\nPartially Transferred\nMaterial Transferred\nOn Hold\nSubmitted\nCancelled\nCompleted",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -695,7 +695,7 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-20 17:39:42.293242",
|
||||
"modified": "2026-06-19 17:39:42.293242",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Job Card",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user