mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-31 08:14:36 +00:00
Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8378b6e203 | ||
|
|
eaf95e5c36 | ||
|
|
52f61c088e | ||
|
|
9c946eb168 | ||
|
|
8f36753705 | ||
|
|
7a606ab91c | ||
|
|
2471c5ccbb | ||
|
|
0ce50407f9 | ||
|
|
ee12f8d2d4 | ||
|
|
c690ed0058 | ||
|
|
083ef9baa7 | ||
|
|
f05e8ed0ce | ||
|
|
feec193d7d | ||
|
|
861c50e727 | ||
|
|
f1a0a5e1bf | ||
|
|
7eb0eb77aa | ||
|
|
ca15a14666 | ||
|
|
afbe1f7e53 | ||
|
|
339bb0b4ea | ||
|
|
fe6534b888 | ||
|
|
ebc8482310 | ||
|
|
c411b8e471 | ||
|
|
06ba783267 | ||
|
|
a30aac87bf | ||
|
|
c7ffe822f1 | ||
|
|
9e5f77b57c | ||
|
|
0863c1e05c | ||
|
|
39d5fd84db | ||
|
|
68caa60dfa | ||
|
|
f9b3e42dcd | ||
|
|
59efe7299a | ||
|
|
3ea19d8eb1 | ||
|
|
1f9c4bc933 | ||
|
|
484ff8e349 | ||
|
|
ad9870acc1 | ||
|
|
8925585895 | ||
|
|
8d511c9b71 | ||
|
|
85925680ac | ||
|
|
5763378ee1 | ||
|
|
c3eac77ee1 | ||
|
|
008c3b145d | ||
|
|
ee75fac9d7 | ||
|
|
220d005cc2 | ||
|
|
ed1a58aa2a | ||
|
|
2fd1dbd23a | ||
|
|
68dcc96527 | ||
|
|
708d889036 | ||
|
|
20e2728037 | ||
|
|
2359e1610a | ||
|
|
2e5fc38179 | ||
|
|
6d856fa632 | ||
|
|
1802c75ed6 | ||
|
|
c182b4085b | ||
|
|
a0af717234 | ||
|
|
d3c5e866f0 | ||
|
|
2b8b6a09dd | ||
|
|
c79f2e45a8 | ||
|
|
ab4b5abb91 | ||
|
|
3f6501b4ff | ||
|
|
b9c4f790bd | ||
|
|
2206e9fe2a | ||
|
|
bbe7580c9d | ||
|
|
ef21a9ba9b | ||
|
|
88f9039d1a | ||
|
|
2e6b4b5838 | ||
|
|
85d793b690 | ||
|
|
8a50572786 | ||
|
|
bbd942c600 | ||
|
|
84813d7f46 | ||
|
|
1132eb1a0f | ||
|
|
2323f2c978 | ||
|
|
e277647f7b | ||
|
|
c587f4934a | ||
|
|
e0020b478a | ||
|
|
95de2374ed | ||
|
|
d70e005412 | ||
|
|
32baf6a47c | ||
|
|
3f87836536 | ||
|
|
da3844c4df | ||
|
|
1f5281d3b8 | ||
|
|
04e7457cea |
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.29.0"
|
||||
__version__ = "16.30.0"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
|
||||
|
||||
def link_address(self):
|
||||
"""Link address based on owner"""
|
||||
if self.is_your_company_address:
|
||||
if self.get("is_your_company_address"):
|
||||
return
|
||||
|
||||
return super().link_address()
|
||||
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
|
||||
self.is_your_company_address = 1
|
||||
|
||||
def validate_reference(self):
|
||||
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
|
||||
if self.get("is_your_company_address") and not [
|
||||
row for row in self.links if row.link_doctype == "Company"
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Address needs to be linked to a Company. Please add a row for Company in the Links table."
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"enable_common_party_accounting",
|
||||
"allow_multi_currency_invoices_against_single_party_account",
|
||||
"confirm_before_resetting_posting_date",
|
||||
"stock_expense_section",
|
||||
"book_stock_expense_gl_entries",
|
||||
"analytics_section",
|
||||
"enable_discounts_and_margin",
|
||||
"enable_accounting_dimensions",
|
||||
@@ -75,6 +77,8 @@
|
||||
"over_billing_allowance",
|
||||
"credit_controller",
|
||||
"role_allowed_to_over_bill",
|
||||
"enable_overdue_billing_threshold",
|
||||
"role_allowed_to_bypass_overdue_billing",
|
||||
"column_break_11",
|
||||
"assets_tab",
|
||||
"asset_settings_section",
|
||||
@@ -271,6 +275,21 @@
|
||||
"label": "Role Allowed to over bill ",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Block a new Sales Invoice when the customer's overdue amount exceeds the Overdue Limit set on the customer.",
|
||||
"fieldname": "enable_overdue_billing_threshold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Restrict Customer Over Billing"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_overdue_billing_threshold",
|
||||
"description": "Users with this role can still submit invoices for customers who have crossed their Overdue Limit.",
|
||||
"fieldname": "role_allowed_to_bypass_overdue_billing",
|
||||
"fieldtype": "Link",
|
||||
"label": "Role Allowed to Bypass Over Billing Restriction",
|
||||
"options": "Role"
|
||||
},
|
||||
{
|
||||
"fieldname": "period_closing_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
@@ -749,6 +768,18 @@
|
||||
"description": "Changing the account in any transaction of the DocTypes listed below will trigger a repost. To prevent reposting, remove the relevant DocType from the list.",
|
||||
"fieldname": "column_break_mfor",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_expense_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Stock Expense Accounting"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Books Purchase Expense and Expenses Added To Stock account pairs against stock value. On enabling this, the accounts become mandatory in Company or Item Defaults for Purchase Receipt, Purchase Invoice, Stock Entry, Stock Reconciliation and Landed Cost Voucher",
|
||||
"fieldname": "book_stock_expense_gl_entries",
|
||||
"fieldtype": "Check",
|
||||
"label": "Book Stock Expense GL Entries"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -757,7 +788,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-24 12:59:41.868865",
|
||||
"modified": "2026-07-27 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -62,6 +62,7 @@ class AccountsSettings(Document):
|
||||
book_asset_depreciation_entry_automatically: DF.Check
|
||||
book_deferred_entries_based_on: DF.Literal["Days", "Months"]
|
||||
book_deferred_entries_via_journal_entry: DF.Check
|
||||
book_stock_expense_gl_entries: DF.Check
|
||||
book_tax_discount_loss: DF.Check
|
||||
calculate_depr_using_total_days: DF.Check
|
||||
check_supplier_invoice_uniqueness: DF.Check
|
||||
@@ -77,6 +78,7 @@ class AccountsSettings(Document):
|
||||
enable_fuzzy_matching: DF.Check
|
||||
enable_immutable_ledger: DF.Check
|
||||
enable_loyalty_point_program: DF.Check
|
||||
enable_overdue_billing_threshold: DF.Check
|
||||
enable_party_matching: DF.Check
|
||||
enable_subscription: DF.Check
|
||||
exchange_gain_loss_posting_date: DF.Literal["Invoice", "Payment", "Reconciliation Date"]
|
||||
@@ -95,6 +97,7 @@ class AccountsSettings(Document):
|
||||
receivable_payable_remarks_length: DF.Int
|
||||
reconciliation_queue_size: DF.Int
|
||||
repost_allowed_types: DF.Table[RepostAllowedTypes]
|
||||
role_allowed_to_bypass_overdue_billing: DF.Link | None
|
||||
role_allowed_to_over_bill: DF.Link | None
|
||||
role_to_notify_on_depreciation_failure: DF.Link | None
|
||||
role_to_override_stop_action: DF.Link | None
|
||||
@@ -150,6 +153,10 @@ class AccountsSettings(Document):
|
||||
toggle_subscription_sections(not self.enable_subscription)
|
||||
clear_cache = True
|
||||
|
||||
if old_doc.enable_overdue_billing_threshold != self.enable_overdue_billing_threshold:
|
||||
toggle_overdue_billing_threshold_field(not self.enable_overdue_billing_threshold)
|
||||
clear_cache = True
|
||||
|
||||
if clear_cache:
|
||||
frappe.clear_cache()
|
||||
|
||||
@@ -241,6 +248,10 @@ def toggle_subscription_sections(hide):
|
||||
create_property_setter_for_hiding_field(doctype, "subscription_section", hide)
|
||||
|
||||
|
||||
def toggle_overdue_billing_threshold_field(hide):
|
||||
create_property_setter_for_hiding_field("Customer Credit Limit", "overdue_billing_threshold", hide)
|
||||
|
||||
|
||||
def create_property_setter_for_hiding_field(doctype, field_name, hide):
|
||||
make_property_setter(
|
||||
doctype,
|
||||
|
||||
@@ -143,6 +143,30 @@ def preprocess_mt940_content(content: str) -> str:
|
||||
return processed_content
|
||||
|
||||
|
||||
MT940_CUSTOMER_REFERENCE_MAX_LEN = 16
|
||||
|
||||
|
||||
def get_transaction_reference(txn_data: dict) -> str:
|
||||
"""Extract the per-transaction reference from an MT940 :61: tag.
|
||||
|
||||
The mt940 library exposes ``transaction_reference`` from the :20: tag, which is the
|
||||
statement-level reference and identical for every transaction in a statement. The
|
||||
real per-transaction reference is ``customer_reference`` (with any overflow captured
|
||||
into ``extra_details`` when a bank emits a single-line :61: longer than 16 chars).
|
||||
"""
|
||||
customer_reference = (txn_data.get("customer_reference") or "").strip()
|
||||
|
||||
if len(customer_reference) == MT940_CUSTOMER_REFERENCE_MAX_LEN:
|
||||
customer_reference += (txn_data.get("extra_details") or "").strip()
|
||||
|
||||
if customer_reference and customer_reference.upper() != "NONREF":
|
||||
return customer_reference
|
||||
|
||||
return (txn_data.get("bank_reference") or "").strip() or (
|
||||
txn_data.get("transaction_reference") or ""
|
||||
).strip()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
doc = frappe.get_doc("Bank Statement Import", data_import)
|
||||
@@ -190,8 +214,8 @@ def convert_mt940_to_csv(data_import, mt940_file_path):
|
||||
|
||||
deposit = amount_value if amount_value > 0 else ""
|
||||
withdrawal = abs(amount_value) if amount_value < 0 else ""
|
||||
description = txn.data.get("extra_details") or ""
|
||||
reference = txn.data.get("transaction_reference") or ""
|
||||
description = txn.data.get("transaction_details") or txn.data.get("extra_details") or ""
|
||||
reference = get_transaction_reference(txn.data)
|
||||
currency = txn.data.get("currency", "")
|
||||
|
||||
writer.writerow([date_str, deposit, withdrawal, description, reference, doc.bank_account, currency])
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
# Copyright (c) 2020, Frappe Technologies and Contributors
|
||||
# See license.txt
|
||||
|
||||
import mt940
|
||||
|
||||
from erpnext.accounts.doctype.bank_statement_import.bank_statement_import import (
|
||||
get_transaction_reference,
|
||||
is_mt940_format,
|
||||
preprocess_mt940_content,
|
||||
)
|
||||
@@ -188,6 +191,135 @@ class TestBankStatementImport(ERPNextTestSuite):
|
||||
self.assertIn(":20:STMTREF167619", result) # Reference should remain unchanged
|
||||
self.assertIn("UPI/TEST USER/123456789/PaidViaTestApp", result)
|
||||
|
||||
def test_get_transaction_reference_uses_customer_reference(self):
|
||||
"""Per-transaction reference must come from :61: customer_reference, not :20:."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{"customer_reference": "UPI-100000000001", "transaction_reference": "STMTREF12345"}
|
||||
),
|
||||
"UPI-100000000001",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_rejoins_overflow(self):
|
||||
"""When a bank emits a single-line :61: with >16-char reference, the regex
|
||||
splits the tail into extra_details. We must rejoin them."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NEFTINW-12345678",
|
||||
"extra_details": "90",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"NEFTINW-1234567890",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref(self):
|
||||
"""NONREF is the MT940 'no customer reference' sentinel; prefer bank_reference."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NONREF",
|
||||
"bank_reference": "1234567890123456",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"1234567890123456",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_falls_back_to_bank_reference_on_nonref_with_extra_details(self):
|
||||
"""NONREF sentinel must trigger the bank_reference fallback even when
|
||||
extra_details is populated. Without the 16-char gate, the old naive concat
|
||||
would produce a junk reference like 'NONREFsome info' and bypass the check."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NONREF",
|
||||
"extra_details": "some info",
|
||||
"bank_reference": "1234567890123456",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"1234567890123456",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_does_not_append_extra_details_below_16_chars(self):
|
||||
"""When customer_reference is below the 16-char cap, extra_details is a
|
||||
genuine supplementary-info field from :61: — not overflow — and must not
|
||||
be appended to the reference."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "TBMS-123456789",
|
||||
"extra_details": "note field",
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"TBMS-123456789",
|
||||
)
|
||||
|
||||
def test_get_transaction_reference_keeps_noref_literal(self):
|
||||
"""Bare 'NOREF' (without bank_reference) stays as-is; still better than the
|
||||
statement-level reference which is identical across all transactions."""
|
||||
self.assertEqual(
|
||||
get_transaction_reference(
|
||||
{
|
||||
"customer_reference": "NOREF",
|
||||
"bank_reference": None,
|
||||
"transaction_reference": "STMTREF12345",
|
||||
}
|
||||
),
|
||||
"NOREF",
|
||||
)
|
||||
|
||||
def test_mt940_parse_per_transaction_reference_mapping(self):
|
||||
"""End-to-end: every transaction in a statement must get its own distinct
|
||||
reference from :61: customer_reference, never the statement-level :20: reference."""
|
||||
mt940_content = """{1:F0112345678901X0000000000}{2:I94012345678901XN}{4:
|
||||
:20:STMTREF12345
|
||||
:25:1234567890
|
||||
:28C:12345/1
|
||||
:60F:C250716INR88123,38
|
||||
:61:2509280928D5000,00NMSCUPI-100000000001
|
||||
:86:UPI/TEST PAYEE ONE/111111111111/TestApp
|
||||
:61:2509190919D2606,00NMSCUPI-100000000002
|
||||
:86:UPI/TEST PAYEE TWO/222222222222/TestApp
|
||||
:61:2509190919D900,00NMSCUPI-100000000003
|
||||
:86:UPI/TEST PAYEE THREE/333333333333/TestApp
|
||||
:61:2508140814D5000,00NMSCUPI-100000000004
|
||||
:86:UPI/TEST PAYEE FOUR/444444444444/TestApp
|
||||
:61:2508060806D2000,00NMSCUPI-100000000005
|
||||
:86:UPI/TEST PAYEE FIVE/555555555555/TestApp
|
||||
:61:2508030803D1066,00NMSC123456789012
|
||||
:86:PCD/1234/TEST MERCHANT/01234567890123/12:00
|
||||
:61:2507310731D305,62NMSCTBMS-123456789
|
||||
:86:Chrg: Debit Card Annual Fee 1234 for 2025
|
||||
:61:2507240724C1,00NMSCNEFTINW-1234567890
|
||||
:86:NEFT TEST123456789 TEST SERVICES
|
||||
:61:2507170717C100000,00NMSCNOREF
|
||||
:86:BY CLG INST 123456/01-01-25/TESTBANK/TESTCITY
|
||||
:62F:C250930INR100000,00
|
||||
-}"""
|
||||
transactions = list(mt940.parse(preprocess_mt940_content(mt940_content)))
|
||||
references = [get_transaction_reference(t.data) for t in transactions]
|
||||
|
||||
self.assertEqual(
|
||||
references,
|
||||
[
|
||||
"UPI-100000000001",
|
||||
"UPI-100000000002",
|
||||
"UPI-100000000003",
|
||||
"UPI-100000000004",
|
||||
"UPI-100000000005",
|
||||
"123456789012",
|
||||
"TBMS-123456789",
|
||||
"NEFTINW-1234567890",
|
||||
"NOREF",
|
||||
],
|
||||
)
|
||||
# No transaction should carry the statement-level reference from :20:
|
||||
self.assertNotIn("STMTREF12345", references)
|
||||
|
||||
def test_preprocess_mt940_content_whitespace_variants(self):
|
||||
"""Test handling of whitespace and different line endings"""
|
||||
# Test with trailing spaces
|
||||
|
||||
@@ -616,15 +616,27 @@ class ExchangeRateRevaluation(Document):
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
if drafts := frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": ["in", journals]},
|
||||
pluck="name",
|
||||
as_list=1,
|
||||
):
|
||||
part = "journals are" if len(drafts) > 1 else "journal is"
|
||||
doc_links = ", ".join(["{}".format(get_link_to_form("Journal Entry", x)) for x in drafts])
|
||||
frappe.throw(
|
||||
msg=_("Reverse {0} already available in draft status: {1}").format(part, doc_links),
|
||||
)
|
||||
else:
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.save()
|
||||
frappe.msgprint(
|
||||
_("A draft reverse journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
|
||||
@@ -9,11 +9,10 @@ from frappe.utils import add_days, flt, today
|
||||
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.accounts.test.accounts_mixin import AccountsTestMixin
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
class TestExchangeRateRevaluation(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.company = "_Test Company"
|
||||
self.item = "_Test Item"
|
||||
@@ -23,14 +22,6 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
self.set_system_and_company_settings()
|
||||
|
||||
def set_system_and_company_settings(self):
|
||||
# set number and currency precision
|
||||
system_settings = frappe.get_doc("System Settings")
|
||||
system_settings.float_precision = 2
|
||||
system_settings.currency_precision = 2
|
||||
system_settings.language = "en"
|
||||
system_settings.time_zone = "Asia/Kolkata"
|
||||
system_settings.save()
|
||||
|
||||
# Using Exchange Gain/Loss account for unrealized as well.
|
||||
company_doc = frappe.get_doc("Company", self.company)
|
||||
company_doc.unrealized_exchange_gain_loss_account = company_doc.exchange_gain_loss_account
|
||||
@@ -300,3 +291,97 @@ class TestExchangeRateRevaluation(ERPNextTestSuite, AccountsTestMixin):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
|
||||
)
|
||||
def test_05_revaluation_journal_reversal(self):
|
||||
"""
|
||||
Test reversing of revaluation journals
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer="_Test Customer 1",
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=1,
|
||||
)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
err = frappe.new_doc("Exchange Rate Revaluation")
|
||||
err.company = self.company
|
||||
err.posting_date = today()
|
||||
err.fetch_and_calculate_accounts_data()
|
||||
self.assertEqual(len(err.accounts), 1)
|
||||
err.save().submit()
|
||||
|
||||
gain_loss_account = err.get_for_unrealized_gain_loss_account()
|
||||
usd_account = err.accounts[0].account
|
||||
old_balance = err.accounts[0].balance_in_base_currency
|
||||
new_balance = err.accounts[0].new_balance_in_base_currency
|
||||
total_gain_loss = err.total_gain_loss
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
|
||||
je = je.submit()
|
||||
|
||||
je.reload()
|
||||
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
|
||||
self.assertEqual(len(je.accounts), 3)
|
||||
# A gain is credited to the gain/loss account, a loss is debited. The current
|
||||
# exchange rate (from master data) may sit either side of the booked rate, so
|
||||
# derive the column from the sign instead of assuming a gain.
|
||||
gain_loss_debit = abs(total_gain_loss) if total_gain_loss < 0 else 0.0
|
||||
gain_loss_credit = total_gain_loss if total_gain_loss > 0 else 0.0
|
||||
expected = [
|
||||
(usd_account, new_balance, 0.0, 100.0, 0.0),
|
||||
(usd_account, 0.0, old_balance, 0.0, 100.0),
|
||||
(gain_loss_account, gain_loss_debit, gain_loss_credit, gain_loss_debit, gain_loss_credit),
|
||||
]
|
||||
actual = []
|
||||
for acc in je.accounts:
|
||||
actual.append(
|
||||
(
|
||||
acc.account,
|
||||
acc.debit,
|
||||
acc.credit,
|
||||
acc.debit_in_account_currency,
|
||||
acc.credit_in_account_currency,
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
# Assert reversals are not posted
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
# submit
|
||||
draft = frappe.db.get_all(
|
||||
"Journal Entry",
|
||||
filters={"docstatus": 0, "reversal_of": je.name, "voucher_type": "Exchange Rate Revaluation"},
|
||||
pluck="name",
|
||||
as_list=1,
|
||||
)
|
||||
self.assertIsNotNone(draft)
|
||||
frappe.get_doc("Journal Entry", draft[0]).submit()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
reverse_jv = frappe.db.get_all(
|
||||
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
|
||||
)
|
||||
self.assertIsNotNone(reverse_jv)
|
||||
|
||||
@@ -1853,28 +1853,51 @@ class GrowthViewTransformer:
|
||||
self.formatted_rows = context.raw_data.get("formatted_data", [])
|
||||
self.period_list = context.period_list
|
||||
|
||||
def transform(self) -> None:
|
||||
def transform(self):
|
||||
for row_data in self.formatted_rows:
|
||||
if row_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
transformed_values = {}
|
||||
for i in range(len(self.period_list)):
|
||||
current_period = self.period_list[i]["key"]
|
||||
if row_data.get("segment_values"):
|
||||
self._transform_segmented_row(row_data)
|
||||
else:
|
||||
self._transform_single_row(row_data)
|
||||
|
||||
current_value = row_data[current_period]
|
||||
previous_value = row_data[self.period_list[i - 1]["key"]] if i != 0 else 0
|
||||
def _compute_growth_values(self, source: dict) -> dict:
|
||||
transformed = {}
|
||||
|
||||
if i == 0:
|
||||
transformed_values[current_period] = current_value
|
||||
else:
|
||||
growth_percent = self._calculate_growth(previous_value, current_value)
|
||||
transformed_values[current_period] = growth_percent
|
||||
for i, period in enumerate(self.period_list):
|
||||
current_period = period["key"]
|
||||
current_value = source.get(current_period)
|
||||
|
||||
row_data.update(transformed_values)
|
||||
if current_value in (None, ""):
|
||||
continue
|
||||
|
||||
if i == 0:
|
||||
transformed[current_period] = current_value
|
||||
else:
|
||||
previous_period = self.period_list[i - 1]["key"]
|
||||
previous_value = source.get(previous_period) or 0
|
||||
transformed[current_period] = self._calculate_growth(previous_value, current_value)
|
||||
|
||||
return transformed
|
||||
|
||||
def _transform_single_row(self, row_data: dict):
|
||||
row_data.update(self._compute_growth_values(row_data))
|
||||
|
||||
def _transform_segmented_row(self, row_data: dict):
|
||||
for seg_id, seg_data in row_data.get("segment_values", {}).items():
|
||||
if seg_data.get("is_blank_line"):
|
||||
continue
|
||||
|
||||
transformed = self._compute_growth_values(seg_data)
|
||||
seg_data.update(transformed)
|
||||
|
||||
for period_key, value in transformed.items():
|
||||
row_data[f"{seg_id}_{period_key}"] = value
|
||||
|
||||
def _calculate_growth(self, previous_value: float, current_value: float) -> float | None:
|
||||
if current_value is None:
|
||||
if current_value in (None, ""):
|
||||
return None
|
||||
|
||||
if previous_value == 0 and current_value > 0:
|
||||
|
||||
@@ -567,6 +567,7 @@ $.extend(erpnext.journal_entry, {
|
||||
lock_reversal_entry: function (frm) {
|
||||
frm.fields
|
||||
.filter((field) => field.has_input)
|
||||
.filter((field) => field.df.fieldname != "posting_date")
|
||||
.forEach((field) => frm.set_df_property(field.df.fieldname, "read_only", 1));
|
||||
frm.set_df_property("accounts", "read_only", 1);
|
||||
},
|
||||
|
||||
@@ -217,7 +217,8 @@ class POSClosingEntry(StatusUpdater):
|
||||
self.update_sales_invoices_closing_entry()
|
||||
|
||||
def before_cancel(self):
|
||||
self.check_pce_is_cancellable()
|
||||
if self.status != "Failed":
|
||||
self.check_pce_is_cancellable()
|
||||
|
||||
def on_cancel(self):
|
||||
unconsolidate_pos_invoices(closing_entry=self)
|
||||
|
||||
@@ -517,6 +517,7 @@ class SalesInvoice(SellingController):
|
||||
self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
|
||||
self.update_billing_status_for_zero_amount_refdoc("Sales Order")
|
||||
self.check_credit_limit()
|
||||
self.check_overdue_billing_threshold()
|
||||
|
||||
if cint(self.is_pos) != 1 and not self.is_return:
|
||||
self.update_against_document_in_jv()
|
||||
@@ -778,6 +779,11 @@ class SalesInvoice(SellingController):
|
||||
pos_invoice_doc = frappe.get_doc("POS Invoice", pos_invoice)
|
||||
pos_invoice_doc.cancel()
|
||||
|
||||
def check_overdue_billing_threshold(self):
|
||||
from erpnext.selling.doctype.customer.customer import check_overdue_billing_threshold
|
||||
|
||||
check_overdue_billing_threshold(self.customer, self.company)
|
||||
|
||||
@frappe.whitelist()
|
||||
def set_missing_values(self, for_validate=False):
|
||||
pos = self.set_pos_fields(for_validate)
|
||||
|
||||
@@ -82,8 +82,7 @@
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"reqd": 1
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipping_amount_section",
|
||||
@@ -141,19 +140,20 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:41.653314",
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -197,7 +197,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,17 @@ class ShippingRule(Document):
|
||||
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
|
||||
ShippingRuleCondition,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
|
||||
ShippingRuleCountry,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
|
||||
|
||||
account: DF.Link
|
||||
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
|
||||
company: DF.Link
|
||||
conditions: DF.Table[ShippingRuleCondition]
|
||||
cost_center: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
countries: DF.Table[ShippingRuleCountry]
|
||||
disabled: DF.Check
|
||||
label: DF.Data
|
||||
project: DF.Link | None
|
||||
shipping_amount: DF.Currency
|
||||
shipping_rule_type: DF.Literal["Selling", "Buying"]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -858,7 +858,7 @@ def get_dashboard_info(party_type, party, loyalty_program=None):
|
||||
|
||||
doctype = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice"
|
||||
|
||||
companies = frappe.get_all(
|
||||
companies = frappe.get_list(
|
||||
doctype, filters={"docstatus": 1, party_type.lower(): party}, distinct=1, fields=["company"]
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -69,10 +69,10 @@ frappe.query_reports["Accounts Payable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Payable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -98,10 +98,10 @@ frappe.query_reports["Accounts Receivable"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,8 +55,7 @@ class ReceivablePayableReport:
|
||||
self.filters.report_date = getdate(self.filters.report_date or nowdate())
|
||||
self.age_as_on = (
|
||||
getdate(nowdate())
|
||||
if "calculate_ageing_with" not in self.filters
|
||||
or self.filters.calculate_ageing_with == "Today Date"
|
||||
if "age_as_on" not in self.filters or self.filters.age_as_on == "Today"
|
||||
else self.filters.report_date
|
||||
)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
},
|
||||
{
|
||||
fieldname: "report_date",
|
||||
label: __("Posting Date"),
|
||||
label: __("Report Date"),
|
||||
fieldtype: "Date",
|
||||
default: frappe.datetime.get_today(),
|
||||
},
|
||||
@@ -24,10 +24,10 @@ frappe.query_reports["Accounts Receivable Summary"] = {
|
||||
default: "Due Date",
|
||||
},
|
||||
{
|
||||
fieldname: "calculate_ageing_with",
|
||||
label: __("Calculate Ageing With"),
|
||||
fieldname: "age_as_on",
|
||||
label: __("Age as on"),
|
||||
fieldtype: "Select",
|
||||
options: "Report Date\nToday Date",
|
||||
options: "Report Date\nToday",
|
||||
default: "Report Date",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -80,6 +80,7 @@ def execute(filters=None):
|
||||
"parent_section": None,
|
||||
"indent": 0.0,
|
||||
"section": cash_flow_section["section_header"],
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ def get_data_when_grouped_by_invoice(columns, gross_profit_data, filters, group_
|
||||
)
|
||||
if total_base_amount
|
||||
else 0,
|
||||
"currency": filters.currency,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -269,6 +270,7 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
"buying_amount": total_buying_amount,
|
||||
"gross_profit": total_gross_profit,
|
||||
"gross_profit_percent": flt(gross_profit_percent, currency_precision),
|
||||
"currency": filters.currency,
|
||||
}
|
||||
|
||||
total_row = [total_row.get(col, None) for col in [*group_columns, "currency"]]
|
||||
|
||||
@@ -14,7 +14,6 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Order")
|
||||
data = get_data(filters, conditions)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
@@ -39,9 +38,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -60,4 +65,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -331,32 +331,51 @@ class BuyingController(SubcontractingController):
|
||||
address_display_field, render_address(self.get(address_field), check_permissions=False)
|
||||
)
|
||||
|
||||
def get_validated_purchase_expense_details(self, item_code):
|
||||
fields = ("purchase_expense_account", "purchase_expense_contra_account")
|
||||
details = get_purchase_expense_account(item_code, self.company)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
details[field] = frappe.get_cached_value("Company", self.company, field)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
frappe.throw(
|
||||
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
|
||||
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
|
||||
)
|
||||
)
|
||||
|
||||
return details
|
||||
|
||||
def set_gl_entry_for_purchase_expense(self, gl_entries):
|
||||
if not cint(frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")):
|
||||
return
|
||||
|
||||
if self.doctype == "Purchase Invoice" and not self.update_stock:
|
||||
return
|
||||
|
||||
stock_items = self.get_stock_items()
|
||||
|
||||
for row in self.items:
|
||||
details = get_purchase_expense_account(row.item_code, self.company)
|
||||
# A service item holds no stock value, so there is nothing to book against it - and it
|
||||
# must not make the expense accounts mandatory either.
|
||||
if row.item_code not in stock_items:
|
||||
continue
|
||||
|
||||
if not details.purchase_expense_account:
|
||||
details.purchase_expense_account = frappe.get_cached_value(
|
||||
"Company", self.company, "purchase_expense_account"
|
||||
)
|
||||
|
||||
if not details.purchase_expense_account:
|
||||
return
|
||||
|
||||
if not details.purchase_expense_contra_account:
|
||||
details.purchase_expense_contra_account = frappe.get_cached_value(
|
||||
"Company", self.company, "purchase_expense_contra_account"
|
||||
)
|
||||
|
||||
if not details.purchase_expense_contra_account:
|
||||
frappe.throw(
|
||||
_("Please set Purchase Expense Contra Account in Company {0}").format(self.company)
|
||||
)
|
||||
details = self.get_validated_purchase_expense_details(row.item_code)
|
||||
if not details:
|
||||
continue
|
||||
|
||||
amount = flt(row.valuation_rate * row.stock_qty, row.precision("base_amount"))
|
||||
if row.landed_cost_voucher_amount:
|
||||
amount -= flt(row.landed_cost_voucher_amount, row.precision("base_amount"))
|
||||
|
||||
if not amount:
|
||||
# GL Entry rejects a row with neither a debit nor a credit.
|
||||
continue
|
||||
|
||||
self.add_gl_entry(
|
||||
gl_entries=gl_entries,
|
||||
account=details.purchase_expense_account,
|
||||
|
||||
@@ -84,6 +84,11 @@ def stock_entry_row_requires_inspection(purpose, row):
|
||||
|
||||
|
||||
class StockController(AccountsController):
|
||||
#: Vouchers whose stock value change should also be booked to the Expenses Added To Stock
|
||||
#: account pair (Stock Entry, Stock Reconciliation). Purchase Receipt books its own, against
|
||||
#: the landed cost amount rather than the stock value difference.
|
||||
book_expenses_added_to_stock = False
|
||||
|
||||
def validate(self):
|
||||
super().validate()
|
||||
|
||||
@@ -858,10 +863,90 @@ class StockController(AccountsController):
|
||||
).format(wh, self.company)
|
||||
)
|
||||
|
||||
if self.book_expenses_added_to_stock:
|
||||
self.append_expenses_added_to_stock_entries(gl_list, voucher_details, sle_map)
|
||||
|
||||
return process_gl_map(
|
||||
gl_list, precision=precision, from_repost=frappe.flags.through_repost_item_valuation
|
||||
)
|
||||
|
||||
def book_stock_expense_enabled(self):
|
||||
if not hasattr(self, "_book_stock_expense_enabled"):
|
||||
self._book_stock_expense_enabled = cint(
|
||||
frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
|
||||
)
|
||||
|
||||
return self._book_stock_expense_enabled
|
||||
|
||||
def append_expenses_added_to_stock_entries(self, gl_list, voucher_details, sle_map):
|
||||
if not self.book_stock_expense_enabled():
|
||||
return
|
||||
|
||||
precision = self.get_debit_field_precision()
|
||||
|
||||
for item_row in voucher_details:
|
||||
sle_list = sle_map.get(item_row.name)
|
||||
if not sle_list:
|
||||
continue
|
||||
|
||||
amount = flt(sum(flt(sle.stock_value_difference) for sle in sle_list), precision)
|
||||
if not amount:
|
||||
continue
|
||||
|
||||
item_code = item_row.get("item_code") or sle_list[0].item_code
|
||||
self.append_expenses_added_to_stock_pair(gl_list, item_code, amount, item_row)
|
||||
|
||||
def append_expenses_added_to_stock_pair(self, gl_list, item_code, amount, item_row):
|
||||
# A service item holds no stock value, so there is nothing to book against it - and it must
|
||||
# not make the expense accounts mandatory either. A zero pair would be rejected by GL Entry
|
||||
# anyway, which needs a debit or a credit on every row.
|
||||
if not amount or not frappe.get_cached_value("Item", item_code, "is_stock_item"):
|
||||
return
|
||||
|
||||
fields = ("expenses_added_to_stock_account", "expenses_added_to_stock_contra_account")
|
||||
details = get_expenses_added_to_stock_accounts(item_code, self.company)
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
frappe.throw(
|
||||
_("Please set {0} in Company {1} or in the Item Defaults of Item {2}").format(
|
||||
frappe.bold(_(frappe.unscrub(field))), self.company, item_code
|
||||
)
|
||||
)
|
||||
|
||||
cost_center = item_row.get("cost_center") or frappe.get_cached_value(
|
||||
"Company", self.company, "cost_center"
|
||||
)
|
||||
remarks = _("Expenses Added To Stock for Item {0}").format(item_code)
|
||||
common_args = {
|
||||
"cost_center": cost_center,
|
||||
"project": item_row.get("project") or self.get("project"),
|
||||
"remarks": remarks,
|
||||
}
|
||||
|
||||
gl_list.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": details.expenses_added_to_stock_account,
|
||||
"against": details.expenses_added_to_stock_contra_account,
|
||||
"debit": amount,
|
||||
**common_args,
|
||||
},
|
||||
item=item_row,
|
||||
)
|
||||
)
|
||||
gl_list.append(
|
||||
self.get_gl_dict(
|
||||
{
|
||||
"account": details.expenses_added_to_stock_contra_account,
|
||||
"against": details.expenses_added_to_stock_account,
|
||||
"debit": -1 * amount,
|
||||
**common_args,
|
||||
},
|
||||
item=item_row,
|
||||
)
|
||||
)
|
||||
|
||||
def get_debit_field_precision(self):
|
||||
if not frappe.flags.debit_field_precision:
|
||||
frappe.flags.debit_field_precision = frappe.get_precision("GL Entry", "debit_in_account_currency")
|
||||
@@ -1371,8 +1456,9 @@ class StockController(AccountsController):
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "qty")
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
if flt(reserved_qty, precision) <= 0:
|
||||
continue
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
@@ -1383,7 +1469,7 @@ class StockController(AccountsController):
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
@@ -2567,3 +2653,31 @@ def get_item_wise_inventory_account_map(rows, company):
|
||||
)
|
||||
|
||||
return inventory_map
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_expenses_added_to_stock_accounts(item_code, company):
|
||||
"""Resolves the Expenses Added To Stock account pair for an item, falling back through
|
||||
Item Defaults -> Item Group -> Brand -> Company."""
|
||||
from erpnext.stock.doctype.item.item import get_item_defaults
|
||||
|
||||
fields = ["expenses_added_to_stock_account", "expenses_added_to_stock_contra_account"]
|
||||
defaults = get_item_defaults(item_code, company)
|
||||
|
||||
details = frappe._dict({field: defaults.get(field) for field in fields})
|
||||
|
||||
if not details.expenses_added_to_stock_account:
|
||||
details = frappe.db.get_value(
|
||||
"Item Default", {"parent": defaults.item_group, "company": company}, fields, as_dict=1
|
||||
) or frappe._dict({})
|
||||
|
||||
if not details.expenses_added_to_stock_account and defaults.get("brand"):
|
||||
details = frappe.db.get_value(
|
||||
"Item Default", {"parent": defaults.brand, "company": company}, fields, as_dict=1
|
||||
) or frappe._dict({})
|
||||
|
||||
for field in fields:
|
||||
if not details.get(field):
|
||||
details[field] = frappe.get_cached_value("Company", company, field)
|
||||
|
||||
return details
|
||||
|
||||
@@ -6,6 +6,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import DateTimeLikeObject, getdate, today
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
@@ -42,6 +43,9 @@ def get_columns(filters, trans):
|
||||
"addl_tables": based_on_details["addl_tables"],
|
||||
"addl_tables_relational_cond": based_on_details.get("addl_tables_relational_cond", ""),
|
||||
}
|
||||
conditions["company_currency"] = (
|
||||
erpnext.get_company_currency(filters.get("company")) if filters.get("company") else None
|
||||
)
|
||||
|
||||
return conditions
|
||||
|
||||
@@ -206,7 +210,7 @@ def get_data(filters, conditions):
|
||||
|
||||
data.append(des)
|
||||
|
||||
total_row = calculate_total_row(data1, conditions["columns"])
|
||||
total_row = calculate_total_row(data1, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
else:
|
||||
data = frappe.db.sql(
|
||||
@@ -231,19 +235,24 @@ def get_data(filters, conditions):
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
total_row = calculate_total_row(data, conditions["columns"])
|
||||
total_row = calculate_total_row(data, conditions["columns"], conditions.get("company_currency"))
|
||||
data.append(total_row)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def calculate_total_row(data, columns):
|
||||
def calculate_total_row(data, columns, company_currency=None):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
total_values = {}
|
||||
currency_col_idx = None
|
||||
for i, col in enumerate(columns):
|
||||
if "Float" in col or "Currency/currency" in col:
|
||||
# based-on and group-by columns are dicts, periodic and total columns are strings
|
||||
if isinstance(col, dict):
|
||||
if col.get("fieldtype") == "Link" and col.get("options") == "Currency":
|
||||
currency_col_idx = i
|
||||
elif "Float" in col or "Currency/currency" in col:
|
||||
total_values[i] = 0
|
||||
|
||||
for row in data:
|
||||
@@ -254,6 +263,9 @@ def calculate_total_row(data, columns):
|
||||
for i in range(1, len(columns)):
|
||||
total_row.append(total_values.get(i, None))
|
||||
|
||||
if currency_col_idx is not None:
|
||||
total_row[currency_col_idx] = company_currency
|
||||
|
||||
return total_row
|
||||
|
||||
|
||||
|
||||
@@ -279,13 +279,17 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
self.save()
|
||||
|
||||
else:
|
||||
frappe.throw(_("Cannot declare as lost, because Quotation has been made."))
|
||||
frappe.throw(_("Cannot declare as Lost because an active Quotation exists."))
|
||||
|
||||
def has_active_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation",
|
||||
{"opportunity": self.name, "status": ("not in", ["Lost", "Closed"]), "docstatus": 1},
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("not in", ["Lost", "Cancelled", "Expired"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
@@ -294,14 +298,20 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status not in ('Lost', 'Closed')""",
|
||||
and q.status not in ('Lost', 'Cancelled', 'Expired')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def has_ordered_quotation(self):
|
||||
if not self.get("items", []):
|
||||
return frappe.get_all(
|
||||
"Quotation", {"opportunity": self.name, "status": "Ordered", "docstatus": 1}, "name"
|
||||
"Quotation",
|
||||
{
|
||||
"opportunity": self.name,
|
||||
"status": ("in", ["Ordered", "Partially Ordered"]),
|
||||
"docstatus": 1,
|
||||
},
|
||||
"name",
|
||||
)
|
||||
else:
|
||||
return frappe.db.sql(
|
||||
@@ -309,7 +319,7 @@ class Opportunity(TransactionBase, CRMNote):
|
||||
select q.name
|
||||
from `tabQuotation` q, `tabQuotation Item` qi
|
||||
where q.name = qi.parent and q.docstatus=1 and qi.prevdoc_docname =%s
|
||||
and q.status = 'Ordered'""",
|
||||
and q.status in ('Ordered', 'Partially Ordered')""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1003,7 +1003,7 @@ class BOM(WebsiteGenerator):
|
||||
|
||||
for d in self.get("items"):
|
||||
old_rate = d.rate
|
||||
if not self.bom_creator and (d.is_stock_item or d.is_phantom_item):
|
||||
if d.is_stock_item or d.is_phantom_item:
|
||||
d.rate = self.get_rm_rate(
|
||||
{
|
||||
"company": self.company,
|
||||
|
||||
@@ -537,15 +537,8 @@ class BOMCreator(Document):
|
||||
row.delete()
|
||||
updated = True
|
||||
|
||||
items = get_children(parent=kwargs.fg_item, parent_id=self.name)
|
||||
if items:
|
||||
for item in items:
|
||||
updated = True
|
||||
child_row = next((row for row in self.items if row.name == item.name), None)
|
||||
if child_row:
|
||||
child_row.delete()
|
||||
if item.expandable:
|
||||
self.delete_node(fg_item=item.value)
|
||||
if self.delete_child_nodes(kwargs.docname or self.name):
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
self.set_rate_for_items()
|
||||
@@ -555,6 +548,19 @@ class BOMCreator(Document):
|
||||
|
||||
return frappe._dict()
|
||||
|
||||
def delete_child_nodes(self, fg_reference_id: str):
|
||||
deleted = False
|
||||
for item in get_children(parent=fg_reference_id, parent_id=self.name):
|
||||
child_row = next((row for row in self.items if row.name == item.name), None)
|
||||
if child_row:
|
||||
child_row.delete()
|
||||
|
||||
deleted = True
|
||||
if item.expandable:
|
||||
self.delete_child_nodes(item.name)
|
||||
|
||||
return deleted
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_children(doctype: str | None = None, parent: str | None = None, **kwargs):
|
||||
@@ -567,7 +573,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
|
||||
kwargs = frappe._dict(kwargs)
|
||||
|
||||
fields = [
|
||||
"item_code as value",
|
||||
"name as value",
|
||||
"item_name as title",
|
||||
"is_expandable as expandable",
|
||||
"parent as parent_id",
|
||||
@@ -575,6 +581,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
|
||||
"idx",
|
||||
ValueWrapper("BOM Creator Item").as_("doctype"),
|
||||
"name",
|
||||
"item_code",
|
||||
"uom",
|
||||
"rate",
|
||||
"amount",
|
||||
@@ -583,7 +590,7 @@ def get_children(doctype: str | None = None, parent: str | None = None, **kwargs
|
||||
]
|
||||
|
||||
query_filters = {
|
||||
"fg_item": parent,
|
||||
"fg_reference_id": parent,
|
||||
"parent": kwargs.parent_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,26 @@ class TestBOMCreator(ERPNextTestSuite):
|
||||
data = frappe.get_all("BOM", filters={"bom_creator": doc.name, "docstatus": 1})
|
||||
self.assertEqual(len(data), 2)
|
||||
|
||||
def test_repeated_sub_assembly_keeps_own_raw_materials(self):
|
||||
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
|
||||
"Bicycle BOM with Repeated Sub Assembly"
|
||||
)
|
||||
|
||||
self.assertEqual(child_items(doc, doc.name), ["Frame Assembly", "Seat Assembly"])
|
||||
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
|
||||
self.assertEqual(child_items(doc, second_wheel), ["Hub"])
|
||||
|
||||
def test_delete_repeated_sub_assembly_keeps_sibling_raw_materials(self):
|
||||
doc, first_wheel, second_wheel = make_repeated_sub_assembly_bom(
|
||||
"Bicycle BOM with Deleted Sub Assembly"
|
||||
)
|
||||
|
||||
doc.delete_node(doctype="BOM Creator Item", docname=second_wheel)
|
||||
doc.reload()
|
||||
|
||||
self.assertEqual(child_items(doc, first_wheel), ["Rim", "Spokes"])
|
||||
self.assertFalse([row for row in doc.items if row.item_code == "Hub"])
|
||||
|
||||
def test_edit_and_delete_reject_unknown_item(self):
|
||||
final_product = "Bicycle"
|
||||
make_item(
|
||||
@@ -327,6 +347,55 @@ def create_items():
|
||||
)
|
||||
|
||||
|
||||
def make_repeated_sub_assembly_bom(name):
|
||||
"""Bicycle > (Frame Assembly > Wheel Assembly > Rim, Spokes), (Seat Assembly > Wheel Assembly > Hub)"""
|
||||
final_product = "Bicycle"
|
||||
make_item(final_product, {"item_group": "Raw Material", "stock_uom": "Nos"})
|
||||
|
||||
doc = make_bom_creator(
|
||||
name=name,
|
||||
company="_Test Company",
|
||||
item_code=final_product,
|
||||
qty=1,
|
||||
rm_cosy_as_per="Valuation Rate",
|
||||
currency="INR",
|
||||
plc_conversion_rate=1,
|
||||
conversion_rate=1,
|
||||
)
|
||||
|
||||
def add_sub_assembly(fg_item, fg_reference_id, item_code, raw_materials):
|
||||
doc.add_sub_assembly(
|
||||
fg_item=fg_item,
|
||||
fg_reference_id=fg_reference_id,
|
||||
bom_item={
|
||||
"item_code": item_code,
|
||||
"qty": 1,
|
||||
"items": [{"item_code": item, "qty": 1} for item in raw_materials],
|
||||
},
|
||||
)
|
||||
doc.reload()
|
||||
|
||||
return next(
|
||||
row.name
|
||||
for row in doc.items
|
||||
if row.item_code == item_code and row.fg_reference_id == fg_reference_id
|
||||
)
|
||||
|
||||
frame = add_sub_assembly(final_product, doc.name, "Frame Assembly", ["Frame"])
|
||||
first_wheel = add_sub_assembly("Frame Assembly", frame, "Wheel Assembly", ["Rim", "Spokes"])
|
||||
|
||||
seat = add_sub_assembly(final_product, doc.name, "Seat Assembly", ["Seat"])
|
||||
second_wheel = add_sub_assembly("Seat Assembly", seat, "Wheel Assembly", ["Hub"])
|
||||
|
||||
return doc, first_wheel, second_wheel
|
||||
|
||||
|
||||
def child_items(doc, parent):
|
||||
from erpnext.manufacturing.doctype.bom_creator.bom_creator import get_children
|
||||
|
||||
return sorted(row.item_code for row in get_children(parent=parent, parent_id=doc.name))
|
||||
|
||||
|
||||
def make_bom_creator(**kwargs):
|
||||
if isinstance(kwargs, str) or isinstance(kwargs, dict):
|
||||
kwargs = frappe.parse_json(kwargs)
|
||||
|
||||
@@ -981,18 +981,31 @@ class JobCard(Document):
|
||||
self.update_work_order_data(for_quantity, process_loss_qty, pending_qty, time_in_mins, wo)
|
||||
|
||||
def update_semi_finished_good_details(self):
|
||||
if self.operation_id:
|
||||
qty = max(flt(self.manufactured_qty), flt(self.total_completed_qty))
|
||||
if not self.operation_id:
|
||||
return
|
||||
|
||||
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", qty)
|
||||
if (
|
||||
self.finished_good
|
||||
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
|
||||
== self.finished_good
|
||||
):
|
||||
_wo_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
_wo_doc.db_set("produced_qty", self.manufactured_qty)
|
||||
_wo_doc.db_set("status", _wo_doc.get_status())
|
||||
job_cards = frappe.get_all(
|
||||
"Job Card",
|
||||
filters={
|
||||
"work_order": self.work_order,
|
||||
"operation_id": self.operation_id,
|
||||
"docstatus": 1,
|
||||
"is_corrective_job_card": 0,
|
||||
},
|
||||
fields=["manufactured_qty", "total_completed_qty"],
|
||||
)
|
||||
|
||||
completed_qty = sum(max(flt(row.manufactured_qty), flt(row.total_completed_qty)) for row in job_cards)
|
||||
|
||||
frappe.db.set_value("Work Order Operation", self.operation_id, "completed_qty", completed_qty)
|
||||
if (
|
||||
self.finished_good
|
||||
and frappe.get_cached_value("Work Order", self.work_order, "production_item")
|
||||
== self.finished_good
|
||||
):
|
||||
_wo_doc = frappe.get_doc("Work Order", self.work_order)
|
||||
_wo_doc.db_set("produced_qty", sum(flt(row.manufactured_qty) for row in job_cards))
|
||||
_wo_doc.db_set("status", _wo_doc.get_status())
|
||||
|
||||
def update_corrective_in_work_order(self, wo):
|
||||
wo.corrective_operation_cost = 0.0
|
||||
|
||||
@@ -1162,6 +1162,109 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(manufacturing_entry.items[2].qty, 9)
|
||||
self.assertEqual(flt(manufacturing_entry.items[2].basic_rate, 3), 5.278)
|
||||
|
||||
def test_semi_fg_produced_qty_across_split_job_cards(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_job_card
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Split JC RM 1", {"is_stock_item": 1}).name
|
||||
fg = make_item("Split JC FG 1", {"is_stock_item": 1}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
|
||||
operation = {
|
||||
"operation": "Split JC Op A",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=8,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
|
||||
)
|
||||
job_card.for_quantity = 5
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 5)
|
||||
|
||||
make_job_card(
|
||||
work_order.name,
|
||||
[
|
||||
{
|
||||
"name": work_order.operations[0].name,
|
||||
"operation": "Split JC Op A",
|
||||
"qty": 3,
|
||||
"pending_qty": 3,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
|
||||
)
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{
|
||||
"from_time": "2024-02-02 08:00:00",
|
||||
"to_time": "2024-02-02 09:00:00",
|
||||
"completed_qty": job_card.for_quantity,
|
||||
},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(flt(work_order.produced_qty), 8)
|
||||
self.assertEqual(work_order.status, "Completed")
|
||||
self.assertEqual(
|
||||
flt(frappe.db.get_value("Work Order Operation", work_order.operations[0].name, "completed_qty")),
|
||||
8,
|
||||
)
|
||||
|
||||
def test_semi_fg_batch_auto_pull_on_manufacture(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -32,6 +32,7 @@ from erpnext.manufacturing.doctype.bom.bom import get_children as get_bom_childr
|
||||
from erpnext.manufacturing.doctype.bom.bom import validate_bom_no
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_item_details
|
||||
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
|
||||
from erpnext.stock.doctype.item.item import get_uom_conv_factor
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import StockReservation
|
||||
from erpnext.stock.get_item_details import get_conversion_factor
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
@@ -1333,9 +1334,16 @@ def get_exploded_items(item_details, company, bom_no, include_non_stock_items, p
|
||||
|
||||
|
||||
def get_uom_conversion_factor(item_code, uom):
|
||||
return frappe.db.get_value(
|
||||
item = frappe.get_cached_value("Item", item_code, ["variant_of", "stock_uom"], as_dict=True)
|
||||
conversion_factor = frappe.db.get_value(
|
||||
"UOM Conversion Detail", {"parent": item_code, "uom": uom}, "conversion_factor"
|
||||
)
|
||||
if not conversion_factor and item.variant_of:
|
||||
conversion_factor = frappe.db.get_value(
|
||||
"UOM Conversion Detail", {"parent": item.variant_of, "uom": uom}, "conversion_factor"
|
||||
)
|
||||
|
||||
return conversion_factor or get_uom_conv_factor(uom, item.stock_uom)
|
||||
|
||||
|
||||
def get_subitems(
|
||||
|
||||
@@ -1730,6 +1730,118 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
self.assertTrue(row.warehouse == mrp_warhouse)
|
||||
self.assertEqual(row.quantity, 12.0)
|
||||
|
||||
def test_purchase_uom_falls_back_to_uom_conversion_factor(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
if not frappe.db.exists("UOM Conversion Factor", {"from_uom": "Kg", "to_uom": "Gram"}):
|
||||
frappe.get_doc(
|
||||
doctype="UOM Conversion Factor",
|
||||
category="Mass",
|
||||
from_uom="Kg",
|
||||
to_uom="Gram",
|
||||
value=1000,
|
||||
).insert()
|
||||
|
||||
rm = make_item("Test RM Item Global CF", {"is_stock_item": 1, "stock_uom": "Gram"})
|
||||
rm.purchase_uom = "Kg"
|
||||
rm.save()
|
||||
self.assertFalse([row for row in rm.uoms if row.uom == "Kg"])
|
||||
|
||||
bom_tree = {"Test FG Item Global CF": {rm.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=2000,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
row = frappe._dict(next(item for item in items if item["item_code"] == rm.name))
|
||||
self.assertEqual(row.uom, "Kg")
|
||||
self.assertEqual(row.conversion_factor, 1000)
|
||||
self.assertEqual(row.quantity, 2)
|
||||
|
||||
def test_variant_inherits_purchase_uom_conversion_factor_of_template(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
template = make_item(
|
||||
"TRMVCF",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"stock_uom": "Nos",
|
||||
"has_variants": 1,
|
||||
"attributes": [{"attribute": "Colour"}],
|
||||
},
|
||||
)
|
||||
if not [row for row in template.uoms if row.uom == "Box"]:
|
||||
template.purchase_uom = "Box"
|
||||
template.append("uoms", {"uom": "Box", "conversion_factor": 12})
|
||||
template.save()
|
||||
|
||||
if not frappe.db.exists("Item", "TRMVCF-RED"):
|
||||
create_variant("TRMVCF", {"Colour": "Red"}).insert()
|
||||
|
||||
variant = frappe.get_doc("Item", "TRMVCF-RED")
|
||||
variant.uoms = [row for row in variant.uoms if row.uom != "Box"]
|
||||
variant.purchase_uom = "Box"
|
||||
variant.save()
|
||||
|
||||
bom_tree = {"Test FG Item Variant CF": {variant.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=24,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
items = get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
row = frappe._dict(next(item for item in items if item["item_code"] == variant.name))
|
||||
self.assertEqual(row.conversion_factor, 12)
|
||||
self.assertEqual(row.quantity, 2)
|
||||
|
||||
def test_missing_purchase_uom_conversion_factor_throws(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
|
||||
rm = make_item("Test RM Item Missing CF", {"is_stock_item": 1, "stock_uom": "Nos"})
|
||||
rm.purchase_uom = "Box"
|
||||
rm.save()
|
||||
|
||||
bom_tree = {"Test FG Item Missing CF": {rm.name: {}}}
|
||||
parent_bom = create_nested_bom(bom_tree, prefix="")
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=parent_bom.item,
|
||||
planned_qty=10,
|
||||
ignore_existing_ordered_qty=1,
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
warehouse="_Test Warehouse - _TC",
|
||||
)
|
||||
plan.for_warehouse = "_Test Warehouse - _TC"
|
||||
|
||||
with self.assertRaises(frappe.ValidationError) as error:
|
||||
get_items_for_material_requests(
|
||||
plan.as_dict(), warehouses=[{"warehouse": "_Test Warehouse - _TC"}]
|
||||
)
|
||||
|
||||
self.assertIn("UOM Conversion factor", str(error.exception))
|
||||
|
||||
def test_mr_qty_for_complex_bom(self):
|
||||
from erpnext.manufacturing.doctype.bom.test_bom import create_nested_bom
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
@@ -79,6 +79,11 @@ frappe.ui.form.on("BOM Operation", {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
|
||||
hour_rate: function (frm, cdt, cdn) {
|
||||
const d = locals[cdt][cdn];
|
||||
frm.events.calculate_operating_cost(frm, d);
|
||||
},
|
||||
});
|
||||
|
||||
frappe.tour["Routing"] = [
|
||||
|
||||
@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
bom_no: str,
|
||||
item: str,
|
||||
qty: float = 0,
|
||||
company: str | None = None,
|
||||
project: str | None = None,
|
||||
variant_items: str | list | None = None,
|
||||
use_multi_level_bom: bool | None = None,
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
@@ -2563,7 +2569,8 @@ def make_work_order(
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
# selected BOM already belongs to this variant — keep it
|
||||
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
|
||||
@@ -80,7 +80,7 @@ class TestWorkstation(ERPNextTestSuite):
|
||||
|
||||
test_routing_operations = [
|
||||
{"operation": "Test Operation A", "workstation": "_Test Workstation A", "time_in_mins": 60},
|
||||
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 60},
|
||||
{"operation": "Test Operation B", "workstation": "_Test Workstation A", "time_in_mins": 30},
|
||||
]
|
||||
routing_doc = create_routing(routing_name="Routing Test", operations=test_routing_operations)
|
||||
bom_doc = setup_bom(item_code="_Testing Item", routing=routing_doc.name, currency="INR")
|
||||
@@ -110,6 +110,17 @@ class TestWorkstation(ERPNextTestSuite):
|
||||
self.assertEqual(bom_doc.operations[0].hour_rate, 250)
|
||||
self.assertEqual(bom_doc.operations[1].hour_rate, 250)
|
||||
|
||||
# hour_rate propagation must also refresh operating_cost (hour_rate * time_in_mins / 60)
|
||||
# on the Routing's BOM Operation rows; the 30-min op exercises the arithmetic.
|
||||
for operation, expected_operating_cost in (("Test Operation A", 250), ("Test Operation B", 125)):
|
||||
hour_rate, operating_cost = frappe.db.get_value(
|
||||
"BOM Operation",
|
||||
{"parent": routing_doc.name, "parenttype": "Routing", "operation": operation},
|
||||
["hour_rate", "operating_cost"],
|
||||
)
|
||||
self.assertEqual(hour_rate, 250)
|
||||
self.assertEqual(operating_cost, expected_operating_cost)
|
||||
|
||||
|
||||
def make_workstation(*args, **kwargs):
|
||||
args = args if args else kwargs
|
||||
|
||||
@@ -195,9 +195,10 @@ class Workstation(Document):
|
||||
|
||||
for bom_no in bom_list:
|
||||
frappe.db.sql(
|
||||
"""update `tabBOM Operation` set hour_rate = %s
|
||||
"""update `tabBOM Operation`
|
||||
set hour_rate = %s, operating_cost = %s * time_in_mins / 60
|
||||
where parent = %s and workstation = %s""",
|
||||
(self.hour_rate, bom_no[0], self.name),
|
||||
(self.hour_rate, self.hour_rate, bom_no[0], self.name),
|
||||
)
|
||||
|
||||
def validate_workstation_holiday(self, schedule_date, skip_holiday_list_check=False):
|
||||
|
||||
@@ -491,3 +491,6 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||
erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.enable_book_stock_expense_gl_entries
|
||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v16_0.fix_subcontracting_titles
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
has_expense_accounts = frappe.db.exists(
|
||||
"Company", {"purchase_expense_account": ("is", "set")}
|
||||
) or frappe.db.exists("Item Default", {"purchase_expense_account": ("is", "set")})
|
||||
|
||||
if has_expense_accounts:
|
||||
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)
|
||||
28
erpnext/patches/v16_0/fix_subcontracting_titles.py
Normal file
28
erpnext/patches/v16_0/fix_subcontracting_titles.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
This patch corrects the titles of the subcontracting order doctypes set to
|
||||
the text strings "{customer_name}" or "{supplier_name}" instead of the
|
||||
actual customer or supplier name.
|
||||
|
||||
Their `title_field` never pointed at `title`, so the template default was
|
||||
stored verbatim instead of being substituted.
|
||||
"""
|
||||
|
||||
party_fields = {
|
||||
"Subcontracting Order": "supplier_name",
|
||||
"Subcontracting Inward Order": "customer_name",
|
||||
}
|
||||
|
||||
for doctype, party_field in party_fields.items():
|
||||
if not frappe.db.has_column(doctype, "title"):
|
||||
continue
|
||||
|
||||
table = frappe.qb.DocType(doctype)
|
||||
(
|
||||
frappe.qb.update(table)
|
||||
.set(table.title, table[party_field])
|
||||
.where(table.title == f"{{{party_field}}}")
|
||||
).run()
|
||||
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
45
erpnext/patches/v16_0/rename_ar_ap_ageing_filter.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import frappe
|
||||
|
||||
REPORTS = (
|
||||
"Accounts Receivable",
|
||||
"Accounts Payable",
|
||||
"Accounts Receivable Summary",
|
||||
"Accounts Payable Summary",
|
||||
)
|
||||
|
||||
|
||||
def execute():
|
||||
# filter `calculate_ageing_with` -> `age_as_on`, option "Today Date" -> "Today"
|
||||
_migrate("Auto Email Report", "filters", "report")
|
||||
_migrate("Dashboard Chart", "filters_json", "report_name", type_field="chart_type")
|
||||
_migrate("Number Card", "filters_json", "report_name", type_field="type")
|
||||
|
||||
|
||||
def _migrate(doctype, filter_field, report_field, type_field=None):
|
||||
conditions = {report_field: ("in", REPORTS)}
|
||||
if type_field:
|
||||
conditions[type_field] = "Report"
|
||||
|
||||
for row in frappe.get_all(doctype, filters=conditions, fields=["name", filter_field]):
|
||||
updated = _rewrite(row.get(filter_field))
|
||||
if updated is not None:
|
||||
frappe.db.set_value(doctype, row.name, filter_field, updated, update_modified=False)
|
||||
|
||||
|
||||
def _rewrite(raw):
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
try:
|
||||
filters = frappe.parse_json(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
if not isinstance(filters, dict) or "calculate_ageing_with" not in filters:
|
||||
return None
|
||||
|
||||
filters["age_as_on"] = filters.pop("calculate_ageing_with")
|
||||
if filters["age_as_on"] == "Today Date":
|
||||
filters["age_as_on"] = "Today"
|
||||
|
||||
return frappe.as_json(filters, indent=None)
|
||||
@@ -57,6 +57,7 @@ class BOMConfigurator {
|
||||
breadcrumb: "Manufacturing",
|
||||
get_tree_nodes: "erpnext.manufacturing.doctype.bom_creator.bom_creator.get_children",
|
||||
root_label: this.frm.doc.item_code,
|
||||
get_label: (node) => this.get_node_label(node),
|
||||
disable_add_node: true,
|
||||
get_tree_root: false,
|
||||
show_expand_all: false,
|
||||
@@ -66,6 +67,23 @@ class BOMConfigurator {
|
||||
};
|
||||
}
|
||||
|
||||
get_node_label(node) {
|
||||
const item_code = this.get_item_code(node);
|
||||
const item_name = node.data?.title || item_code;
|
||||
|
||||
if (item_name === item_code) {
|
||||
return frappe.utils.escape_html(item_code);
|
||||
}
|
||||
|
||||
return `${frappe.utils.escape_html(item_name)} <span class='text-muted'>(${frappe.utils.escape_html(
|
||||
item_code
|
||||
)})</span>`;
|
||||
}
|
||||
|
||||
get_item_code(node) {
|
||||
return node.data?.item_code || this.frm.doc.item_code;
|
||||
}
|
||||
|
||||
tree_methods() {
|
||||
let frm_obj = this;
|
||||
let view = frappe.views.trees["BOM Configurator"];
|
||||
@@ -73,7 +91,8 @@ class BOMConfigurator {
|
||||
return {
|
||||
onload: function (me) {
|
||||
me.args["parent_id"] = frm_obj.frm.doc.name;
|
||||
me.args["parent"] = frm_obj.frm.doc.item_code;
|
||||
me.args["parent"] = frm_obj.frm.doc.name;
|
||||
me.root_value = frm_obj.frm.doc.name;
|
||||
me.parent = frm_obj.$wrapper.get(0);
|
||||
me.body = frm_obj.$wrapper.get(0);
|
||||
me.make_tree();
|
||||
@@ -83,7 +102,7 @@ class BOMConfigurator {
|
||||
const uom = node.data.uom || frm_obj.frm.doc.uom;
|
||||
const docname = node.data.name || frm_obj.frm.doc.name;
|
||||
let amount = node.data.amount;
|
||||
if (node.data.value === frm_obj.frm.doc.item_code) {
|
||||
if (node.is_root) {
|
||||
amount = frm_obj.frm.doc.raw_material_cost;
|
||||
}
|
||||
|
||||
@@ -243,7 +262,7 @@ class BOMConfigurator {
|
||||
method: "add_item",
|
||||
doc: this.frm.doc,
|
||||
args: {
|
||||
fg_item: node.data.value,
|
||||
fg_item: this.get_item_code(node),
|
||||
item_code: data.item_code,
|
||||
fg_reference_id: node.data.name || this.frm.doc.name,
|
||||
qty: data.qty,
|
||||
@@ -298,7 +317,7 @@ class BOMConfigurator {
|
||||
method: "add_sub_assembly",
|
||||
doc: this.frm.doc,
|
||||
args: {
|
||||
fg_item: node.data.value,
|
||||
fg_item: this.get_item_code(node),
|
||||
fg_reference_id: node.data.name || this.frm.doc.name,
|
||||
bom_item: bom_item,
|
||||
operation: node.data.operation,
|
||||
@@ -417,7 +436,7 @@ class BOMConfigurator {
|
||||
});
|
||||
|
||||
dialog.set_values({
|
||||
item_code: node.data.value,
|
||||
item_code: this.get_item_code(node),
|
||||
qty: node.data.qty,
|
||||
});
|
||||
|
||||
@@ -445,7 +464,7 @@ class BOMConfigurator {
|
||||
method: "add_sub_assembly",
|
||||
doc: this.frm.doc,
|
||||
args: {
|
||||
fg_item: node.data.value,
|
||||
fg_item: this.get_item_code(node),
|
||||
bom_item: bom_item,
|
||||
fg_reference_id: node.data.name || this.frm.doc.name,
|
||||
convert_to_sub_assembly: true,
|
||||
@@ -482,7 +501,6 @@ class BOMConfigurator {
|
||||
method: "delete_node",
|
||||
doc: this.frm.doc,
|
||||
args: {
|
||||
fg_item: node.data.value,
|
||||
doctype: node.data.doctype,
|
||||
docname: node.data.name,
|
||||
},
|
||||
|
||||
@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
|
||||
}
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
this.set_route_options_for_new_doc();
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating a Batch or Serial and Batch Bundle from the link
|
||||
// field, copy details from the line item to the new form
|
||||
if (!this.frm.fields_dict.items) return;
|
||||
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
barcode(doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
if (row.barcode) {
|
||||
|
||||
@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
erpnext.toggle_serial_batch_fields(this.frm);
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating the batch from the link field, copy item from line item to batch form
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
|
||||
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_barcode() {
|
||||
frappe.flags.dialog_set = false;
|
||||
this.barcode_scanner.process_scan();
|
||||
|
||||
@@ -41,6 +41,26 @@ erpnext.financial_statements = {
|
||||
_is_special_view: function (column, data) {
|
||||
if (!data) return false;
|
||||
const view = get_filter_value("selected_view");
|
||||
|
||||
if (!["Growth", "Margin"].includes(view)) return false;
|
||||
|
||||
if (get_filter_value("report_template")) {
|
||||
const columnInfo = erpnext.financial_statements._parse_column_info(column.fieldname, data);
|
||||
// Account column
|
||||
if (columnInfo.isAccount) return false;
|
||||
|
||||
const periodKeys = data._segment_info?.period_keys || [];
|
||||
|
||||
if (!periodKeys.includes(columnInfo.fieldname)) return false;
|
||||
|
||||
if (view === "Growth") {
|
||||
// First period of new segment
|
||||
if (periodKeys[0] === columnInfo.fieldname) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return (view === "Growth" && column.colIndex >= 3) || (view === "Margin" && column.colIndex >= 2);
|
||||
},
|
||||
|
||||
|
||||
@@ -475,10 +475,10 @@
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"description": "Transactions are blocked or warned when outstanding balance exceeds this amount.",
|
||||
"description": "Transactions are blocked when the outstanding balance exceeds the credit limit. When Restrict Customer Over Billing is enabled, new invoices are also blocked when the customer's overdue amount exceeds the Overdue Limit.",
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ from frappe.model.mapper import get_mapped_doc
|
||||
from frappe.model.naming import set_name_by_naming_series, set_name_from_naming_options
|
||||
from frappe.model.utils.rename_doc import update_linked_doctypes
|
||||
from frappe.query_builder import Field, functions
|
||||
from frappe.utils import cint, cstr, flt, get_formatted_email, today
|
||||
from frappe.utils import cint, cstr, flt, fmt_money, get_formatted_email, getdate, today
|
||||
from frappe.utils.user import get_users_with_role
|
||||
|
||||
from erpnext.accounts.party import (
|
||||
@@ -204,17 +204,21 @@ class Customer(TransactionBase):
|
||||
self.credit_limits = []
|
||||
self.payment_terms = self.default_price_list = ""
|
||||
|
||||
tables = [["accounts", "account"], ["credit_limits", "credit_limit"]]
|
||||
tables = [
|
||||
["accounts", ["account"]],
|
||||
["credit_limits", ["credit_limit", "overdue_billing_threshold"]],
|
||||
]
|
||||
fields = ["payment_terms", "default_price_list"]
|
||||
|
||||
for row in tables:
|
||||
table, field = row[0], row[1]
|
||||
table, table_fields = row[0], row[1]
|
||||
if not doc.get(table):
|
||||
continue
|
||||
|
||||
for entry in doc.get(table):
|
||||
child = self.append(table)
|
||||
child.update({"company": entry.company, field: entry.get(field)})
|
||||
child.update({"company": entry.company})
|
||||
child.update({field: entry.get(field) for field in table_fields})
|
||||
|
||||
for field in fields:
|
||||
if not doc.get(field):
|
||||
@@ -403,6 +407,9 @@ class Customer(TransactionBase):
|
||||
else:
|
||||
company_record.append(limit.company)
|
||||
|
||||
if not flt(limit.credit_limit):
|
||||
continue
|
||||
|
||||
outstanding_amt = get_customer_outstanding(
|
||||
self.name, limit.company, ignore_outstanding_sales_order=limit.bypass_credit_limit_check
|
||||
)
|
||||
@@ -674,6 +681,124 @@ def send_emails(customer, customer_outstanding, credit_limit, credit_controller_
|
||||
frappe.sendmail(recipients=credit_controller_users_list, subject=subject, message=message)
|
||||
|
||||
|
||||
def check_overdue_billing_threshold(customer: str, company: str) -> None:
|
||||
if not frappe.get_single_value("Accounts Settings", "enable_overdue_billing_threshold"):
|
||||
return
|
||||
|
||||
threshold = get_overdue_billing_threshold(customer, company)
|
||||
if not threshold:
|
||||
return
|
||||
|
||||
overdue_amount = get_customer_overdue_amount(customer, company)
|
||||
if overdue_amount <= threshold:
|
||||
return
|
||||
|
||||
bypass_role = frappe.get_single_value("Accounts Settings", "role_allowed_to_bypass_overdue_billing")
|
||||
if bypass_role and bypass_role in frappe.get_roles():
|
||||
return
|
||||
|
||||
company_currency = frappe.get_cached_value("Company", company, "default_currency")
|
||||
frappe.throw(
|
||||
_("Overdue Limit crossed for customer {0}. Overdue amount {1} exceeds the allowed limit {2}.").format(
|
||||
customer,
|
||||
fmt_money(overdue_amount, currency=company_currency),
|
||||
fmt_money(threshold, currency=company_currency),
|
||||
),
|
||||
title=_("Overdue Limit Crossed"),
|
||||
)
|
||||
|
||||
|
||||
def get_overdue_billing_threshold(customer: str, company: str) -> float:
|
||||
"""Overdue limit set on the customer, falling back to its customer group."""
|
||||
threshold = frappe.db.get_value(
|
||||
"Customer Credit Limit",
|
||||
{"parent": customer, "parenttype": "Customer", "company": company},
|
||||
"overdue_billing_threshold",
|
||||
)
|
||||
|
||||
if not threshold:
|
||||
customer_group = frappe.get_cached_value("Customer", customer, "customer_group")
|
||||
threshold = frappe.db.get_value(
|
||||
"Customer Credit Limit",
|
||||
{"parent": customer_group, "parenttype": "Customer Group", "company": company},
|
||||
"overdue_billing_threshold",
|
||||
)
|
||||
|
||||
return flt(threshold)
|
||||
|
||||
|
||||
def get_customer_overdue_amount(customer: str, company: str) -> float:
|
||||
"""Amount the customer owes past its due date, in company currency.
|
||||
|
||||
Follows the same rule as the Overdue invoice status, so a customer is only
|
||||
blocked for what the invoice list already shows as overdue.
|
||||
"""
|
||||
invoices = get_outstanding_invoices_for_customer(customer, company)
|
||||
if not invoices:
|
||||
return 0.0
|
||||
|
||||
payable_amounts = get_past_due_payable_amounts([d.name for d in invoices])
|
||||
return flt(sum(get_overdue_portion(d, payable_amounts.get(d.name)) for d in invoices))
|
||||
|
||||
|
||||
def get_outstanding_invoices_for_customer(customer: str, company: str) -> list[frappe._dict]:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
gl_entry = frappe.qb.DocType("GL Entry")
|
||||
sales_invoice = frappe.qb.DocType("Sales Invoice")
|
||||
|
||||
# debit - credit is always booked in company currency, so this is comparable to the overdue limit
|
||||
outstanding = Sum(gl_entry.debit) - Sum(gl_entry.credit)
|
||||
|
||||
return (
|
||||
frappe.qb.from_(gl_entry)
|
||||
.inner_join(sales_invoice)
|
||||
.on(sales_invoice.name == gl_entry.against_voucher)
|
||||
.select(
|
||||
sales_invoice.name,
|
||||
sales_invoice.due_date,
|
||||
sales_invoice.base_grand_total,
|
||||
outstanding.as_("outstanding"),
|
||||
)
|
||||
.where(gl_entry.party_type == "Customer")
|
||||
.where(gl_entry.party == customer)
|
||||
.where(gl_entry.company == company)
|
||||
.where(gl_entry.is_cancelled == 0)
|
||||
.where(gl_entry.against_voucher_type == "Sales Invoice")
|
||||
.groupby(sales_invoice.name, sales_invoice.due_date, sales_invoice.base_grand_total)
|
||||
.having(outstanding > 0)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
def get_past_due_payable_amounts(invoices: list[str]) -> dict[str, float]:
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
payment_schedule = frappe.qb.DocType("Payment Schedule")
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(payment_schedule)
|
||||
.select(payment_schedule.parent, Sum(payment_schedule.base_payment_amount).as_("payable"))
|
||||
.where(payment_schedule.parenttype == "Sales Invoice")
|
||||
.where(payment_schedule.parent.isin(invoices))
|
||||
.where(payment_schedule.due_date < getdate())
|
||||
.groupby(payment_schedule.parent)
|
||||
).run(as_dict=True)
|
||||
|
||||
return {d.parent: flt(d.payable) for d in rows}
|
||||
|
||||
|
||||
def get_overdue_portion(invoice: frappe._dict, payable_amount: float | None) -> float:
|
||||
outstanding = flt(invoice.outstanding)
|
||||
|
||||
# No payable amount means either a schedule-less invoice (POS, opening) or one whose terms are
|
||||
# all still in the future. Both are answered by the invoice due date, which is the last term.
|
||||
if payable_amount is None:
|
||||
return outstanding if invoice.due_date and getdate(invoice.due_date) < getdate() else 0.0
|
||||
|
||||
paid = flt(invoice.base_grand_total) - outstanding
|
||||
return min(max(payable_amount - paid, 0.0), outstanding)
|
||||
|
||||
|
||||
def get_customer_outstanding(customer, company, ignore_outstanding_sales_order=False, cost_center=None):
|
||||
# Outstanding based on GL Entries
|
||||
cond = ""
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.utils import flt, nowdate
|
||||
from frappe.utils import add_days, flt, getdate, nowdate
|
||||
|
||||
from erpnext.accounts.party import get_due_date
|
||||
from erpnext.exceptions import PartyDisabled, PartyFrozen
|
||||
from erpnext.selling.doctype.customer.customer import (
|
||||
get_credit_limit,
|
||||
get_customer_outstanding,
|
||||
get_customer_overdue_amount,
|
||||
get_overdue_billing_threshold,
|
||||
make_quotation,
|
||||
parse_full_name,
|
||||
)
|
||||
@@ -77,7 +79,11 @@ class TestCustomer(ERPNextTestSuite):
|
||||
"company": "_Test Company",
|
||||
"account": "Creditors - _TC",
|
||||
}
|
||||
test_credit_limits = {"company": "_Test Company", "credit_limit": 350000}
|
||||
test_credit_limits = {
|
||||
"company": "_Test Company",
|
||||
"credit_limit": 350000,
|
||||
"overdue_billing_threshold": 5000,
|
||||
}
|
||||
doc.append("accounts", test_account_details)
|
||||
doc.append("credit_limits", test_credit_limits)
|
||||
doc.insert()
|
||||
@@ -97,6 +103,7 @@ class TestCustomer(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(c_doc.credit_limits[0].company, "_Test Company")
|
||||
self.assertEqual(c_doc.credit_limits[0].credit_limit, 350000)
|
||||
self.assertEqual(c_doc.credit_limits[0].overdue_billing_threshold, 5000)
|
||||
c_doc.delete()
|
||||
doc.delete()
|
||||
|
||||
@@ -352,6 +359,139 @@ class TestCustomer(ERPNextTestSuite):
|
||||
)
|
||||
self.assertRaises(frappe.ValidationError, customer.save)
|
||||
|
||||
def test_get_customer_overdue_amount(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
# a past-due, unpaid invoice adds its outstanding to the overdue amount
|
||||
create_sales_invoice(qty=1, rate=500, posting_date=add_days(nowdate(), -30))
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
|
||||
|
||||
# an invoice due today (not yet past due) does not
|
||||
create_sales_invoice(qty=1, rate=700, posting_date=nowdate())
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 500)
|
||||
|
||||
def test_get_customer_overdue_amount_is_in_company_currency(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer USD", "_Test Company")
|
||||
|
||||
# 100 USD at a conversion rate of 50 must be counted as 5000 in company currency
|
||||
create_sales_invoice(
|
||||
customer="_Test Customer USD",
|
||||
debit_to="_Test Receivable USD - _TC",
|
||||
currency="USD",
|
||||
conversion_rate=50,
|
||||
qty=1,
|
||||
rate=100,
|
||||
posting_date=add_days(nowdate(), -30),
|
||||
)
|
||||
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer USD", "_Test Company"), baseline + 5000)
|
||||
|
||||
def test_get_customer_overdue_amount_follows_payment_terms(self):
|
||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
def make_invoice_with_terms():
|
||||
si = create_sales_invoice(
|
||||
qty=1, rate=1200, posting_date=add_days(nowdate(), -60), do_not_save=True
|
||||
)
|
||||
si.append("payment_schedule", {"due_date": add_days(nowdate(), -60), "invoice_portion": 50})
|
||||
si.append("payment_schedule", {"due_date": add_days(nowdate(), 30), "invoice_portion": 50})
|
||||
si.insert()
|
||||
si.submit()
|
||||
return si
|
||||
|
||||
baseline = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
# only the term that has fallen due counts, not the whole 1200 balance. The invoice due_date
|
||||
# is the last term (in 30 days), so this is only caught by reading the payment schedule.
|
||||
si = make_invoice_with_terms()
|
||||
self.assertEqual(getdate(si.due_date), getdate(add_days(nowdate(), 30)))
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline + 600)
|
||||
|
||||
# paying off the past-due term clears the overdue amount
|
||||
pe = get_payment_entry("Sales Invoice", si.name, bank_account="_Test Bank - _TC")
|
||||
pe.reference_no = "_Test Overdue Payment"
|
||||
pe.reference_date = nowdate()
|
||||
pe.paid_amount = pe.received_amount = 600
|
||||
pe.references[0].allocated_amount = 600
|
||||
pe.insert()
|
||||
pe.submit()
|
||||
self.assertEqual(get_customer_overdue_amount("_Test Customer", "_Test Company"), baseline)
|
||||
|
||||
def test_overdue_billing_threshold_on_submit(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
create_sales_invoice(qty=1, rate=1000, posting_date=add_days(nowdate(), -30))
|
||||
overdue = get_customer_overdue_amount("_Test Customer", "_Test Company")
|
||||
|
||||
settings = frappe.get_single("Accounts Settings")
|
||||
original_enable = settings.enable_overdue_billing_threshold
|
||||
original_bypass_role = settings.role_allowed_to_bypass_overdue_billing
|
||||
try:
|
||||
settings.enable_overdue_billing_threshold = 1
|
||||
settings.role_allowed_to_bypass_overdue_billing = None
|
||||
settings.save()
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", overdue - 100)
|
||||
|
||||
# overdue is over the threshold and the user has no bypass role -> blocked
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
self.assertRaises(frappe.ValidationError, si.submit)
|
||||
|
||||
# a user holding the bypass role can still submit
|
||||
settings.role_allowed_to_bypass_overdue_billing = "Accounts Manager"
|
||||
settings.save()
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
si.submit()
|
||||
self.assertEqual(si.docstatus, 1)
|
||||
|
||||
# threshold still crossed, but the feature is off -> never blocked
|
||||
settings.enable_overdue_billing_threshold = 0
|
||||
settings.role_allowed_to_bypass_overdue_billing = None
|
||||
settings.save()
|
||||
si = create_sales_invoice(do_not_submit=True)
|
||||
si.submit()
|
||||
self.assertEqual(si.docstatus, 1)
|
||||
finally:
|
||||
settings.enable_overdue_billing_threshold = original_enable
|
||||
settings.role_allowed_to_bypass_overdue_billing = original_bypass_role
|
||||
settings.save()
|
||||
|
||||
def test_overdue_billing_threshold_falls_back_to_customer_group(self):
|
||||
customer_group = frappe.get_cached_value("Customer", "_Test Customer", "customer_group")
|
||||
group = frappe.get_doc("Customer Group", customer_group)
|
||||
group.credit_limits = []
|
||||
group.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 5000})
|
||||
group.save()
|
||||
|
||||
# the customer has no threshold of its own, so the group's applies
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
|
||||
|
||||
# a threshold on the customer wins over the group
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", 2000)
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 2000)
|
||||
|
||||
# a 0 on the customer inherits the group's limit
|
||||
set_overdue_billing_threshold("_Test Customer", "_Test Company", 0)
|
||||
self.assertEqual(get_overdue_billing_threshold("_Test Customer", "_Test Company"), 5000)
|
||||
|
||||
def test_overdue_threshold_row_without_credit_limit(self):
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
|
||||
# outstanding must be > 0 so a 0 credit_limit would previously trip the check
|
||||
create_sales_invoice(qty=1, rate=500)
|
||||
|
||||
customer = frappe.get_doc("Customer", "_Test Customer")
|
||||
customer.credit_limits = []
|
||||
customer.append("credit_limits", {"company": "_Test Company", "overdue_billing_threshold": 1000})
|
||||
customer.save()
|
||||
|
||||
self.assertEqual(customer.credit_limits[0].overdue_billing_threshold, 1000)
|
||||
self.assertEqual(flt(customer.credit_limits[0].credit_limit), 0.0)
|
||||
|
||||
def test_customer_payment_terms(self):
|
||||
frappe.db.set_value(
|
||||
"Customer", "_Test Customer With Template", "payment_terms", "_Test Payment Term Template 3"
|
||||
@@ -451,6 +591,18 @@ def set_credit_limit(customer, company, credit_limit):
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
|
||||
def set_overdue_billing_threshold(customer, company, threshold):
|
||||
customer = frappe.get_doc("Customer", customer)
|
||||
for d in customer.credit_limits:
|
||||
if d.company == company:
|
||||
d.overdue_billing_threshold = threshold
|
||||
d.db_update()
|
||||
return
|
||||
|
||||
customer.append("credit_limits", {"company": company, "overdue_billing_threshold": threshold})
|
||||
customer.credit_limits[-1].db_insert()
|
||||
|
||||
|
||||
def create_internal_customer(customer_name=None, represents_company=None, allowed_to_interact_with=None):
|
||||
if not customer_name:
|
||||
customer_name = represents_company
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"company",
|
||||
"column_break_2",
|
||||
"credit_limit",
|
||||
"overdue_billing_threshold",
|
||||
"bypass_credit_limit_check"
|
||||
],
|
||||
"fields": [
|
||||
@@ -18,6 +19,15 @@
|
||||
"in_list_view": 1,
|
||||
"label": "Credit Limit"
|
||||
},
|
||||
{
|
||||
"columns": 3,
|
||||
"description": "New Sales Invoices are blocked when the customer's overdue amount exceeds this. Requires 'Restrict Customer Over Billing' in Accounts Settings.",
|
||||
"fieldname": "overdue_billing_threshold",
|
||||
"fieldtype": "Currency",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Overdue Limit"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_2",
|
||||
"fieldtype": "Column Break"
|
||||
|
||||
@@ -18,6 +18,7 @@ class CustomerCreditLimit(Document):
|
||||
bypass_credit_limit_check: DF.Check
|
||||
company: DF.Link | None
|
||||
credit_limit: DF.Currency
|
||||
overdue_billing_threshold: DF.Currency
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
|
||||
@@ -296,6 +296,7 @@ class Quotation(SellingController):
|
||||
# update enquiry status
|
||||
self.update_opportunity("Quotation")
|
||||
self.update_lead()
|
||||
self.carry_forward_communication()
|
||||
|
||||
def on_cancel(self):
|
||||
if self.lost_reasons:
|
||||
@@ -307,6 +308,18 @@ class Quotation(SellingController):
|
||||
self.update_opportunity("Open")
|
||||
self.update_lead()
|
||||
|
||||
def carry_forward_communication(self):
|
||||
from erpnext.crm.utils import copy_comments, link_communications
|
||||
|
||||
if not (
|
||||
self.opportunity
|
||||
and frappe.get_single_value("CRM Settings", "carry_forward_communication_and_comments")
|
||||
):
|
||||
return
|
||||
|
||||
copy_comments("Opportunity", self.opportunity, self)
|
||||
link_communications("Opportunity", self.opportunity, self)
|
||||
|
||||
def print_other_charges(self, docname):
|
||||
print_lst = []
|
||||
for d in self.get("taxes"):
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe.query_builder import DocType, Order
|
||||
from frappe.query_builder import Criterion, DocType, Order
|
||||
from frappe.utils import cint, get_datetime
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
@@ -148,50 +148,55 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te
|
||||
if not frappe.db.exists("Item Group", item_group):
|
||||
item_group = get_root_of("Item Group")
|
||||
|
||||
condition = get_conditions(search_term)
|
||||
condition += get_item_group_condition(pos_profile)
|
||||
|
||||
lft, rgt = frappe.db.get_value("Item Group", item_group, ["lft", "rgt"])
|
||||
|
||||
bin_join_selection, bin_join_condition = "", ""
|
||||
if hide_unavailable_items:
|
||||
bin_join_selection = "LEFT JOIN `tabBin` bin ON bin.item_code = item.name"
|
||||
bin_join_condition = "AND (item.is_stock_item = 0 OR (item.is_stock_item = 1 AND bin.warehouse = %(warehouse)s AND bin.actual_qty > 0))"
|
||||
item = frappe.qb.DocType("Item")
|
||||
item_group_dt = frappe.qb.DocType("Item Group")
|
||||
|
||||
items_data = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
item.name AS item_code,
|
||||
item_group_subquery = (
|
||||
frappe.qb.from_(item_group_dt)
|
||||
.select(item_group_dt.name)
|
||||
.where((item_group_dt.lft >= lft) & (item_group_dt.rgt <= rgt))
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(item)
|
||||
.select(
|
||||
item.name.as_("item_code"),
|
||||
item.item_name,
|
||||
item.description,
|
||||
item.stock_uom,
|
||||
item.image AS item_image,
|
||||
item.image.as_("item_image"),
|
||||
item.is_stock_item,
|
||||
item.sales_uom
|
||||
FROM
|
||||
`tabItem` item {bin_join_selection}
|
||||
WHERE
|
||||
item.disabled = 0
|
||||
AND item.has_variants = 0
|
||||
AND item.is_sales_item = 1
|
||||
AND item.is_fixed_asset = 0
|
||||
AND item.item_group in (SELECT name FROM `tabItem Group` WHERE lft >= {lft} AND rgt <= {rgt})
|
||||
AND {condition}
|
||||
{bin_join_condition}
|
||||
ORDER BY
|
||||
item.name asc
|
||||
LIMIT
|
||||
{page_length} offset {start}""".format(
|
||||
start=cint(start),
|
||||
page_length=cint(page_length),
|
||||
lft=cint(lft),
|
||||
rgt=cint(rgt),
|
||||
condition=condition,
|
||||
bin_join_selection=bin_join_selection,
|
||||
bin_join_condition=bin_join_condition,
|
||||
),
|
||||
{"warehouse": warehouse},
|
||||
as_dict=1,
|
||||
item.sales_uom,
|
||||
)
|
||||
.where(
|
||||
(item.disabled == 0)
|
||||
& (item.has_variants == 0)
|
||||
& (item.is_sales_item == 1)
|
||||
& (item.is_fixed_asset == 0)
|
||||
& (item.item_group.isin(item_group_subquery))
|
||||
& get_conditions(search_term, item)
|
||||
)
|
||||
)
|
||||
|
||||
item_group_condition = get_item_group_condition(pos_profile, item)
|
||||
if item_group_condition is not None:
|
||||
query = query.where(item_group_condition)
|
||||
|
||||
if hide_unavailable_items:
|
||||
bin_dt = frappe.qb.DocType("Bin")
|
||||
query = (
|
||||
query.left_join(bin_dt)
|
||||
.on(bin_dt.item_code == item.name)
|
||||
.where(
|
||||
(item.is_stock_item == 0)
|
||||
| ((item.is_stock_item == 1) & (bin_dt.warehouse == warehouse) & (bin_dt.actual_qty > 0))
|
||||
)
|
||||
)
|
||||
|
||||
items_data = (
|
||||
query.orderby(item.name, order=Order.asc).limit(cint(page_length)).offset(cint(start)).run(as_dict=1)
|
||||
)
|
||||
|
||||
# return (empty) list if there are no results
|
||||
@@ -262,56 +267,63 @@ def search_for_serial_or_batch_or_barcode_number(search_value: str) -> dict[str,
|
||||
return scan_barcode(search_value)
|
||||
|
||||
|
||||
def get_conditions(search_term):
|
||||
condition = "("
|
||||
condition += """item.name like {search_term}
|
||||
or item.item_name like {search_term}""".format(search_term=frappe.db.escape("%" + search_term + "%"))
|
||||
condition += add_search_fields_condition(search_term)
|
||||
condition += ")"
|
||||
def get_conditions(search_term, item=None):
|
||||
if item is None:
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
return condition
|
||||
pattern = f"%{search_term}%"
|
||||
conditions = [item.name.like(pattern), item.item_name.like(pattern)]
|
||||
conditions += add_search_fields_condition(search_term, item)
|
||||
|
||||
return Criterion.any(conditions)
|
||||
|
||||
|
||||
def add_search_fields_condition(search_term):
|
||||
condition = ""
|
||||
def add_search_fields_condition(search_term, item=None):
|
||||
if item is None:
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
pattern = f"%{search_term}%"
|
||||
conditions = []
|
||||
search_fields = frappe.get_all("POS Search Fields", fields=["fieldname"])
|
||||
if search_fields:
|
||||
for field in search_fields:
|
||||
if not field.get("fieldname"):
|
||||
continue
|
||||
condition += " or item.`{}` like {}".format(
|
||||
field["fieldname"], frappe.db.escape("%" + search_term + "%")
|
||||
)
|
||||
return condition
|
||||
for field in search_fields:
|
||||
if not field.get("fieldname"):
|
||||
continue
|
||||
conditions.append(item[field["fieldname"]].like(pattern))
|
||||
|
||||
return conditions
|
||||
|
||||
|
||||
def get_item_group_condition(pos_profile):
|
||||
cond = "and 1=1"
|
||||
def get_item_group_condition(pos_profile, item=None):
|
||||
if item is None:
|
||||
item = frappe.qb.DocType("Item")
|
||||
|
||||
item_groups = get_item_groups(pos_profile)
|
||||
if item_groups:
|
||||
cond = "and item.item_group in (%s)" % (", ".join(["%s"] * len(item_groups)))
|
||||
return item.item_group.isin(item_groups)
|
||||
|
||||
return cond % tuple(item_groups)
|
||||
return None
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def item_group_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
item_groups = []
|
||||
cond = "1=1"
|
||||
pos_profile = filters.get("pos_profile")
|
||||
|
||||
item_filters = [["name", "like", f"%{txt}%"]]
|
||||
if pos_profile:
|
||||
item_groups = get_item_groups(pos_profile)
|
||||
|
||||
if item_groups:
|
||||
cond = "name in (%s)" % (", ".join(["%s"] * len(item_groups)))
|
||||
cond = cond % tuple(item_groups)
|
||||
item_filters.append(["name", "in", item_groups])
|
||||
|
||||
return frappe.db.sql(
|
||||
f""" select distinct name from `tabItem Group`
|
||||
where {cond} and (name like %(txt)s) limit {page_len} offset {start}""",
|
||||
{"txt": "%%%s%%" % txt},
|
||||
return frappe.get_all(
|
||||
"Item Group",
|
||||
filters=item_filters,
|
||||
fields=["name"],
|
||||
distinct=True,
|
||||
order_by="", # original raw SQL had no ORDER BY; suppress the injected default (creation desc on MariaDB)
|
||||
limit_start=start,
|
||||
limit_page_length=page_len,
|
||||
as_list=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
137
erpnext/selling/page/point_of_sale/test_point_of_sale.py
Normal file
137
erpnext/selling/page/point_of_sale/test_point_of_sale.py
Normal file
@@ -0,0 +1,137 @@
|
||||
# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.utils import random_string
|
||||
|
||||
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
|
||||
from erpnext.selling.page.point_of_sale.point_of_sale import get_items
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestPointOfSaleGetItems(ERPNextTestSuite):
|
||||
"""Covers the raw-SQL -> frappe.qb conversion of point_of_sale.get_items."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Reuse the bootstrap leaf item group; an item assigned directly to it
|
||||
# falls inside its own (lft, rgt) subtree, which is what get_items filters on.
|
||||
self.item_group = "_Test Item Group"
|
||||
|
||||
# A non-stock sales item keeps get_stock_availability cheap (no Bin needed)
|
||||
# and keeps the item out of the hide_unavailable_items branch.
|
||||
self.item_code = "_Test POS Item " + random_string(10)
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": self.item_code,
|
||||
"item_name": self.item_code,
|
||||
"item_group": self.item_group,
|
||||
"stock_uom": "_Test UOM",
|
||||
"is_stock_item": 0,
|
||||
"is_sales_item": 1,
|
||||
"is_fixed_asset": 0,
|
||||
"has_variants": 0,
|
||||
"disabled": 0,
|
||||
}
|
||||
)
|
||||
item.insert()
|
||||
self.item = item
|
||||
|
||||
# make_pos_profile builds "_Test POS Profile" (hide_unavailable_items unset,
|
||||
# no item_groups restriction). Rolled back by tearDown.
|
||||
self.pos_profile = make_pos_profile().name
|
||||
|
||||
def _get_item_codes(self, search_term):
|
||||
result = get_items(
|
||||
start=0,
|
||||
page_length=100,
|
||||
price_list="Standard Selling",
|
||||
item_group=self.item_group,
|
||||
pos_profile=self.pos_profile,
|
||||
search_term=search_term,
|
||||
)
|
||||
# get_items returns {"items": [...]} when the qb query yields rows,
|
||||
# and a bare (empty) list when nothing matches.
|
||||
items = result["items"] if isinstance(result, dict) else result
|
||||
return [row.get("item_code") for row in items]
|
||||
|
||||
def _make_stock_item(self):
|
||||
# Fresh stock item in the filtered item group so it passes the
|
||||
# item_group.isin(subquery) clause and reaches the Bin left-join.
|
||||
item_code = "_Test POS Stock Item " + random_string(10)
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": item_code,
|
||||
"item_name": item_code,
|
||||
"item_group": self.item_group,
|
||||
"stock_uom": "_Test UOM",
|
||||
"is_stock_item": 1,
|
||||
"is_sales_item": 1,
|
||||
"is_fixed_asset": 0,
|
||||
"has_variants": 0,
|
||||
"disabled": 0,
|
||||
}
|
||||
).insert()
|
||||
return item_code
|
||||
|
||||
def test_matching_search_term_returns_item(self):
|
||||
# search_term matches Item.name / Item.item_name via the LIKE OR-condition;
|
||||
# scan_barcode finds nothing for this value, so the converted qb query runs.
|
||||
item_codes = self._get_item_codes(self.item_code)
|
||||
self.assertIn(self.item_code, item_codes)
|
||||
|
||||
def test_non_matching_search_term_excludes_item(self):
|
||||
non_matching = "zzz_no_such_item_" + random_string(10)
|
||||
item_codes = self._get_item_codes(non_matching)
|
||||
self.assertNotIn(self.item_code, item_codes)
|
||||
|
||||
def test_partial_search_term_matches_on_item_name(self):
|
||||
# A substring of the item code must still match (LIKE %term%),
|
||||
# proving the OR/LIKE clause survived the SQL->qb conversion.
|
||||
partial = self.item_code.split(" ")[-1]
|
||||
item_codes = self._get_item_codes(partial)
|
||||
self.assertIn(self.item_code, item_codes)
|
||||
|
||||
def test_disabled_item_is_excluded(self):
|
||||
# disabled == 0 is part of the converted WHERE clause; flipping it
|
||||
# must drop the item even when the search term matches.
|
||||
frappe.db.set_value("Item", self.item_code, "disabled", 1)
|
||||
item_codes = self._get_item_codes(self.item_code)
|
||||
self.assertNotIn(self.item_code, item_codes)
|
||||
|
||||
def test_non_sales_item_is_excluded(self):
|
||||
# is_sales_item == 1 is part of the converted WHERE clause.
|
||||
frappe.db.set_value("Item", self.item_code, "is_sales_item", 0)
|
||||
item_codes = self._get_item_codes(self.item_code)
|
||||
self.assertNotIn(self.item_code, item_codes)
|
||||
|
||||
def test_hide_unavailable_items_filters_on_bin_actual_qty(self):
|
||||
# Covers the hide_unavailable_items branch: the Bin left-join only keeps a
|
||||
# stock item when bin.warehouse == profile warehouse AND bin.actual_qty > 0.
|
||||
# A second stock item with no Bin row (no stock) must be hidden.
|
||||
warehouse = frappe.db.get_value("POS Profile", self.pos_profile, "warehouse")
|
||||
frappe.db.set_value("POS Profile", self.pos_profile, "hide_unavailable_items", 1)
|
||||
|
||||
in_stock_item = self._make_stock_item()
|
||||
out_of_stock_item = self._make_stock_item()
|
||||
|
||||
# Material Receipt gives in_stock_item actual_qty > 0 in the profile warehouse;
|
||||
# out_of_stock_item gets no Bin row at all.
|
||||
make_stock_entry(item_code=in_stock_item, target=warehouse, qty=5, basic_rate=100)
|
||||
|
||||
# Sanity-check the precondition the branch keys off of.
|
||||
self.assertGreater(
|
||||
frappe.db.get_value("Bin", {"item_code": in_stock_item, "warehouse": warehouse}, "actual_qty")
|
||||
or 0,
|
||||
0,
|
||||
)
|
||||
self.assertFalse(frappe.db.exists("Bin", {"item_code": out_of_stock_item}))
|
||||
|
||||
in_stock_codes = self._get_item_codes(in_stock_item)
|
||||
self.assertIn(in_stock_item, in_stock_codes)
|
||||
|
||||
out_of_stock_codes = self._get_item_codes(out_of_stock_item)
|
||||
self.assertNotIn(out_of_stock_item, out_of_stock_codes)
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# License: GNU General Public License v3. See license.txt
|
||||
|
||||
|
||||
from frappe import _
|
||||
|
||||
from erpnext.controllers.trends import get_columns, get_data
|
||||
@@ -40,9 +39,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0] for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -59,4 +64,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -39,9 +39,15 @@ def get_chart_data(data, conditions, filters):
|
||||
labels = [column.split(":")[0].replace(" (Amt)", "") for column in columns]
|
||||
datapoints = [0] * len(labels)
|
||||
|
||||
group_by_col_idx = None
|
||||
if filters.get("group_by"):
|
||||
group_by_col_idx = conditions["columns"].index(conditions["grbc"][0])
|
||||
|
||||
for row in data:
|
||||
# If group by filter, don't add first row of group (it's already summed)
|
||||
if not row[start]:
|
||||
# Skip the final grand-total row
|
||||
if row[0] == f"'{_('Total')}'":
|
||||
continue
|
||||
if group_by_col_idx is not None and row[group_by_col_idx] == "":
|
||||
continue
|
||||
# Remove None values and compute only periodic data
|
||||
row = [x if x else 0 for x in row[start:-2]]
|
||||
@@ -58,4 +64,6 @@ def get_chart_data(data, conditions, filters):
|
||||
"type": "line",
|
||||
"lineOptions": {"regionFill": 1},
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -69,6 +69,17 @@ frappe.ui.form.on("Company", {
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
["default_wip_warehouse", "default_fg_warehouse", "default_scrap_warehouse"].forEach((fieldname) => {
|
||||
frm.set_query(fieldname, function (doc) {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.name,
|
||||
is_group: 0,
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
company_name: function (frm) {
|
||||
@@ -307,6 +318,8 @@ erpnext.company.setup_queries = function (frm) {
|
||||
["default_advance_received_account", { root_type: "Liability", account_type: "Receivable" }],
|
||||
["default_advance_paid_account", { root_type: "Asset", account_type: "Payable" }],
|
||||
["service_expense_account", { root_type: "Expense" }],
|
||||
["expenses_added_to_stock_account", { root_type: "Expense" }],
|
||||
["expenses_added_to_stock_contra_account", { root_type: "Expense" }],
|
||||
],
|
||||
function (i, v) {
|
||||
erpnext.company.set_custom_query(frm, v);
|
||||
|
||||
@@ -117,6 +117,10 @@
|
||||
"service_expense_account",
|
||||
"column_break_ereg",
|
||||
"purchase_expense_contra_account",
|
||||
"stock_expense_section",
|
||||
"expenses_added_to_stock_account",
|
||||
"column_break_gthb",
|
||||
"expenses_added_to_stock_contra_account",
|
||||
"stock_tab",
|
||||
"auto_accounting_for_stock_settings",
|
||||
"enable_perpetual_inventory",
|
||||
@@ -824,6 +828,27 @@
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Buying and Selling"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_expense_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Stock Expense"
|
||||
},
|
||||
{
|
||||
"fieldname": "expenses_added_to_stock_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Expenses Added To Stock Account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_gthb",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "expenses_added_to_stock_contra_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Expenses Added To Stock Contra Account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"fieldname": "stock_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
|
||||
@@ -98,6 +98,8 @@ class Company(NestedSet):
|
||||
exception_budget_approver_role: DF.Link | None
|
||||
exchange_gain_loss_account: DF.Link | None
|
||||
existing_company: DF.Link | None
|
||||
expenses_added_to_stock_account: DF.Link | None
|
||||
expenses_added_to_stock_contra_account: DF.Link | None
|
||||
fax: DF.Data | None
|
||||
is_group: DF.Check
|
||||
lft: DF.Int
|
||||
|
||||
@@ -132,7 +132,7 @@
|
||||
{
|
||||
"fieldname": "credit_limits",
|
||||
"fieldtype": "Table",
|
||||
"label": "Credit Limit",
|
||||
"label": "Credit & Overdue Limits",
|
||||
"options": "Customer Credit Limit"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -75,6 +75,7 @@ class DeprecatedSerialNoValuation:
|
||||
| (table.serial_no.like("%\n" + serial_no))
|
||||
| (table.serial_no.like("%\n" + serial_no + "\n%"))
|
||||
)
|
||||
& (table.item_code == self.sle.item_code)
|
||||
& (table.company == self.sle.company)
|
||||
& (table.warehouse == self.sle.warehouse)
|
||||
& (table.serial_and_batch_bundle.isnull())
|
||||
|
||||
@@ -296,7 +296,7 @@ def get_batches_by_oldest(item_code, warehouse):
|
||||
"""Returns the oldest batch and qty for the given item_code and warehouse"""
|
||||
batches = get_batch_qty(item_code=item_code, warehouse=warehouse)
|
||||
batches_dates = [[batch, frappe.get_value("Batch", batch.batch_no, "expiry_date")] for batch in batches]
|
||||
batches_dates.sort(key=lambda tup: tup[1])
|
||||
batches_dates.sort(key=lambda tup: (tup[1] is None, tup[1]))
|
||||
return batches_dates
|
||||
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class Bin(Document):
|
||||
& (subcontract_order.docstatus == 1)
|
||||
)
|
||||
if subcontract_doctype == "Purchase Order"
|
||||
else (subcontract_order.docstatus == 1)
|
||||
else ((subcontract_order.status != "Closed") & (subcontract_order.docstatus == 1))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -212,6 +212,7 @@ class Bin(Document):
|
||||
else (
|
||||
(Coalesce(se.subcontracting_order, "") != "")
|
||||
& (subcontract_order.name == se.subcontracting_order)
|
||||
& (subcontract_order.status != "Closed")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -620,7 +620,13 @@ $.extend(erpnext.item, {
|
||||
};
|
||||
});
|
||||
|
||||
let fields = ["purchase_expense_account", "purchase_expense_contra_account", "default_cogs_account"];
|
||||
let fields = [
|
||||
"purchase_expense_account",
|
||||
"purchase_expense_contra_account",
|
||||
"expenses_added_to_stock_account",
|
||||
"expenses_added_to_stock_contra_account",
|
||||
"default_cogs_account",
|
||||
];
|
||||
|
||||
fields.forEach((field) => {
|
||||
frm.set_query(field, "item_defaults", (doc, cdt, cdn) => {
|
||||
|
||||
@@ -721,7 +721,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license",
|
||||
"description": "Income from this item will be recognized over a period of months instead of all at once. Eg: annual subscription paid upfront.",
|
||||
"fieldname": "enable_deferred_revenue",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Deferred Revenue"
|
||||
@@ -734,7 +734,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Income from this item will be recognized over a period of months instead of all at once. Eg: annual subscription paid upfront.",
|
||||
"description": "Expense for this item will be recognized over a period of months. Eg: prepaid insurance or annual software license",
|
||||
"fieldname": "enable_deferred_expense",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Deferred Expense"
|
||||
@@ -1093,7 +1093,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified": "2026-07-28 18:58:43.328497",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
"column_break_cpif",
|
||||
"purchase_expense_account",
|
||||
"purchase_expense_contra_account",
|
||||
"expenses_added_to_stock_account",
|
||||
"expenses_added_to_stock_contra_account",
|
||||
"selling_defaults",
|
||||
"selling_cost_center",
|
||||
"column_break_12",
|
||||
@@ -45,6 +47,7 @@
|
||||
{
|
||||
"fieldname": "default_warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Default Warehouse",
|
||||
"options": "Warehouse",
|
||||
@@ -73,6 +76,7 @@
|
||||
"fieldname": "buying_cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Buying Cost Center",
|
||||
"ignore_user_permissions": 1,
|
||||
"options": "Cost Center",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
@@ -92,6 +96,7 @@
|
||||
"fieldname": "expense_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Expense Account",
|
||||
"ignore_user_permissions": 1,
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
@@ -144,6 +149,7 @@
|
||||
"fieldname": "deferred_expense_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Deferred Expense Account",
|
||||
"ignore_user_permissions": 1,
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
@@ -181,6 +187,7 @@
|
||||
"description": "Account to record additional purchase expenses like freight or customs for this item",
|
||||
"fieldname": "purchase_expense_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Purchase Expense Account",
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
@@ -189,14 +196,34 @@
|
||||
"description": "Used to balance the books when recording extra purchase costs like freight or customs",
|
||||
"fieldname": "purchase_expense_contra_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Purchase Expense Contra Account",
|
||||
"options": "Account"
|
||||
},
|
||||
{
|
||||
"description": "Account to track value added to stock via Stock Entry, Stock Reconciliation or Landed Cost Voucher",
|
||||
"fieldname": "expenses_added_to_stock_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Expenses Added To Stock Account",
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"description": "Used to balance the books when recording expenses added to stock",
|
||||
"fieldname": "expenses_added_to_stock_contra_account",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"label": "Expenses Added To Stock Contra Account",
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"description": "Stock account where inventory value for this item will be tracked",
|
||||
"fieldname": "default_inventory_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Inventory Account",
|
||||
"ignore_user_permissions": 1,
|
||||
"options": "Account",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
@@ -211,7 +238,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-27 01:49:01.396845",
|
||||
"modified": "2026-07-28 15:39:44.848087",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Default",
|
||||
|
||||
@@ -26,6 +26,8 @@ class ItemDefault(Document):
|
||||
deferred_expense_account: DF.Link | None
|
||||
deferred_revenue_account: DF.Link | None
|
||||
expense_account: DF.Link | None
|
||||
expenses_added_to_stock_account: DF.Link | None
|
||||
expenses_added_to_stock_contra_account: DF.Link | None
|
||||
income_account: DF.Link | None
|
||||
inventory_account_currency: DF.Link | None
|
||||
parent: DF.Data
|
||||
@@ -33,6 +35,7 @@ class ItemDefault(Document):
|
||||
parenttype: DF.Data
|
||||
purchase_expense_account: DF.Link | None
|
||||
purchase_expense_contra_account: DF.Link | None
|
||||
purchase_price_variance_account: DF.Link | None
|
||||
selling_cost_center: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "hash",
|
||||
"creation": "2013-03-07 11:42:59",
|
||||
"doctype": "DocType",
|
||||
@@ -18,6 +19,7 @@
|
||||
"columns": 3,
|
||||
"fieldname": "warehouse_group",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Check Availability in Warehouse",
|
||||
"options": "Warehouse"
|
||||
@@ -26,6 +28,7 @@
|
||||
"columns": 2,
|
||||
"fieldname": "warehouse",
|
||||
"fieldtype": "Link",
|
||||
"ignore_user_permissions": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Request for",
|
||||
"options": "Warehouse",
|
||||
@@ -59,7 +62,7 @@
|
||||
"in_create": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-12-02 16:02:23.254963",
|
||||
"modified": "2026-07-28 15:54:36.089238",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item Reorder",
|
||||
|
||||
@@ -1381,6 +1381,9 @@ def create_delivery_wo_so(pick_list, target, target_doc=None):
|
||||
|
||||
target_doc.company = pick_list.company
|
||||
|
||||
if not target_doc.customer:
|
||||
target_doc.customer = pick_list.customer
|
||||
|
||||
item_table_mapper_without_so = {
|
||||
"doctype": f"{target} Item",
|
||||
"field_map": {
|
||||
|
||||
@@ -652,6 +652,14 @@ class PurchaseReceipt(BuyingController):
|
||||
item=item,
|
||||
)
|
||||
|
||||
def make_expenses_added_to_stock_entries(item):
|
||||
if not self.book_stock_expense_enabled():
|
||||
return
|
||||
|
||||
amount = flt(item.landed_cost_voucher_amount, item.precision("base_net_amount"))
|
||||
if amount and not item.is_fixed_asset:
|
||||
self.append_expenses_added_to_stock_pair(gl_entries, item.item_code, amount, item)
|
||||
|
||||
def make_amount_difference_entry(item):
|
||||
if item.amount_difference_with_purchase_invoice and stock_asset_rbnb:
|
||||
account_currency = get_account_currency(stock_asset_rbnb)
|
||||
@@ -796,6 +804,7 @@ class PurchaseReceipt(BuyingController):
|
||||
make_item_asset_inward_gl_entry(d, stock_value_diff, stock_asset_account_name)
|
||||
outgoing_amount = make_stock_received_but_not_billed_entry(d)
|
||||
make_landed_cost_gl_entries(d)
|
||||
make_expenses_added_to_stock_entries(d)
|
||||
make_amount_difference_entry(d)
|
||||
make_sub_contracting_gl_entries(d)
|
||||
make_divisional_loss_gl_entry(d, outgoing_amount)
|
||||
|
||||
@@ -5125,6 +5125,14 @@ class TestPurchaseReceipt(ERPNextTestSuite):
|
||||
self.assertEqual(srbnb_cost, 1000)
|
||||
|
||||
def test_purchase_expense_account(self):
|
||||
# Single, so it outlives this test - every later Purchase Receipt / Invoice would otherwise
|
||||
# be forced to resolve the expense account pair and throw for unconfigured companies.
|
||||
previous = frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value, "Accounts Settings", "book_stock_expense_gl_entries", previous
|
||||
)
|
||||
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)
|
||||
|
||||
item = "Test Item with Purchase Expense Account"
|
||||
make_item(item, {"is_stock_item": 1})
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
|
||||
def clear_attachment(self):
|
||||
if attachments := get_attachments(self.doctype, self.name):
|
||||
attachment = attachments[0]
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
|
||||
|
||||
if self.reposting_data_file:
|
||||
self.db_set("reposting_data_file", None)
|
||||
|
||||
@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
)
|
||||
|
||||
def test_clear_attachment_skips_referenced_data_file(self):
|
||||
riv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"company": "_Test Company",
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"posting_date": today(),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
attached = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": "repost_data.json.gz",
|
||||
"content": "test",
|
||||
"attached_to_doctype": riv.doctype,
|
||||
"attached_to_name": riv.name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
riv.db_set("reposting_data_file", attached.file_url)
|
||||
|
||||
riv.clear_attachment()
|
||||
|
||||
self.assertFalse(frappe.db.exists("File", attached.name))
|
||||
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Reposting Settings",
|
||||
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},
|
||||
|
||||
@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
}
|
||||
|
||||
frm.events.set_route_options_for_new_doc(frm);
|
||||
|
||||
frm.set_df_property(
|
||||
"items",
|
||||
"cannot_add_rows",
|
||||
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
);
|
||||
},
|
||||
|
||||
set_route_options_for_new_doc(frm) {
|
||||
let batch_no_field = frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: frm.doc.doctype,
|
||||
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
get_items_from_transit_entry: function (frm) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
|
||||
frm.add_custom_button(
|
||||
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
super.onload_post_render();
|
||||
var me = this;
|
||||
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
|
||||
me.company();
|
||||
|
||||
@@ -176,6 +176,8 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
work_order: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
book_expenses_added_to_stock = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.status_updater = [
|
||||
|
||||
@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
|
||||
self.assertEqual(qty_after(receipt2), 10)
|
||||
|
||||
def test_cancel_shifts_same_timestamp_delivery_notes(self):
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
posting_date = today()
|
||||
posting_time = "10:00:00"
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="09:00:00",
|
||||
)
|
||||
|
||||
dns = []
|
||||
for i in range(5):
|
||||
dns.append(
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10 * i,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
dn = dns[2]
|
||||
dn.cancel()
|
||||
|
||||
expected_qty_after_transaction_of_dns3 = 40
|
||||
qty_after_transaction_of_dns3 = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": dns[3].name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
|
||||
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
|
||||
@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
|
||||
};
|
||||
});
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse,
|
||||
voucher_type: frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (frm.doc.company) {
|
||||
erpnext.queries.setup_queries(frm, "Warehouse", function () {
|
||||
return erpnext.queries.warehouse(frm.doc);
|
||||
|
||||
@@ -57,6 +57,8 @@ class StockReconciliation(StockController):
|
||||
set_warehouse: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
book_expenses_added_to_stock = True
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.head_row = ["Item Code", "Warehouse", "Quantity", "Valuation Rate"]
|
||||
|
||||
@@ -84,6 +84,7 @@ def get_item_details(ctx, doc=None, for_validate=False, overwrite_warehouse=True
|
||||
for_validate = parse_json(for_validate)
|
||||
overwrite_warehouse = parse_json(overwrite_warehouse)
|
||||
item = frappe.get_cached_doc("Item", ctx.item_code)
|
||||
item.check_permission()
|
||||
validate_item_details(ctx, item)
|
||||
|
||||
if isinstance(doc, str):
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Delivery Note")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -52,4 +52,6 @@ def get_chart_data(data, filters):
|
||||
},
|
||||
"type": "bar",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
import erpnext
|
||||
|
||||
|
||||
def execute(filters: dict | None = None):
|
||||
columns = get_columns()
|
||||
@@ -24,6 +26,14 @@ def get_columns() -> list[dict]:
|
||||
"label": _("Total Landed Cost"),
|
||||
"fieldname": "landed_cost",
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
},
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"hidden": 1,
|
||||
},
|
||||
{
|
||||
"label": _("Purchase Voucher Type"),
|
||||
@@ -49,6 +59,7 @@ def get_columns() -> list[dict]:
|
||||
|
||||
|
||||
def get_data(filters) -> list[list]:
|
||||
company_currency = erpnext.get_company_currency(filters.get("company"))
|
||||
landed_cost_vouchers = get_landed_cost_vouchers(filters) or {}
|
||||
landed_vouchers = list(landed_cost_vouchers.keys())
|
||||
vendor_invoices = {}
|
||||
@@ -57,7 +68,6 @@ def get_data(filters) -> list[list]:
|
||||
|
||||
data = []
|
||||
|
||||
print(vendor_invoices)
|
||||
for name, vouchers in landed_cost_vouchers.items():
|
||||
res = {
|
||||
"name": name,
|
||||
@@ -72,6 +82,7 @@ def get_data(filters) -> list[list]:
|
||||
"landed_cost": d.landed_cost,
|
||||
"voucher_type": d.voucher_type,
|
||||
"voucher_no": d.voucher_no,
|
||||
"currency": company_currency,
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -88,7 +99,6 @@ def get_data(filters) -> list[list]:
|
||||
|
||||
if vendor_invoice_list and len(vendor_invoice_list) > len(vouchers):
|
||||
for row in vendor_invoice_list[last_index + 1 :]:
|
||||
print(row)
|
||||
data.append({"vendor_invoice": row})
|
||||
|
||||
return data
|
||||
|
||||
@@ -14,12 +14,12 @@ def execute(filters=None):
|
||||
conditions = get_columns(filters, "Purchase Receipt")
|
||||
data = get_data(filters, conditions)
|
||||
|
||||
chart_data = get_chart_data(data, filters)
|
||||
chart_data = get_chart_data(data, conditions, filters)
|
||||
|
||||
return conditions["columns"], data, None, chart_data
|
||||
|
||||
|
||||
def get_chart_data(data, filters):
|
||||
def get_chart_data(data, conditions, filters):
|
||||
def wrap_in_quotes(label):
|
||||
return f"'{label}'"
|
||||
|
||||
@@ -53,4 +53,6 @@ def get_chart_data(data, filters):
|
||||
"type": "bar",
|
||||
"colors": ["#5e64ff"],
|
||||
"fieldtype": "Currency",
|
||||
"options": "currency",
|
||||
"currency": conditions.get("company_currency"),
|
||||
}
|
||||
|
||||
@@ -325,6 +325,7 @@ class FIFOSlots:
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
self._rebalance_batch_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
@@ -346,6 +347,29 @@ class FIFOSlots:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _rebalance_batch_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_batch_no"):
|
||||
self._rebalance_batch_slot_values(item_dict["fifo_queue"])
|
||||
|
||||
def _rebalance_batch_slot_values(self, fifo_queue: list) -> None:
|
||||
"""A batch is one valuation pool, so per-slot value differences are stale
|
||||
detail: spread the pool value over its slots in proportion to qty."""
|
||||
groups = {}
|
||||
for slot in fifo_queue:
|
||||
if is_batch_slot(slot):
|
||||
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
|
||||
groups.setdefault(key, []).append(slot)
|
||||
|
||||
for slots in groups.values():
|
||||
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
|
||||
if total_qty <= 0:
|
||||
continue
|
||||
|
||||
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
|
||||
for slot in slots:
|
||||
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
|
||||
@@ -5,7 +5,13 @@ from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import FIFOSlots, format_report_data, get_average_age
|
||||
from erpnext.stock.report.stock_ageing.stock_ageing import (
|
||||
BATCH_SLOT_QTY_INDEX,
|
||||
BATCH_SLOT_VALUE_INDEX,
|
||||
FIFOSlots,
|
||||
format_report_data,
|
||||
get_average_age,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
@@ -565,10 +571,11 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
],
|
||||
)
|
||||
|
||||
def test_partial_batch_reco_keeps_existing_slot_values(self):
|
||||
def test_partial_batch_reco_pools_slot_values(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 100, single-SLE reco >> 12]
|
||||
The reco entry qty (delta 2) does not cover the whole batch, so
|
||||
stock_value_difference / qty is not the batch rate: skip the rescale."""
|
||||
stock_value_difference / qty is not the batch rate: skip the rescale.
|
||||
The batch total (1400) is untouched, then pooled across both slots."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
@@ -608,11 +615,163 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
[slot[:4] for slot in queue],
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01"],
|
||||
[batch_no, 1, 2.0, "2021-12-01"],
|
||||
],
|
||||
)
|
||||
self.assertAlmostEqual(queue[0][4], 1166.67, places=2)
|
||||
self.assertAlmostEqual(queue[1][4], 233.33, places=2)
|
||||
|
||||
def test_batch_receipts_at_differing_rates_pool_slot_values(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10] and no issue.
|
||||
Nothing goes negative, but the batch is one valuation pool, so both
|
||||
age slots carry the pooled rate instead of their receipt value."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Split",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-SPLIT-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 10.0, "2021-12-01", 1000.0],
|
||||
[batch_no, 1, 2.0, "2021-12-01", 400.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
def test_batch_pooling_preserves_total_on_repeating_rate(self):
|
||||
"""Ledger (same wh, batch B): [+3 @ 100/3, +6 @ 0, +2 @ 0]
|
||||
The pooled rate does not terminate, so assert the redistributed
|
||||
slot values still add back to the batch total."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Residual",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-RESIDUAL-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 3, 3, 100),
|
||||
make_sle("2021-12-02", "002", 6, 9, 0),
|
||||
make_sle("2021-12-03", "003", 2, 11, 0),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual([slot[BATCH_SLOT_QTY_INDEX] for slot in queue], [3.0, 6.0, 2.0])
|
||||
self.assertEqual(sum(slot[BATCH_SLOT_VALUE_INDEX] for slot in queue), 100.0)
|
||||
|
||||
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
|
||||
Consuming the zero-valued head slot at the pooled rate drives it
|
||||
negative; slot values are then rebalanced to the batch pool rate."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Rebalance",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-REBALANCE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
make_sle("2021-12-03", "003", -4, 16, -20),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ frappe.query_reports["Stock Balance"] = {
|
||||
fieldname: "include_zero_stock_items",
|
||||
label: __("Include Zero Stock Items"),
|
||||
fieldtype: "Check",
|
||||
default: 0,
|
||||
default: 1,
|
||||
},
|
||||
{
|
||||
fieldname: "show_dimension_wise_stock",
|
||||
|
||||
@@ -497,7 +497,9 @@ class update_entries_after:
|
||||
|
||||
self.data = frappe._dict()
|
||||
|
||||
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
|
||||
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
|
||||
"cancelled"
|
||||
):
|
||||
self.initialize_previous_data(self.args)
|
||||
|
||||
self.build()
|
||||
@@ -805,10 +807,23 @@ class update_entries_after:
|
||||
|
||||
def process_sle_against_current_timestamp(self):
|
||||
sl_entries = get_sle_against_current_voucher(self.args)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
for sle in sl_entries:
|
||||
sle["timestamp"] = sle.posting_datetime
|
||||
self.process_sle(sle)
|
||||
|
||||
def seed_previous_sle_for_cancellation(self, anchor_sle):
|
||||
key = (anchor_sle.item_code, anchor_sle.warehouse)
|
||||
if key in self.prev_sle_dict:
|
||||
return
|
||||
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.name
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[key] = prev_sle
|
||||
|
||||
def get_future_entries_to_fix(self):
|
||||
# includes current entry!
|
||||
args = self.data[self.args.warehouse].previous_sle or frappe._dict(
|
||||
|
||||
211
erpnext/stock/tests/test_expenses_added_to_stock.py
Normal file
211
erpnext/stock/tests/test_expenses_added_to_stock.py
Normal file
@@ -0,0 +1,211 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
COMPANY = "_Test Company with perpetual inventory"
|
||||
WAREHOUSE = "Stores - TCP1"
|
||||
|
||||
|
||||
class TestExpensesAddedToStock(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
self.restore_stock_expense_settings()
|
||||
self.eats_account = create_account(
|
||||
account_name="Expenses Added To Stock",
|
||||
parent_account="Expenses - TCP1",
|
||||
company=COMPANY,
|
||||
)
|
||||
self.eats_contra_account = create_account(
|
||||
account_name="Expenses Added To Stock Contra",
|
||||
parent_account="Expenses - TCP1",
|
||||
company=COMPANY,
|
||||
)
|
||||
self.purchase_expense_account = create_account(
|
||||
account_name="Test Purchase Expense EATS",
|
||||
parent_account="Expenses - TCP1",
|
||||
company=COMPANY,
|
||||
)
|
||||
self.purchase_expense_contra_account = create_account(
|
||||
account_name="Test Purchase Expense Contra EATS",
|
||||
parent_account="Expenses - TCP1",
|
||||
company=COMPANY,
|
||||
)
|
||||
frappe.db.set_value(
|
||||
"Company",
|
||||
COMPANY,
|
||||
{
|
||||
"expenses_added_to_stock_account": self.eats_account,
|
||||
"expenses_added_to_stock_contra_account": self.eats_contra_account,
|
||||
"purchase_expense_account": self.purchase_expense_account,
|
||||
"purchase_expense_contra_account": self.purchase_expense_contra_account,
|
||||
},
|
||||
)
|
||||
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 1)
|
||||
self.item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
def restore_stock_expense_settings(self):
|
||||
"""These are a Single and Company fields, so they outlive the test. Left set, every later
|
||||
Purchase Receipt / Invoice in the run has to resolve the expense account pair and throws
|
||||
for any company that has none configured."""
|
||||
account_fields = [
|
||||
"expenses_added_to_stock_account",
|
||||
"expenses_added_to_stock_contra_account",
|
||||
"purchase_expense_account",
|
||||
"purchase_expense_contra_account",
|
||||
]
|
||||
previous_accounts = frappe.db.get_value("Company", COMPANY, account_fields, as_dict=True)
|
||||
previous_flag = frappe.db.get_single_value("Accounts Settings", "book_stock_expense_gl_entries")
|
||||
|
||||
self.addCleanup(frappe.db.set_value, "Company", COMPANY, dict(previous_accounts))
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"Accounts Settings",
|
||||
"book_stock_expense_gl_entries",
|
||||
previous_flag,
|
||||
)
|
||||
|
||||
def get_gl_balances(self, voucher_type, voucher_no):
|
||||
entries = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": voucher_type,
|
||||
"voucher_no": voucher_no,
|
||||
"is_cancelled": 0,
|
||||
"account": ("in", [self.eats_account, self.eats_contra_account]),
|
||||
},
|
||||
fields=["account", "debit", "credit"],
|
||||
)
|
||||
|
||||
balances = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
|
||||
debits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
|
||||
credits = frappe._dict({self.eats_account: 0.0, self.eats_contra_account: 0.0})
|
||||
for entry in entries:
|
||||
balances[entry.account] += entry.debit - entry.credit
|
||||
debits[entry.account] += entry.debit
|
||||
credits[entry.account] += entry.credit
|
||||
|
||||
return balances, debits, credits
|
||||
|
||||
def test_material_receipt_books_expenses_added_to_stock(self):
|
||||
se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
|
||||
self.assertEqual(debits[self.eats_account], 1000)
|
||||
self.assertEqual(credits[self.eats_contra_account], 1000)
|
||||
|
||||
def test_material_issue_books_reverse_pair(self):
|
||||
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
|
||||
se = make_stock_entry(item_code=self.item, from_warehouse=WAREHOUSE, qty=5, company=COMPANY)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
|
||||
self.assertEqual(credits[self.eats_account], 500)
|
||||
self.assertEqual(debits[self.eats_contra_account], 500)
|
||||
|
||||
def test_material_transfer_books_nothing(self):
|
||||
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
|
||||
se = make_stock_entry(
|
||||
item_code=self.item,
|
||||
from_warehouse=WAREHOUSE,
|
||||
to_warehouse="Finished Goods - TCP1",
|
||||
qty=5,
|
||||
company=COMPANY,
|
||||
)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
|
||||
self.assertEqual(debits[self.eats_account], 0)
|
||||
self.assertEqual(credits[self.eats_account], 0)
|
||||
|
||||
def test_stock_reconciliation_books_pair(self):
|
||||
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
|
||||
create_stock_reconciliation,
|
||||
)
|
||||
|
||||
make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
|
||||
sr = create_stock_reconciliation(
|
||||
item_code=self.item, warehouse=WAREHOUSE, qty=15, rate=100, company=COMPANY
|
||||
)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Stock Reconciliation", sr.name)
|
||||
self.assertEqual(debits[self.eats_account], 500)
|
||||
self.assertEqual(credits[self.eats_contra_account], 500)
|
||||
|
||||
def test_landed_cost_voucher_books_pair(self):
|
||||
from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
|
||||
create_landed_cost_voucher,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
company=COMPANY, warehouse=WAREHOUSE, item_code=self.item, qty=10, rate=100
|
||||
)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name)
|
||||
self.assertEqual(debits[self.eats_account], 0)
|
||||
|
||||
create_landed_cost_voucher("Purchase Receipt", pr.name, COMPANY, charges=200)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Purchase Receipt", pr.name)
|
||||
self.assertEqual(debits[self.eats_account], 200)
|
||||
self.assertEqual(credits[self.eats_contra_account], 200)
|
||||
|
||||
def test_no_entries_when_feature_disabled(self):
|
||||
frappe.db.set_single_value("Accounts Settings", "book_stock_expense_gl_entries", 0)
|
||||
|
||||
se = make_stock_entry(item_code=self.item, to_warehouse=WAREHOUSE, qty=10, rate=100, company=COMPANY)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Stock Entry", se.name)
|
||||
self.assertEqual(debits[self.eats_account], 0)
|
||||
self.assertEqual(credits[self.eats_contra_account], 0)
|
||||
|
||||
def test_missing_contra_account_raises_when_feature_enabled(self):
|
||||
frappe.db.set_value("Company", COMPANY, "expenses_added_to_stock_contra_account", None)
|
||||
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
make_stock_entry,
|
||||
item_code=self.item,
|
||||
to_warehouse=WAREHOUSE,
|
||||
qty=10,
|
||||
rate=100,
|
||||
company=COMPANY,
|
||||
)
|
||||
|
||||
def test_service_item_books_nothing_on_purchase_invoice_with_update_stock(self):
|
||||
"""A service item carries no stock value, so booking it produced a GL row with neither a
|
||||
debit nor a credit, which GL Entry rejects outright."""
|
||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||
|
||||
service_item = make_item(properties={"is_stock_item": 0}).name
|
||||
|
||||
pi = make_purchase_invoice(
|
||||
company=COMPANY,
|
||||
warehouse=WAREHOUSE,
|
||||
item_code=service_item,
|
||||
qty=1,
|
||||
rate=500,
|
||||
update_stock=1,
|
||||
expense_account="Cost of Goods Sold - TCP1",
|
||||
cost_center="Main - TCP1",
|
||||
)
|
||||
|
||||
self.assertEqual(pi.docstatus, 1)
|
||||
|
||||
_balances, debits, credits = self.get_gl_balances("Purchase Invoice", pi.name)
|
||||
self.assertEqual(debits[self.eats_account], 0)
|
||||
self.assertEqual(credits[self.eats_contra_account], 0)
|
||||
|
||||
booked = frappe.get_all(
|
||||
"GL Entry",
|
||||
filters={
|
||||
"voucher_type": "Purchase Invoice",
|
||||
"voucher_no": pi.name,
|
||||
"is_cancelled": 0,
|
||||
"account": self.purchase_expense_account,
|
||||
},
|
||||
)
|
||||
self.assertFalse(booked)
|
||||
@@ -8,7 +8,6 @@
|
||||
"document_type": "Document",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"naming_series",
|
||||
"sales_order",
|
||||
"customer",
|
||||
@@ -29,6 +28,7 @@
|
||||
"service_items_section",
|
||||
"service_items",
|
||||
"tab_other_info",
|
||||
"title",
|
||||
"order_status_section",
|
||||
"status",
|
||||
"per_raw_material_received",
|
||||
@@ -43,10 +43,8 @@
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "{customer_name}",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Title",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
@@ -306,7 +304,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-26 17:16:21.697846",
|
||||
"modified": "2026-07-27 11:20:14.512336",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Inward Order",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"document_type": "Document",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"title",
|
||||
"naming_series",
|
||||
"purchase_order",
|
||||
"supplier",
|
||||
@@ -55,6 +54,7 @@
|
||||
"additional_costs",
|
||||
"total_additional_costs",
|
||||
"tab_other_info",
|
||||
"title",
|
||||
"order_status_section",
|
||||
"status",
|
||||
"column_break_39",
|
||||
@@ -69,10 +69,8 @@
|
||||
"fields": [
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"default": "{supplier_name}",
|
||||
"fieldname": "title",
|
||||
"fieldtype": "Data",
|
||||
"hidden": 1,
|
||||
"label": "Title",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1
|
||||
@@ -494,7 +492,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-14 10:31:40.682892",
|
||||
"modified": "2026-07-27 11:20:14.512336",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Subcontracting",
|
||||
"name": "Subcontracting Order",
|
||||
|
||||
@@ -336,6 +336,85 @@ class TestSubcontractingOrder(ERPNextTestSuite):
|
||||
bin_after_cancel_sco.reserved_qty_for_sub_contract, bin_before_sco.reserved_qty_for_sub_contract
|
||||
)
|
||||
|
||||
def test_close_subcontracting_order_releases_reserved_qty(self):
|
||||
# RM in stock at the reserve warehouse for transfer
|
||||
make_stock_entry(target="_Test Warehouse - _TC", item_code="_Test Item", qty=10, basic_rate=100)
|
||||
make_stock_entry(
|
||||
target="_Test Warehouse - _TC", item_code="_Test Item Home Desktop 100", qty=20, basic_rate=100
|
||||
)
|
||||
|
||||
bin_before_sco = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname="reserved_qty_for_sub_contract",
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# Create SCO with a reserve warehouse on the supplied items
|
||||
service_items = [
|
||||
{
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_code": "Subcontracted Service Item 1",
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"fg_item": "_Test FG Item",
|
||||
"fg_item_qty": 10,
|
||||
},
|
||||
]
|
||||
sco = get_subcontracting_order(service_items=service_items)
|
||||
|
||||
# Transfer only 90% of the raw materials to the supplier warehouse
|
||||
ste = frappe.get_doc(make_rm_stock_entry(sco.name))
|
||||
for item in ste.items:
|
||||
item.qty *= 0.9
|
||||
ste.save()
|
||||
ste.submit()
|
||||
sco.load_from_db()
|
||||
self.assertEqual(sco.status, "Partial Material Transferred")
|
||||
|
||||
# Receive only a partial qty so the order stays open (per_received < 100)
|
||||
scr = make_subcontracting_receipt(sco.name)
|
||||
scr.items[0].qty -= 1
|
||||
scr.save()
|
||||
scr.submit()
|
||||
sco.load_from_db()
|
||||
self.assertEqual(sco.status, "Partially Received")
|
||||
|
||||
# Keep another SCO open so transfers from the closed SCO must not reduce its reservation
|
||||
open_sco = get_subcontracting_order(service_items=service_items)
|
||||
self.assertEqual(open_sco.status, "Open")
|
||||
|
||||
bin_before_close = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# One unit remains reserved for the partially transferred SCO, plus ten for the open SCO
|
||||
self.assertEqual(
|
||||
bin_before_close.reserved_qty_for_sub_contract,
|
||||
bin_before_sco.reserved_qty_for_sub_contract + 11,
|
||||
)
|
||||
|
||||
# Close the partially-received order
|
||||
sco.update_status("Closed")
|
||||
self.assertEqual(sco.status, "Closed")
|
||||
|
||||
bin_after_close = frappe.db.get_value(
|
||||
"Bin",
|
||||
filters={"warehouse": "_Test Warehouse - _TC", "item_code": "_Test Item"},
|
||||
fieldname=["reserved_qty_for_sub_contract", "projected_qty"],
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
# Closing releases the remaining unit without applying its transfer against the open SCO
|
||||
self.assertEqual(
|
||||
bin_after_close.reserved_qty_for_sub_contract,
|
||||
bin_before_sco.reserved_qty_for_sub_contract + 10,
|
||||
)
|
||||
self.assertEqual(bin_after_close.projected_qty, bin_before_close.projected_qty + 1)
|
||||
|
||||
def test_send_to_subcontractor_ste_submit_without_sco_write_permission(self):
|
||||
"""A Stock-only user (can submit Stock Entries but has no Subcontracting Order write) must be
|
||||
able to submit and cancel a 'Send to Subcontractor' Stock Entry. The SCO status update on the
|
||||
|
||||
Reference in New Issue
Block a user