mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-29 07:28:17 +00:00
Compare commits
147 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 | ||
|
|
a5de60c357 | ||
|
|
264bfa188b | ||
|
|
2c8c076f6e | ||
|
|
a0ac2a58dd | ||
|
|
5930ce623c | ||
|
|
3a196ae8e7 | ||
|
|
beeffee8f9 | ||
|
|
0f252542c3 | ||
|
|
92217b2c45 | ||
|
|
a4bf50656a | ||
|
|
6b3b03fcd8 | ||
|
|
c9394c030f | ||
|
|
429b58b833 | ||
|
|
e0d0bf07c8 | ||
|
|
a26296ca50 | ||
|
|
8a320fc0af | ||
|
|
f5bb4052fa | ||
|
|
0bdf258888 | ||
|
|
fe65882e59 | ||
|
|
a38cbfc88e | ||
|
|
942a8db7fa | ||
|
|
b7cf3bf641 | ||
|
|
0d53f1adb7 | ||
|
|
fcee4f128e | ||
|
|
e2be05e48b | ||
|
|
a9c3a00dc4 | ||
|
|
d70e005412 | ||
|
|
50c79c82bb | ||
|
|
c466b49d09 | ||
|
|
32baf6a47c | ||
|
|
406d3b3862 | ||
|
|
2852671cd5 | ||
|
|
a3bfdede06 | ||
|
|
34a65e5dba | ||
|
|
ec0da0f113 | ||
|
|
aa08f753b4 | ||
|
|
c3aea9ca9c | ||
|
|
025b2f2922 | ||
|
|
5f6952b15c | ||
|
|
3948951904 | ||
|
|
915562c7a8 | ||
|
|
dfd05caeb0 | ||
|
|
827831a247 | ||
|
|
e1ebfa7163 | ||
|
|
c055de2da6 | ||
|
|
c5d94d5fc3 | ||
|
|
74451366c2 | ||
|
|
134d63de78 | ||
|
|
8946fc41a2 | ||
|
|
cbd4f93f3d | ||
|
|
0817d1064c | ||
|
|
b1adec7e9e | ||
|
|
caea21208e | ||
|
|
2808b34d42 | ||
|
|
7ccb2584c4 | ||
|
|
f3e1b3fca7 | ||
|
|
555c607f2f | ||
|
|
7cd7e4ab0f | ||
|
|
839fd5e3b6 | ||
|
|
56bbca0203 | ||
|
|
478719b828 | ||
|
|
57a49ce168 | ||
|
|
6d47c51c5b | ||
|
|
6ab1668b37 | ||
|
|
b256c76c3b | ||
|
|
e24ef847dc | ||
|
|
20f6ac81b9 | ||
|
|
02549ef8c4 | ||
|
|
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.28.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."
|
||||
|
||||
@@ -120,6 +120,7 @@ class Account(NestedSet):
|
||||
self.validate_account_currency()
|
||||
self.validate_root_company_and_sync_account_to_children()
|
||||
self.validate_receivable_payable_account_type()
|
||||
self.validate_stock_account_type_change()
|
||||
|
||||
def validate_parent_child_account_type(self):
|
||||
if self.parent_account:
|
||||
@@ -208,6 +209,36 @@ class Account(NestedSet):
|
||||
frappe.msgprint(msg)
|
||||
self.add_comment("Comment", msg)
|
||||
|
||||
def validate_stock_account_type_change(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not (doc_before_save and doc_before_save.account_type == "Stock"):
|
||||
return
|
||||
|
||||
if self.account_type == "Stock":
|
||||
return
|
||||
|
||||
if self.stock_ledger_entry_exists():
|
||||
frappe.throw(
|
||||
_(
|
||||
"The account type of {0} cannot be changed from {1} because stock ledger entries exist against it."
|
||||
).format(frappe.bold(self.name), frappe.bold(_("Stock")))
|
||||
)
|
||||
|
||||
def stock_ledger_entry_exists(self):
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
|
||||
warehouse_account = get_warehouse_account_map(self.company)
|
||||
warehouses = [wh for wh, details in warehouse_account.items() if details.account == self.name]
|
||||
if not warehouses:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
frappe.db.count(
|
||||
"Stock Ledger Entry",
|
||||
filters={"warehouse": ("in", warehouses), "is_cancelled": 0},
|
||||
)
|
||||
)
|
||||
|
||||
def validate_root_details(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
|
||||
|
||||
@@ -307,6 +307,31 @@ class TestAccount(ERPNextTestSuite):
|
||||
acc.account_currency = "USD"
|
||||
self.assertRaises(frappe.ValidationError, acc.save)
|
||||
|
||||
def test_stock_account_type_change_with_ledger_entries(self):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
|
||||
|
||||
company = "_Test Company with perpetual inventory"
|
||||
warehouse = "Stores - TCP1"
|
||||
stock_account = get_warehouse_account(frappe.get_doc("Warehouse", warehouse))
|
||||
|
||||
make_stock_entry(
|
||||
item_code="_Test Item",
|
||||
target=warehouse,
|
||||
company=company,
|
||||
qty=5,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
account = frappe.get_doc("Account", stock_account)
|
||||
self.assertEqual(account.account_type, "Stock")
|
||||
|
||||
account.account_type = ""
|
||||
self.assertRaises(frappe.ValidationError, account.save)
|
||||
|
||||
account.reload()
|
||||
account.account_name = f"{account.account_name} Updated"
|
||||
account.save() # non-type change stays allowed
|
||||
|
||||
def test_account_balance(self):
|
||||
from erpnext.accounts.utils import get_balance_on
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,6 +218,7 @@ def build_forest(data):
|
||||
for row in data:
|
||||
account_name, parent_account, account_number, parent_account_number = row[0:4]
|
||||
if account_number:
|
||||
account_number = cstr(account_number).strip()
|
||||
account_name = f"{account_number} - {account_name}"
|
||||
if parent_account_number:
|
||||
parent_account_number = cstr(parent_account_number).strip()
|
||||
|
||||
@@ -169,23 +169,10 @@ frappe.ui.form.on("Dunning", {
|
||||
},
|
||||
get_dunning_letter_text: function (frm) {
|
||||
if (frm.doc.dunning_type) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.dunning.dunning.get_dunning_letter_text",
|
||||
args: {
|
||||
dunning_type: frm.doc.dunning_type,
|
||||
language: frm.doc.language,
|
||||
doc: frm.doc,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.set_value("body_text", r.message.body_text);
|
||||
frm.set_value("closing_text", r.message.closing_text);
|
||||
frm.set_value("language", r.message.language);
|
||||
} else {
|
||||
frm.set_value("body_text", "");
|
||||
frm.set_value("closing_text", "");
|
||||
}
|
||||
},
|
||||
frm.call("get_dunning_letter_text").then((r) => {
|
||||
if (!r.exc) {
|
||||
frm.refresh_fields();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -163,6 +163,46 @@ class Dunning(AccountsController):
|
||||
"Serial and Batch Bundle",
|
||||
]
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dunning_letter_text(self):
|
||||
DOCTYPE = "Dunning Letter Text"
|
||||
FIELDS = ["body_text", "closing_text", "language"]
|
||||
|
||||
if not self.dunning_type:
|
||||
return
|
||||
|
||||
filters = {"parent": self.dunning_type, "is_default_language": 1}
|
||||
|
||||
if self.language:
|
||||
filters.pop("is_default_language")
|
||||
filters["language"] = self.language
|
||||
|
||||
letter_text = frappe.db.get_value(DOCTYPE, filters, FIELDS, as_dict=True)
|
||||
|
||||
if not letter_text:
|
||||
msg = (
|
||||
_("Dunning Letter for Dunning Type {0} in language '{1}' not found.").format(
|
||||
frappe.bold(self.dunning_type), frappe.bold(self.language)
|
||||
)
|
||||
if self.language
|
||||
else _("Dunning Letter for Dunning Type {0} not found.").format(
|
||||
frappe.bold(self.dunning_type)
|
||||
)
|
||||
)
|
||||
frappe.msgprint(msg, alert=True, indicator="yellow")
|
||||
|
||||
self.body_text = (
|
||||
frappe.render_template(letter_text.body_text, self.as_dict(), restrict_globals=True)
|
||||
if letter_text
|
||||
else None
|
||||
)
|
||||
self.closing_text = (
|
||||
frappe.render_template(letter_text.closing_text, self.as_dict(), restrict_globals=True)
|
||||
if letter_text
|
||||
else None
|
||||
)
|
||||
self.language = letter_text.language if letter_text else self.language
|
||||
|
||||
|
||||
def update_linked_dunnings(doc, previous_outstanding_amount):
|
||||
if (
|
||||
@@ -241,35 +281,3 @@ def get_linked_dunnings_as_per_state(sales_invoice, state):
|
||||
& (overdue_payment.sales_invoice == sales_invoice)
|
||||
)
|
||||
).run(as_dict=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_dunning_letter_text(dunning_type: str, doc: str | dict, language: str | None = None) -> dict:
|
||||
DOCTYPE = "Dunning Letter Text"
|
||||
FIELDS = ["body_text", "closing_text", "language"]
|
||||
|
||||
if isinstance(doc, str):
|
||||
doc = json.loads(doc)
|
||||
|
||||
if not language:
|
||||
language = doc.get("language")
|
||||
|
||||
letter_text = None
|
||||
if language:
|
||||
letter_text = frappe.db.get_value(
|
||||
DOCTYPE, {"parent": dunning_type, "language": language}, FIELDS, as_dict=1
|
||||
)
|
||||
|
||||
if not letter_text:
|
||||
letter_text = frappe.db.get_value(
|
||||
DOCTYPE, {"parent": dunning_type, "is_default_language": 1}, FIELDS, as_dict=1
|
||||
)
|
||||
|
||||
if not letter_text:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"body_text": frappe.render_template(letter_text.body_text, doc),
|
||||
"closing_text": frappe.render_template(letter_text.closing_text, doc),
|
||||
"language": letter_text.language,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import comma_and
|
||||
from frappe.utils.jinja import validate_template
|
||||
|
||||
|
||||
class DunningType(Document):
|
||||
@@ -30,3 +33,134 @@ class DunningType(Document):
|
||||
def autoname(self):
|
||||
company_abbr = frappe.get_value("Company", self.company, "abbr")
|
||||
self.name = f"{self.dunning_type} - {company_abbr}"
|
||||
|
||||
def validate(self):
|
||||
self.validate_dunning_letter_text()
|
||||
self.validate_income_account()
|
||||
self.validate_cost_center()
|
||||
self.set_default_dunning_type()
|
||||
|
||||
def validate_dunning_letter_text(self):
|
||||
self.validate_languages()
|
||||
self.validate_is_default_language()
|
||||
self.validate_dunning_letter_text_templates()
|
||||
|
||||
def validate_income_account(self):
|
||||
if not self.income_account:
|
||||
return
|
||||
|
||||
account = frappe.get_cached_doc("Account", self.income_account)
|
||||
|
||||
msg = []
|
||||
if account.company != self.company:
|
||||
msg.append(
|
||||
_(
|
||||
"{0} doesn't belong to Company {1}. Please select an Income Account that belongs to Company {1}."
|
||||
).format(frappe.bold(self.income_account), frappe.bold(self.company))
|
||||
)
|
||||
|
||||
if account.disabled:
|
||||
msg.append(
|
||||
_("{0} is disabled. Please select a valid Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if account.root_type != "Income":
|
||||
msg.append(
|
||||
_("{0} is not an Income Account. Please select a valid Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if account.is_group:
|
||||
msg.append(
|
||||
_("{0} is a group account. Please select a non-group Income Account.").format(
|
||||
frappe.bold(self.income_account)
|
||||
)
|
||||
)
|
||||
|
||||
if msg:
|
||||
frappe.msgprint(
|
||||
msg,
|
||||
title=_("Income Account Validation Error"),
|
||||
as_list=True,
|
||||
raise_exception=frappe.ValidationError,
|
||||
)
|
||||
|
||||
def validate_cost_center(self):
|
||||
if not self.cost_center:
|
||||
return
|
||||
|
||||
cost_center = frappe.get_cached_doc("Cost Center", self.cost_center)
|
||||
|
||||
msg = []
|
||||
if cost_center.company != self.company:
|
||||
msg.append(
|
||||
_(
|
||||
"{0} doesn't belong to Company {1}. Please select a Cost Center that belongs to Company {1}."
|
||||
).format(frappe.bold(self.cost_center), frappe.bold(self.company))
|
||||
)
|
||||
|
||||
if cost_center.disabled:
|
||||
msg.append(
|
||||
_("{0} is disabled. Please select an enabled Cost Center.").format(
|
||||
frappe.bold(self.cost_center)
|
||||
)
|
||||
)
|
||||
|
||||
if cost_center.is_group:
|
||||
msg.append(
|
||||
_("{0} is a group Cost Center. Please select a non-group Cost Center.").format(
|
||||
frappe.bold(self.cost_center)
|
||||
)
|
||||
)
|
||||
|
||||
if msg:
|
||||
frappe.msgprint(
|
||||
msg,
|
||||
title=_("Cost Center Validation Error"),
|
||||
as_list=True,
|
||||
raise_exception=frappe.ValidationError,
|
||||
)
|
||||
|
||||
def validate_languages(self):
|
||||
languages = [d.language for d in self.dunning_letter_text]
|
||||
|
||||
if len(languages) == len(set(languages)):
|
||||
return
|
||||
|
||||
frappe.throw(_("Duplicate languages found on Dunning Letter Text. Keep only one of them."))
|
||||
|
||||
def validate_is_default_language(self):
|
||||
is_default_language_list = [
|
||||
d.language for d in self.dunning_letter_text if d.is_default_language == 1
|
||||
]
|
||||
|
||||
if len(is_default_language_list) <= 1:
|
||||
return
|
||||
|
||||
frappe.throw(
|
||||
_("{0} languages are marked as default languages. Please select only one of them.").format(
|
||||
comma_and(is_default_language_list, add_quotes=True)
|
||||
)
|
||||
)
|
||||
|
||||
def validate_dunning_letter_text_templates(self):
|
||||
for d in self.dunning_letter_text:
|
||||
if d.body_text:
|
||||
validate_template(d.body_text, restrict_globals=True)
|
||||
|
||||
if d.closing_text:
|
||||
validate_template(d.closing_text, restrict_globals=True)
|
||||
|
||||
def set_default_dunning_type(self):
|
||||
if self.is_default != 1:
|
||||
return
|
||||
|
||||
frappe.db.set_value(
|
||||
"Dunning Type",
|
||||
{"company": self.company, "is_default": 1, "name": ["!=", self.name]},
|
||||
"is_default",
|
||||
0,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,200 @@
|
||||
# Copyright (c) 2020, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
def make_dunning_type(dunning_type, company="_Test Company", **kwargs):
|
||||
doc = frappe.new_doc("Dunning Type")
|
||||
doc.dunning_type = dunning_type
|
||||
doc.company = company
|
||||
doc.dunning_fee = kwargs.get("dunning_fee", 100)
|
||||
doc.rate_of_interest = kwargs.get("rate_of_interest", 5)
|
||||
doc.is_default = kwargs.get("is_default", 0)
|
||||
|
||||
if "income_account" in kwargs:
|
||||
doc.income_account = kwargs["income_account"]
|
||||
elif kwargs.get("income_account") is not False:
|
||||
doc.income_account = "Sales - _TC" if company == "_Test Company" else "Sales - _TC1"
|
||||
|
||||
if "cost_center" in kwargs:
|
||||
doc.cost_center = kwargs["cost_center"]
|
||||
elif kwargs.get("cost_center") is not False:
|
||||
doc.cost_center = "Main - _TC" if company == "_Test Company" else "Main - _TC1"
|
||||
|
||||
for row in kwargs.get("dunning_letter_text", [{"language": "en", "body_text": "Test body"}]):
|
||||
doc.append("dunning_letter_text", row)
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
class TestDunningType(ERPNextTestSuite):
|
||||
pass
|
||||
def test_income_account_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company Account", income_account="Sales - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_disabled(self):
|
||||
disabled_account = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Account",
|
||||
"account_name": "_Test Disabled Income Account",
|
||||
"parent_account": "Direct Income - _TC",
|
||||
"company": "_Test Company",
|
||||
"account_type": "Income Account",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled Account", income_account=disabled_account.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_income_account_must_be_income_type(self):
|
||||
doc = make_dunning_type("_Test Dunning Non Income Account", income_account="Debtors - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is not an Income Account", doc.insert)
|
||||
|
||||
def test_income_account_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group Account", income_account="Income - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group account", doc.insert)
|
||||
|
||||
def test_income_account_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No Income Account", income_account=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.income_account)
|
||||
|
||||
def test_valid_income_account_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid Income Account", income_account="Sales - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.income_account, "Sales - _TC")
|
||||
|
||||
def test_cost_center_must_belong_to_company(self):
|
||||
doc = make_dunning_type("_Test Dunning Wrong Company CC", cost_center="Main - _TC1")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "doesn't belong to Company", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_disabled(self):
|
||||
disabled_cc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Cost Center",
|
||||
"cost_center_name": "_Test Disabled Cost Center",
|
||||
"parent_cost_center": "_Test Company - _TC",
|
||||
"company": "_Test Company",
|
||||
"disabled": 1,
|
||||
}
|
||||
).insert()
|
||||
|
||||
doc = make_dunning_type("_Test Dunning Disabled CC", cost_center=disabled_cc.name)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is disabled", doc.insert)
|
||||
|
||||
def test_cost_center_must_not_be_group(self):
|
||||
doc = make_dunning_type("_Test Dunning Group CC", cost_center="_Test Company - _TC")
|
||||
self.assertRaisesRegex(frappe.ValidationError, "is a group Cost Center", doc.insert)
|
||||
|
||||
def test_cost_center_is_optional(self):
|
||||
doc = make_dunning_type("_Test Dunning No CC", cost_center=False)
|
||||
doc.insert()
|
||||
self.assertFalse(doc.cost_center)
|
||||
|
||||
def test_valid_cost_center_passes(self):
|
||||
doc = make_dunning_type("_Test Dunning Valid CC", cost_center="Main - _TC")
|
||||
doc.insert()
|
||||
self.assertEqual(doc.cost_center, "Main - _TC")
|
||||
|
||||
def test_duplicate_languages_not_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Duplicate Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "en", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Duplicate languages found", doc.insert)
|
||||
|
||||
def test_unique_languages_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Unique Languages",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one"},
|
||||
{"language": "de", "body_text": "Body two"},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(len(doc.dunning_letter_text), 2)
|
||||
|
||||
def test_only_one_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Multiple Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 1},
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(
|
||||
frappe.ValidationError, "languages are marked as default languages", doc.insert
|
||||
)
|
||||
|
||||
def test_single_default_language_allowed(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Single Default Language",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Body one", "is_default_language": 1},
|
||||
{"language": "de", "body_text": "Body two", "is_default_language": 0},
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertEqual(doc.dunning_letter_text[0].is_default_language, 1)
|
||||
|
||||
def test_invalid_jinja_template_in_body_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Body Template",
|
||||
dunning_letter_text=[{"language": "en", "body_text": "{{ unclosed"}],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_invalid_jinja_template_in_closing_text_raises(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Invalid Closing Template",
|
||||
dunning_letter_text=[
|
||||
{"language": "en", "body_text": "Valid body", "closing_text": "{{ unclosed"}
|
||||
],
|
||||
)
|
||||
self.assertRaisesRegex(frappe.ValidationError, "Syntax error in template", doc.insert)
|
||||
|
||||
def test_valid_jinja_template_passes(self):
|
||||
doc = make_dunning_type(
|
||||
"_Test Dunning Valid Template",
|
||||
dunning_letter_text=[
|
||||
{
|
||||
"language": "en",
|
||||
"body_text": "Outstanding amount is {{ outstanding_amount }}",
|
||||
"closing_text": "Regards, {{ company }}",
|
||||
}
|
||||
],
|
||||
)
|
||||
doc.insert()
|
||||
self.assertTrue(doc.name)
|
||||
|
||||
def test_set_default_dunning_type_unsets_previous_default(self):
|
||||
first = make_dunning_type("_Test Dunning Default One", is_default=1)
|
||||
first.insert()
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 1)
|
||||
|
||||
second = make_dunning_type("_Test Dunning Default Two", is_default=1)
|
||||
second.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", first.name, "is_default"), 0)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", second.name, "is_default"), 1)
|
||||
|
||||
def test_set_default_dunning_type_scoped_per_company(self):
|
||||
company_1 = make_dunning_type("_Test Dunning Default Co1", is_default=1)
|
||||
company_1.insert()
|
||||
|
||||
company_2 = make_dunning_type(
|
||||
"_Test Dunning Default Co2",
|
||||
company="_Test Company 1",
|
||||
is_default=1,
|
||||
)
|
||||
company_2.insert()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_1.name, "is_default"), 1)
|
||||
self.assertEqual(frappe.db.get_value("Dunning Type", company_2.name, "is_default"), 1)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"section_break_mjlv",
|
||||
"due_date",
|
||||
"column_break_qghl",
|
||||
"amount"
|
||||
"amount",
|
||||
"currency"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -55,8 +56,18 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Amount",
|
||||
"options": "currency",
|
||||
"precision": "2"
|
||||
},
|
||||
{
|
||||
"fieldname": "currency",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Currency",
|
||||
"options": "Currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_lnjp",
|
||||
"fieldtype": "Column Break"
|
||||
@@ -74,7 +85,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-01-19 02:21:36.455830",
|
||||
"modified": "2026-07-11 00:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Reference",
|
||||
|
||||
@@ -784,6 +784,7 @@ def set_payment_references(payment_schedules):
|
||||
"description": row.get("description"),
|
||||
"due_date": row.get("due_date"),
|
||||
"amount": row.get("payment_amount"),
|
||||
"currency": row.get("currency"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,9 +99,9 @@ class ProcessStatementOfAccounts(Document):
|
||||
if not self.pdf_name:
|
||||
self.pdf_name = "{{ customer.customer_name }}"
|
||||
|
||||
validate_template(self.subject)
|
||||
validate_template(self.body)
|
||||
validate_template(self.pdf_name)
|
||||
validate_template(self.subject, restrict_globals=True)
|
||||
validate_template(self.body, restrict_globals=True)
|
||||
validate_template(self.pdf_name, restrict_globals=True)
|
||||
|
||||
if not self.customers:
|
||||
frappe.throw(_("Customers not selected."))
|
||||
@@ -527,15 +527,15 @@ def send_emails(document_name, from_scheduler=False, posting_date=None):
|
||||
if report:
|
||||
for customer, report_pdf in report.items():
|
||||
context = get_context(customer, doc)
|
||||
filename = frappe.render_template(doc.pdf_name, context)
|
||||
filename = frappe.render_template(doc.pdf_name, context, restrict_globals=True)
|
||||
attachments = [{"fname": filename + ".pdf", "fcontent": report_pdf}]
|
||||
|
||||
recipients, cc = get_recipients_and_cc(customer, doc)
|
||||
if not recipients:
|
||||
continue
|
||||
|
||||
subject = frappe.render_template(doc.subject, context)
|
||||
message = frappe.render_template(doc.body, context)
|
||||
subject = frappe.render_template(doc.subject, context, restrict_globals=True)
|
||||
message = frappe.render_template(doc.body, context, restrict_globals=True)
|
||||
|
||||
if doc.sender:
|
||||
sender_email = frappe.db.get_value("Email Account", doc.sender, "email_id")
|
||||
|
||||
@@ -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)
|
||||
@@ -3156,8 +3162,6 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
|
||||
from frappe.model.mapper import get_mapped_doc
|
||||
|
||||
def postprocess_dunning(source, target):
|
||||
from erpnext.accounts.doctype.dunning.dunning import get_dunning_letter_text
|
||||
|
||||
dunning_type = frappe.db.exists("Dunning Type", {"is_default": 1, "company": source.company})
|
||||
if dunning_type:
|
||||
dunning_type = frappe.get_doc("Dunning Type", dunning_type)
|
||||
@@ -3166,14 +3170,8 @@ def create_dunning(source_name, target_doc=None, ignore_permissions=False):
|
||||
target.dunning_fee = dunning_type.dunning_fee
|
||||
target.income_account = dunning_type.income_account
|
||||
target.cost_center = dunning_type.cost_center
|
||||
letter_text = get_dunning_letter_text(
|
||||
dunning_type=dunning_type.name, doc=target.as_dict(), language=source.language
|
||||
)
|
||||
|
||||
if letter_text:
|
||||
target.body_text = letter_text.get("body_text")
|
||||
target.closing_text = letter_text.get("closing_text")
|
||||
target.language = letter_text.get("language")
|
||||
target.language = source.language
|
||||
target.get_dunning_letter_text()
|
||||
|
||||
# update outstanding from doc
|
||||
if source.payment_schedule and len(source.payment_schedule) == 1:
|
||||
|
||||
@@ -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"]]
|
||||
|
||||
@@ -923,8 +923,10 @@
|
||||
"fieldname": "job_card",
|
||||
"fieldtype": "Link",
|
||||
"label": "Job Card",
|
||||
"no_copy": 1,
|
||||
"options": "Job Card",
|
||||
"search_index": 1
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "distributed_discount_amount",
|
||||
@@ -951,7 +953,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-14 12:16:16.192936",
|
||||
"modified": "2026-07-15 10:30:04.600510",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order Item",
|
||||
|
||||
@@ -16,7 +16,10 @@ from erpnext.accounts.party import (
|
||||
validate_party_accounts,
|
||||
validate_party_currency_before_merging,
|
||||
)
|
||||
from erpnext.controllers.website_list_for_contact import add_role_for_portal_user
|
||||
from erpnext.controllers.website_list_for_contact import (
|
||||
add_role_for_portal_user,
|
||||
link_portal_users_to_contacts,
|
||||
)
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
|
||||
@@ -109,6 +112,7 @@ class Supplier(TransactionBase):
|
||||
def on_update(self):
|
||||
self.create_primary_contact()
|
||||
self.create_primary_address()
|
||||
link_portal_users_to_contacts(self)
|
||||
|
||||
def add_role_for_user(self):
|
||||
for portal_user in self.portal_users:
|
||||
|
||||
@@ -203,3 +203,24 @@ class TestSupplierPortal(ERPNextTestSuite):
|
||||
_, suppliers = get_customers_suppliers("Purchase Order", user)
|
||||
|
||||
self.assertIn(supplier.name, suppliers)
|
||||
|
||||
def test_portal_user_contact_link(self):
|
||||
user_email = frappe.generate_hash() + "@example.com"
|
||||
user = frappe.new_doc("User")
|
||||
user.email = user_email
|
||||
user.first_name = "Test Portal Contact User"
|
||||
user.send_welcome_email = False
|
||||
user.insert(ignore_permissions=True)
|
||||
|
||||
contact = frappe.new_doc("Contact")
|
||||
contact.first_name = "Test Portal Contact User"
|
||||
contact.add_email(user_email, is_primary=1)
|
||||
contact.links = []
|
||||
contact.insert(ignore_permissions=True)
|
||||
|
||||
supplier = create_supplier()
|
||||
supplier.append("portal_users", {"user": user.name})
|
||||
supplier.save()
|
||||
|
||||
contact.reload()
|
||||
self.assertTrue(contact.has_link("Supplier", supplier.name))
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"fieldname": "net_rate",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Net Rate",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -613,7 +614,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-17 12:05:52.441645",
|
||||
"modified": "2026-07-15 10:33:24.855979",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation Item",
|
||||
|
||||
@@ -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")
|
||||
@@ -1347,66 +1432,64 @@ class StockController(AccountsController):
|
||||
if not batches:
|
||||
return
|
||||
|
||||
field_mapper = {
|
||||
"Sales Invoice": [["Sales Order", "sales_order"]],
|
||||
"Delivery Note": [["Sales Order", "against_sales_order"]],
|
||||
"Stock Entry": [
|
||||
["Work Order", "work_order"],
|
||||
["Subcontracting Inward Order", "subcontracting_inward_order"],
|
||||
],
|
||||
reference_fields = {
|
||||
"Sales Invoice": ["sales_order"],
|
||||
"Delivery Note": ["against_sales_order"],
|
||||
"Stock Entry": ["work_order", "subcontracting_inward_order"],
|
||||
}.get(self.doctype)
|
||||
|
||||
qty_field = {
|
||||
"Sales Invoice": "qty",
|
||||
"Delivery Note": "qty",
|
||||
"Stock Entry": "fg_completed_qty",
|
||||
}.get(self.doctype)
|
||||
|
||||
reserved_batches_data = self.get_reserved_batches(batches)
|
||||
items = self.items
|
||||
if self.doctype == "Stock Entry":
|
||||
items = [self]
|
||||
|
||||
for item in items:
|
||||
for field in field_mapper:
|
||||
if not item.get(field[1]):
|
||||
continue
|
||||
own_vouchers = {item.get(field) for item in items for field in reference_fields if item.get(field)}
|
||||
|
||||
value = item.get(field[1])
|
||||
for row in reserved_batches_data:
|
||||
if self.doctype in ["Sales Invoice", "Delivery Note"] and row.item_code != item.get(
|
||||
"item_code"
|
||||
):
|
||||
continue
|
||||
outstanding_qty = defaultdict(float)
|
||||
reservations = defaultdict(list)
|
||||
for row in self.get_reserved_batches(batches):
|
||||
if row.voucher_no in own_vouchers:
|
||||
continue
|
||||
|
||||
if row.voucher_no == value:
|
||||
continue
|
||||
key = (row.batch_no, row.warehouse)
|
||||
outstanding = flt(row.qty) - flt(row.delivered_qty)
|
||||
outstanding_qty[key] += outstanding
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
row.batch_no,
|
||||
row.warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "qty")
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, precision) <= 0:
|
||||
continue
|
||||
|
||||
if item.get(qty_field) < batch_qty:
|
||||
continue
|
||||
batch_qty = get_batch_qty(
|
||||
batch_no,
|
||||
warehouse,
|
||||
posting_date=self.posting_date,
|
||||
posting_time=self.posting_time,
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is already reserved in {1} {2}. So, cannot proceed with the {3} {4}, which is created against the {5} {6}."
|
||||
).format(
|
||||
frappe.bold(row.batch_no),
|
||||
frappe.bold(row.voucher_type),
|
||||
frappe.bold(row.voucher_no),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
frappe.bold(field[0]),
|
||||
frappe.bold(value),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
f"{frappe.bold(voucher_type)} {frappe.bold(voucher_no)}"
|
||||
for voucher_type, voucher_no in dict.fromkeys(
|
||||
(row.voucher_type, row.voucher_no) for row in reservations[(batch_no, warehouse)]
|
||||
)
|
||||
)
|
||||
frappe.throw(
|
||||
_(
|
||||
"The batch {0} is reserved for {1} in the warehouse {2} and the remaining quantity is not enough to cover the reservations. So, cannot proceed with the {3} {4}."
|
||||
).format(
|
||||
frappe.bold(batch_no),
|
||||
vouchers,
|
||||
frappe.bold(warehouse),
|
||||
frappe.bold(self.doctype),
|
||||
frappe.bold(self.name),
|
||||
),
|
||||
title=_("Reserved Batch Conflict"),
|
||||
)
|
||||
|
||||
def get_reserved_batches(self, batches):
|
||||
doctype = frappe.qb.DocType("Stock Reservation Entry")
|
||||
@@ -1418,9 +1501,10 @@ class StockController(AccountsController):
|
||||
.on(doctype.name == child_doc.parent)
|
||||
.select(
|
||||
child_doc.batch_no,
|
||||
child_doc.qty,
|
||||
child_doc.delivered_qty,
|
||||
doctype.voucher_type,
|
||||
doctype.voucher_no,
|
||||
doctype.item_code,
|
||||
doctype.warehouse,
|
||||
)
|
||||
.where((doctype.docstatus == 1) & (child_doc.batch_no.isin(batches)))
|
||||
@@ -2057,7 +2141,7 @@ class StockController(AccountsController):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
def show_accounting_ledger_preview(company: str, doctype: str, docname: str):
|
||||
filters = frappe._dict(company=company, include_dimensions=1)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
@@ -2071,7 +2155,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
def show_stock_ledger_preview(company: str, doctype: str, docname: str):
|
||||
filters = frappe._dict(company=company)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
@@ -2569,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
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import json
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.modules.utils import get_module_app
|
||||
from frappe.query_builder import Criterion
|
||||
from frappe.query_builder.functions import Lower
|
||||
from frappe.utils import cint, flt, has_common
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
@@ -306,3 +308,63 @@ def add_role_for_portal_user(portal_user, role):
|
||||
|
||||
user_doc.add_roles(role)
|
||||
frappe.msgprint(_("Added {1} Role to User {0}.").format(frappe.bold(user_doc.name), role), alert=True)
|
||||
|
||||
|
||||
def link_portal_users_to_contacts(doc):
|
||||
"""When portal users are added to Supplier/Customer, link them to the Contact profile."""
|
||||
# a User's name is its (lowercased) email, so portal_users are already the emails
|
||||
portal_users = {p.user for p in doc.get("portal_users") or [] if p.user}
|
||||
if not portal_users:
|
||||
return
|
||||
|
||||
before = doc.get_doc_before_save()
|
||||
if before:
|
||||
previous_users = {p.user for p in before.get("portal_users") or [] if p.user}
|
||||
if portal_users == previous_users:
|
||||
return
|
||||
|
||||
portal_users = list(portal_users)
|
||||
|
||||
contact = frappe.qb.DocType("Contact")
|
||||
contact_email = frappe.qb.DocType("Contact Email")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(contact)
|
||||
.left_join(contact_email)
|
||||
.on(contact_email.parent == contact.name)
|
||||
.select(contact.name)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
conditions = [
|
||||
contact.user.isin(portal_users),
|
||||
Lower(contact.email_id).isin(portal_users),
|
||||
Lower(contact_email.email_id).isin(portal_users),
|
||||
]
|
||||
|
||||
query = query.where(Criterion.any(conditions))
|
||||
contacts = query.run(pluck=True)
|
||||
|
||||
if not contacts:
|
||||
return
|
||||
|
||||
dynamic_link = frappe.qb.DocType("Dynamic Link")
|
||||
existing_links = (
|
||||
frappe.qb.from_(dynamic_link)
|
||||
.select(dynamic_link.parent)
|
||||
.where(
|
||||
(dynamic_link.parenttype == "Contact")
|
||||
& (dynamic_link.parent.isin(contacts))
|
||||
& (dynamic_link.link_doctype == doc.doctype)
|
||||
& (dynamic_link.link_name == doc.name)
|
||||
)
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
contacts_to_link = [name for name in contacts if name not in existing_links]
|
||||
|
||||
for name in contacts_to_link:
|
||||
contact_doc = frappe.get_doc("Contact", name)
|
||||
if not contact_doc.has_link(doc.doctype, doc.name):
|
||||
contact_doc.append("links", {"link_doctype": doc.doctype, "link_name": doc.name})
|
||||
contact_doc.save(ignore_permissions=True)
|
||||
|
||||
@@ -7,7 +7,11 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"scheduled_time",
|
||||
"column_break_xaox",
|
||||
"status",
|
||||
"created_through_portal",
|
||||
"email_verified",
|
||||
"verification_token",
|
||||
"customer_details_section",
|
||||
"customer_name",
|
||||
"customer_phone_number",
|
||||
@@ -54,7 +58,8 @@
|
||||
"fieldtype": "Datetime",
|
||||
"in_list_view": 1,
|
||||
"label": "Scheduled Time",
|
||||
"reqd": 1
|
||||
"reqd": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
@@ -77,8 +82,8 @@
|
||||
"fieldname": "customer_email",
|
||||
"fieldtype": "Data",
|
||||
"label": "Email",
|
||||
"reqd": 1,
|
||||
"options": "Email"
|
||||
"options": "Email",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "linked_docs_section",
|
||||
@@ -100,13 +105,43 @@
|
||||
"fieldtype": "Dynamic Link",
|
||||
"label": "Party",
|
||||
"options": "appointment_with"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "created_through_portal",
|
||||
"fieldtype": "Check",
|
||||
"label": "Created through Portal",
|
||||
"read_only": 1,
|
||||
"set_only_once": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_xaox",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.created_through_portal === 1;",
|
||||
"fieldname": "email_verified",
|
||||
"fieldtype": "Check",
|
||||
"label": "Email Verified",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "verification_token",
|
||||
"fieldtype": "Data",
|
||||
"label": "Verification Token",
|
||||
"hidden": 1,
|
||||
"read_only": 1,
|
||||
"no_copy": 1,
|
||||
"search_index": 1
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"modified": "2026-06-06 13:05:59.300573",
|
||||
"modified": "2026-07-20 02:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Appointment",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -158,8 +193,9 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,20 @@
|
||||
|
||||
|
||||
from collections import Counter
|
||||
from datetime import timedelta
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.desk.form.assign_to import add as add_assignment
|
||||
from frappe.model.document import Document
|
||||
from frappe.share import add_docshare
|
||||
from frappe.utils import get_url, getdate, now
|
||||
from frappe.utils.verified_command import get_signed_params
|
||||
from frappe.utils import add_to_date, cint, date_diff, get_datetime, get_url, getdate, now, now_datetime
|
||||
from frappe.utils.data import sha256_hash
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
class Appointment(Document):
|
||||
@@ -24,104 +30,227 @@ class Appointment(Document):
|
||||
|
||||
appointment_with: DF.Link | None
|
||||
calendar_event: DF.Link | None
|
||||
created_through_portal: DF.Check
|
||||
customer_details: DF.LongText | None
|
||||
customer_email: DF.Data
|
||||
customer_name: DF.Data
|
||||
customer_phone_number: DF.Data | None
|
||||
customer_skype: DF.Data | None
|
||||
email_verified: DF.Check
|
||||
party: DF.DynamicLink | None
|
||||
scheduled_time: DF.Datetime
|
||||
status: DF.Literal["Open", "Unverified", "Closed"]
|
||||
verification_token: DF.Data | None
|
||||
# end: auto-generated types
|
||||
|
||||
def find_lead_by_email(self):
|
||||
lead_list = frappe.get_list(
|
||||
"Lead", filters={"email_id": self.customer_email}, ignore_permissions=True
|
||||
)
|
||||
if lead_list:
|
||||
return lead_list[0].name
|
||||
return None
|
||||
def validate(self):
|
||||
self.validate_status_update()
|
||||
if not self.has_value_changed("scheduled_time"):
|
||||
return
|
||||
|
||||
def find_customer_by_email(self):
|
||||
customer_list = frappe.get_list(
|
||||
"Customer", filters={"email_id": self.customer_email}, ignore_permissions=True
|
||||
self.validate_backdated_booking()
|
||||
|
||||
if is_appointment_scheduling_enabled():
|
||||
self.validate_advanced_booking()
|
||||
self.validate_holiday()
|
||||
self.validate_slot_timing()
|
||||
|
||||
self.validate_available_time_slot()
|
||||
|
||||
def validate_status_update(self):
|
||||
if not self.has_value_changed("status"):
|
||||
return
|
||||
|
||||
if not self.created_through_portal:
|
||||
if self.status == "Unverified":
|
||||
frappe.throw(_("Appointments created manually cannot have 'Unverified' status."))
|
||||
return
|
||||
|
||||
if self.status == "Unverified" and self.email_verified:
|
||||
frappe.throw(_("A verified appointment cannot be moved back to 'Unverified' status."))
|
||||
|
||||
if self.status == "Open" and not self.email_verified:
|
||||
frappe.throw(
|
||||
_("An appointment booked through the portal can only be opened via email verification.")
|
||||
)
|
||||
|
||||
def validate_backdated_booking(self):
|
||||
if get_datetime(self.scheduled_time) < now_datetime():
|
||||
frappe.throw(_("Appointment cannot be scheduled for a past time."))
|
||||
|
||||
def validate_advanced_booking(self):
|
||||
advance_booking_days = cint(get_booking_settings().advance_booking_days)
|
||||
|
||||
if advance_booking_days and date_diff(self.scheduled_time, now_datetime()) > advance_booking_days:
|
||||
frappe.throw(
|
||||
_("Appointment can only be scheduled up to {0} day(s) in advance.").format(
|
||||
advance_booking_days
|
||||
)
|
||||
)
|
||||
|
||||
def validate_holiday(self):
|
||||
holiday_list = get_booking_settings().holiday_list
|
||||
|
||||
if not holiday_list:
|
||||
frappe.throw(_("Please add a valid Holiday List on Appointment Booking Settings."))
|
||||
|
||||
if is_holiday(holiday_list, getdate(self.scheduled_time)):
|
||||
frappe.throw(_("Appointment cannot be scheduled on a holiday."))
|
||||
|
||||
def validate_slot_timing(self):
|
||||
settings = get_booking_settings()
|
||||
if not settings.availability_of_slots:
|
||||
frappe.throw(_("No availability of slots are found. Please add on Appointment Booking Settings."))
|
||||
|
||||
scheduled_time = get_datetime(self.scheduled_time)
|
||||
day_of_week = WEEKDAYS[scheduled_time.weekday()]
|
||||
slot_start = timedelta(
|
||||
hours=scheduled_time.hour, minutes=scheduled_time.minute, seconds=scheduled_time.second
|
||||
)
|
||||
if customer_list:
|
||||
return customer_list[0].name
|
||||
return None
|
||||
slot_end = slot_start + timedelta(minutes=cint(settings.appointment_duration))
|
||||
|
||||
for slot in settings.availability_of_slots:
|
||||
if slot.day_of_week == day_of_week and slot.from_time <= slot_start and slot_end <= slot.to_time:
|
||||
return
|
||||
|
||||
frappe.throw(_("Appointment must be scheduled within the available slot timings."))
|
||||
|
||||
def validate_available_time_slot(self):
|
||||
settings = get_booking_settings()
|
||||
if not cint(settings.number_of_agents):
|
||||
return
|
||||
|
||||
# the locking read serializes concurrent bookings for the same window,
|
||||
# so two simultaneous requests cannot both pass the capacity check
|
||||
booked = count_overlapping_appointments(
|
||||
self.scheduled_time,
|
||||
cint(settings.appointment_duration),
|
||||
exclude_appointment=self.name,
|
||||
for_update=True,
|
||||
)
|
||||
|
||||
if booked >= cint(settings.number_of_agents):
|
||||
frappe.throw(_("Time slot is not available"))
|
||||
|
||||
def before_insert(self):
|
||||
number_of_appointments_in_same_slot = frappe.db.count(
|
||||
"Appointment", filters={"scheduled_time": self.scheduled_time}
|
||||
)
|
||||
number_of_agents = frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents")
|
||||
if number_of_agents != 0:
|
||||
if number_of_appointments_in_same_slot >= number_of_agents:
|
||||
frappe.throw(_("Time slot is not available"))
|
||||
# Link lead
|
||||
if not self.party:
|
||||
lead = self.find_lead_by_email()
|
||||
customer = self.find_customer_by_email()
|
||||
if customer:
|
||||
self.appointment_with = "Customer"
|
||||
self.party = customer
|
||||
else:
|
||||
self.appointment_with = "Lead"
|
||||
self.party = lead
|
||||
# Set status to "Unverified" for new Appointments.
|
||||
if self.created_through_portal:
|
||||
self.status = "Unverified"
|
||||
return
|
||||
|
||||
self.link_customer_lead()
|
||||
|
||||
def after_insert(self):
|
||||
if self.party:
|
||||
# Create Calendar event
|
||||
if not self.created_through_portal and self.party:
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
else:
|
||||
# Set status to unverified
|
||||
self.db_set("status", "Unverified")
|
||||
# Send email to confirm
|
||||
self.send_confirmation_email()
|
||||
return
|
||||
|
||||
# Send email to confirm
|
||||
self.send_confirmation_email()
|
||||
|
||||
def on_update(self):
|
||||
# capture transitions before nested saves during materialization
|
||||
# refresh the before-save snapshot
|
||||
status_changed = self.has_value_changed("status")
|
||||
email_just_verified = bool(
|
||||
self.created_through_portal and self.email_verified
|
||||
) and self.has_value_changed("email_verified")
|
||||
|
||||
self.link_auto_assign_and_create_calendar_event()
|
||||
|
||||
if email_just_verified:
|
||||
self.send_appointment_confirmed_email()
|
||||
|
||||
if status_changed:
|
||||
self.update_event_and_assignments_status()
|
||||
|
||||
def on_trash(self):
|
||||
# the Event only references the party, not the appointment,
|
||||
# so it must be cleaned up explicitly
|
||||
if not self.calendar_event:
|
||||
return
|
||||
|
||||
event = self.calendar_event
|
||||
self.db_set("calendar_event", None, update_modified=False)
|
||||
frappe.delete_doc("Event", event, ignore_permissions=True)
|
||||
|
||||
def send_confirmation_email(self):
|
||||
verify_url = self._get_verify_url()
|
||||
template = "confirm_appointment"
|
||||
args = {
|
||||
"link": verify_url,
|
||||
"site_url": frappe.utils.get_url(),
|
||||
"full_name": self.customer_name,
|
||||
}
|
||||
self.send_email_to_customer(
|
||||
template="confirm_appointment",
|
||||
subject=_("Appointment Confirmation"),
|
||||
args={"link": self._get_verify_url(), "expiry_minutes": get_verification_link_expiry()},
|
||||
)
|
||||
frappe.msgprint(_("Please check your email to confirm the appointment."))
|
||||
|
||||
def send_appointment_confirmed_email(self):
|
||||
self.send_email_to_customer(
|
||||
template="appointment_confirmed",
|
||||
subject=_("Appointment Confirmed"),
|
||||
args={"scheduled_time": frappe.utils.format_datetime(self.scheduled_time)},
|
||||
reference_doctype="Appointment",
|
||||
reference_name=self.name,
|
||||
)
|
||||
|
||||
def send_email_to_customer(self, template, subject, args, **kwargs):
|
||||
frappe.sendmail(
|
||||
recipients=[self.customer_email],
|
||||
template=template,
|
||||
args=args,
|
||||
subject=_("Appointment Confirmation"),
|
||||
args={"full_name": self.customer_name, "site_url": frappe.utils.get_url(), **args},
|
||||
subject=subject,
|
||||
**kwargs,
|
||||
)
|
||||
if frappe.session.user == "Guest":
|
||||
frappe.msgprint(_("Please check your email to confirm the appointment"))
|
||||
else:
|
||||
frappe.msgprint(
|
||||
_("Appointment was created. But no lead was found. Please check the email to confirm")
|
||||
)
|
||||
|
||||
def on_change(self):
|
||||
# Sync Calendar
|
||||
if not self.calendar_event:
|
||||
def link_auto_assign_and_create_calendar_event(self):
|
||||
if self.is_new() or (self.created_through_portal and not self.email_verified):
|
||||
return
|
||||
|
||||
if not self.calendar_event:
|
||||
# first materialization: link the party, assign an agent, create the event
|
||||
self.link_customer_lead()
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
|
||||
self.sync_calendar_event()
|
||||
|
||||
def sync_calendar_event(self):
|
||||
if not self.calendar_event or not self.has_value_changed("scheduled_time"):
|
||||
return
|
||||
|
||||
cal_event = frappe.get_doc("Event", self.calendar_event)
|
||||
cal_event.starts_on = self.scheduled_time
|
||||
cal_event.save(ignore_permissions=True)
|
||||
|
||||
def set_verified(self, email):
|
||||
if email != self.customer_email:
|
||||
frappe.throw(_("Email verification failed."))
|
||||
# Create new lead
|
||||
def update_event_and_assignments_status(self):
|
||||
"""Close or reopen the calendar event and assignments along with the appointment."""
|
||||
if self.status == "Unverified":
|
||||
return
|
||||
|
||||
is_closed = self.status == "Closed"
|
||||
new_status = "Closed" if is_closed else "Open"
|
||||
|
||||
if self.calendar_event:
|
||||
frappe.db.set_value("Event", self.calendar_event, "status", new_status)
|
||||
|
||||
# only move ToDos between Open and Closed - never touch Cancelled ones
|
||||
todo_filters = {
|
||||
"reference_type": "Appointment",
|
||||
"reference_name": self.name,
|
||||
"status": "Open" if is_closed else "Closed",
|
||||
}
|
||||
frappe.db.set_value("ToDo", todo_filters, "status", new_status)
|
||||
|
||||
def link_customer_lead(self):
|
||||
if not self.party:
|
||||
customer = self.find_party_by_email("Customer")
|
||||
self.appointment_with = "Customer" if customer else "Lead"
|
||||
self.party = customer or self.find_party_by_email("Lead")
|
||||
|
||||
self.create_lead_and_link()
|
||||
# Remove unverified status
|
||||
self.status = "Open"
|
||||
# Create calender event
|
||||
self.auto_assign()
|
||||
self.create_calendar_event()
|
||||
self.save(ignore_permissions=True)
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
|
||||
def find_party_by_email(self, doctype):
|
||||
party = frappe.get_all(doctype, filters={"email_id": self.customer_email}, limit=1, pluck="name")
|
||||
return party[0] if party else None
|
||||
|
||||
def create_lead_and_link(self):
|
||||
# Return if already linked
|
||||
@@ -140,86 +269,39 @@ class Appointment(Document):
|
||||
if self.customer_details:
|
||||
lead.append(
|
||||
"notes",
|
||||
{
|
||||
"note": self.customer_details,
|
||||
"added_by": frappe.session.user,
|
||||
"added_on": now(),
|
||||
},
|
||||
{"note": self.customer_details, "added_by": frappe.session.user, "added_on": now()},
|
||||
)
|
||||
|
||||
lead.insert(ignore_permissions=True)
|
||||
|
||||
# Link lead
|
||||
self.party = lead.name
|
||||
self.party = lead.insert(ignore_permissions=True).name
|
||||
|
||||
def auto_assign(self):
|
||||
existing_assignee = self.get_assignee_from_latest_opportunity()
|
||||
if existing_assignee:
|
||||
# If the latest opportunity is assigned to someone
|
||||
# Assign the appointment to the same
|
||||
self.assign_agent(existing_assignee)
|
||||
return
|
||||
if self._assign:
|
||||
return
|
||||
available_agents = _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time))
|
||||
for agent in available_agents:
|
||||
if _check_agent_availability(agent, self.scheduled_time):
|
||||
self.assign_agent(agent[0])
|
||||
break
|
||||
|
||||
if existing_assignee := self.get_assignee_from_latest_opportunity():
|
||||
# assign to whoever handles the party's latest opportunity
|
||||
self.assign_agent(existing_assignee)
|
||||
return
|
||||
|
||||
busy_agents = get_busy_agents(self.scheduled_time)
|
||||
for agent in _get_agents_sorted_by_asc_workload(getdate(self.scheduled_time)):
|
||||
if agent not in busy_agents:
|
||||
self.assign_agent(agent)
|
||||
break
|
||||
|
||||
def get_assignee_from_latest_opportunity(self):
|
||||
if not self.party:
|
||||
if not self.party or not frappe.db.exists("Lead", self.party):
|
||||
return None
|
||||
if not frappe.db.exists("Lead", self.party):
|
||||
return None
|
||||
opporutnities = frappe.get_list(
|
||||
|
||||
opportunities = frappe.get_all(
|
||||
"Opportunity",
|
||||
filters={
|
||||
"party_name": self.party,
|
||||
},
|
||||
ignore_permissions=True,
|
||||
filters={"party_name": self.party},
|
||||
fields=["_assign"],
|
||||
order_by="creation desc",
|
||||
limit=1,
|
||||
)
|
||||
if not opporutnities:
|
||||
return None
|
||||
latest_opportunity = frappe.get_doc("Opportunity", opporutnities[0].name)
|
||||
assignee = latest_opportunity._assign
|
||||
if not assignee:
|
||||
return None
|
||||
assignee = frappe.parse_json(assignee)[0]
|
||||
return assignee
|
||||
|
||||
def create_calendar_event(self):
|
||||
if self.calendar_event:
|
||||
return
|
||||
appointment_event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": " ".join(["Appointment with", self.customer_name]),
|
||||
"starts_on": self.scheduled_time,
|
||||
"status": "Open",
|
||||
"type": "Public",
|
||||
"send_reminder": frappe.db.get_single_value(
|
||||
"Appointment Booking Settings", "email_reminders"
|
||||
),
|
||||
"event_participants": [
|
||||
dict(reference_doctype=self.appointment_with, reference_docname=self.party)
|
||||
],
|
||||
}
|
||||
)
|
||||
employee = _get_employee_from_user(self._assign)
|
||||
if employee:
|
||||
appointment_event.append(
|
||||
"event_participants", dict(reference_doctype="Employee", reference_docname=employee.name)
|
||||
)
|
||||
appointment_event.insert(ignore_permissions=True)
|
||||
self.calendar_event = appointment_event.name
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
def _get_verify_url(self):
|
||||
verify_route = "/book_appointment/verify"
|
||||
params = {"email": self.customer_email, "appointment": self.name}
|
||||
return get_url(verify_route + "?" + get_signed_params(params))
|
||||
assignees = opportunities and frappe.parse_json(opportunities[0]._assign or "[]")
|
||||
return assignees[0] if assignees else None
|
||||
|
||||
def assign_agent(self, agent):
|
||||
if not frappe.has_permission(doc=self, user=agent):
|
||||
@@ -227,45 +309,157 @@ class Appointment(Document):
|
||||
|
||||
add_assignment({"doctype": self.doctype, "name": self.name, "assign_to": [agent]})
|
||||
|
||||
def create_calendar_event(self):
|
||||
if self.calendar_event:
|
||||
return
|
||||
|
||||
event = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Event",
|
||||
"subject": f"Appointment with {self.customer_name}",
|
||||
"starts_on": self.scheduled_time,
|
||||
"status": "Open",
|
||||
"type": "Public",
|
||||
"send_reminder": cint(get_booking_settings().email_reminders),
|
||||
"event_participants": self.get_event_participants(),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
self.calendar_event = event.name
|
||||
self.save(ignore_permissions=True)
|
||||
|
||||
def get_event_participants(self):
|
||||
participants = [dict(reference_doctype=self.appointment_with, reference_docname=self.party)]
|
||||
|
||||
if employee := _get_employee_from_user(self._assign):
|
||||
participants.append(dict(reference_doctype="Employee", reference_docname=employee.name))
|
||||
|
||||
return participants
|
||||
|
||||
def _get_verify_url(self):
|
||||
key = self.generate_verification_key()
|
||||
return get_url("/book_appointment/verify?" + urlencode({"key": key}))
|
||||
|
||||
def generate_verification_key(self):
|
||||
# store only the hash; the raw key lives solely in the emailed link
|
||||
key = frappe.generate_hash()
|
||||
self.db_set("verification_token", sha256_hash(key), update_modified=False)
|
||||
return key
|
||||
|
||||
|
||||
def get_booking_settings():
|
||||
return frappe.get_cached_doc("Appointment Booking Settings")
|
||||
|
||||
|
||||
def is_appointment_scheduling_enabled():
|
||||
return bool(cint(get_booking_settings().enable_scheduling))
|
||||
|
||||
|
||||
def get_verification_link_expiry():
|
||||
"""Verification link expiry window in minutes."""
|
||||
return cint(get_booking_settings().verification_link_expiry_duration)
|
||||
|
||||
|
||||
def count_overlapping_appointments(
|
||||
scheduled_time, appointment_duration, exclude_appointment=None, for_update=False
|
||||
):
|
||||
"""Count non-Closed appointments whose duration window overlaps `scheduled_time`.
|
||||
With `for_update`, the range stays locked until commit, serializing concurrent bookings."""
|
||||
# select the rows (not COUNT) so `for_update` stays valid: PostgreSQL
|
||||
# rejects `FOR UPDATE` combined with an aggregate function
|
||||
appointment = frappe.qb.DocType("Appointment")
|
||||
query = (
|
||||
frappe.qb.from_(appointment)
|
||||
.select(appointment.name)
|
||||
.where(appointment.scheduled_time > add_to_date(scheduled_time, minutes=-appointment_duration))
|
||||
.where(appointment.scheduled_time < add_to_date(scheduled_time, minutes=appointment_duration))
|
||||
.where(appointment.status != "Closed")
|
||||
)
|
||||
|
||||
if exclude_appointment:
|
||||
query = query.where(appointment.name != exclude_appointment)
|
||||
|
||||
if for_update:
|
||||
query = query.for_update()
|
||||
|
||||
return len(query.run())
|
||||
|
||||
|
||||
def handle_expired_unverified_appointments():
|
||||
"""Close or delete Unverified appointments whose verification link has expired."""
|
||||
expiry = get_verification_link_expiry()
|
||||
if not expiry:
|
||||
return
|
||||
|
||||
cutoff = add_to_date(now_datetime(), minutes=-expiry)
|
||||
filters = {"status": "Unverified", "creation": ("<", cutoff)}
|
||||
action = get_booking_settings().action_for_expired_unverified_appointments or "Mark as Closed"
|
||||
|
||||
if action == "Mark as Closed":
|
||||
frappe.db.set_value("Appointment", filters, "status", "Closed")
|
||||
elif action == "Delete Permanently":
|
||||
for name in frappe.get_all("Appointment", filters=filters, pluck="name"):
|
||||
frappe.delete_doc("Appointment", name, ignore_permissions=True)
|
||||
|
||||
|
||||
def _get_agents_sorted_by_asc_workload(date):
|
||||
appointments = frappe.get_all("Appointment", fields="*")
|
||||
agent_list = _get_agent_list_as_strings()
|
||||
if not appointments:
|
||||
return agent_list
|
||||
appointment_counter = Counter(agent_list)
|
||||
for appointment in appointments:
|
||||
assign_data = appointment._assign
|
||||
if isinstance(assign_data, str):
|
||||
assign_data = assign_data.strip()
|
||||
if not assign_data:
|
||||
continue
|
||||
assigned_to = frappe.parse_json(assign_data)
|
||||
if assigned_to and (assigned_to[0] in agent_list) and getdate(appointment.scheduled_time) == date:
|
||||
appointment_counter[assigned_to[0]] += 1
|
||||
sorted_agent_list = appointment_counter.most_common()
|
||||
sorted_agent_list.reverse()
|
||||
return sorted_agent_list
|
||||
# count only the given day's assignments; scheduled_time is indexed so the
|
||||
# date range is resolved in SQL instead of scanning every appointment ever
|
||||
workload = Counter(agent.user for agent in get_booking_settings().agent_list)
|
||||
assigns = frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["_assign", "is", "set"],
|
||||
["scheduled_time", ">=", getdate(date)],
|
||||
["scheduled_time", "<", add_to_date(getdate(date), days=1)],
|
||||
],
|
||||
pluck="_assign",
|
||||
)
|
||||
|
||||
for assign in assigns:
|
||||
assignees = frappe.parse_json((assign or "").strip() or "[]")
|
||||
if assignees and assignees[0] in workload:
|
||||
workload[assignees[0]] += 1
|
||||
|
||||
return [agent for agent, _workload in reversed(workload.most_common())]
|
||||
|
||||
|
||||
def _get_agent_list_as_strings():
|
||||
agent_list_as_strings = []
|
||||
agent_list = frappe.get_doc("Appointment Booking Settings").agent_list
|
||||
for agent in agent_list:
|
||||
agent_list_as_strings.append(agent.user)
|
||||
return agent_list_as_strings
|
||||
def get_busy_agents(scheduled_time):
|
||||
"""Agents already assigned to a non-Closed appointment overlapping `scheduled_time`."""
|
||||
duration = _get_appointment_duration()
|
||||
assigns = frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["scheduled_time", ">", add_to_date(scheduled_time, minutes=-duration)],
|
||||
["scheduled_time", "<", add_to_date(scheduled_time, minutes=duration)],
|
||||
["status", "!=", "Closed"],
|
||||
],
|
||||
pluck="_assign",
|
||||
)
|
||||
return {assignee for assign in assigns for assignee in frappe.parse_json(assign or "[]")}
|
||||
|
||||
|
||||
def _check_agent_availability(agent_email, scheduled_time):
|
||||
appointemnts_at_scheduled_time = frappe.get_all("Appointment", filters={"scheduled_time": scheduled_time})
|
||||
for appointment in appointemnts_at_scheduled_time:
|
||||
if appointment._assign == agent_email:
|
||||
return False
|
||||
return True
|
||||
return agent_email not in get_busy_agents(scheduled_time)
|
||||
|
||||
|
||||
def get_booked_slot_times(from_time, to_time):
|
||||
"""scheduled_times of non-Closed appointments within (from_time, to_time), for slot availability."""
|
||||
return frappe.get_all(
|
||||
"Appointment",
|
||||
filters=[
|
||||
["scheduled_time", ">", from_time],
|
||||
["scheduled_time", "<", to_time],
|
||||
["status", "!=", "Closed"],
|
||||
],
|
||||
pluck="scheduled_time",
|
||||
)
|
||||
|
||||
|
||||
def _get_appointment_duration():
|
||||
return cint(get_booking_settings().appointment_duration)
|
||||
|
||||
|
||||
def _get_employee_from_user(user):
|
||||
employee_docname = frappe.db.get_value("Employee", {"user_id": user})
|
||||
if employee_docname:
|
||||
return frappe.get_doc("Employee", employee_docname)
|
||||
return None
|
||||
return frappe.get_doc("Employee", employee_docname) if employee_docname else None
|
||||
|
||||
@@ -1,37 +1,167 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
import datetime
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, getdate, now_datetime, set_request
|
||||
from frappe.utils.data import sha256_hash
|
||||
|
||||
from erpnext.crm.doctype.appointment.appointment import (
|
||||
Appointment,
|
||||
_check_agent_availability,
|
||||
handle_expired_unverified_appointments,
|
||||
)
|
||||
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
from erpnext.www.book_appointment.index import create_appointment, get_appointment_slots
|
||||
from erpnext.www.book_appointment.verify import index as verify_index
|
||||
|
||||
LEAD_EMAIL = "test_appointment_lead@example.com"
|
||||
VERIFICATION_EXPIRY_MINUTES = 30
|
||||
ALL_WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def create_test_appointment():
|
||||
test_appointment = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Appointment",
|
||||
"status": "Open",
|
||||
"customer_name": "Test Lead",
|
||||
"customer_phone_number": "666",
|
||||
"customer_skype": "test",
|
||||
"customer_email": LEAD_EMAIL,
|
||||
"scheduled_time": datetime.datetime.now(),
|
||||
"customer_details": "Hello, Friend!",
|
||||
}
|
||||
)
|
||||
def create_test_appointment(**kwargs):
|
||||
args = {
|
||||
"doctype": "Appointment",
|
||||
"status": "Open",
|
||||
"customer_name": "Test Lead",
|
||||
"customer_phone_number": "666",
|
||||
"customer_skype": "test",
|
||||
"customer_email": LEAD_EMAIL,
|
||||
"scheduled_time": add_to_date(now_datetime(), hours=2),
|
||||
"customer_details": "Hello, Friend!",
|
||||
}
|
||||
args.update(kwargs)
|
||||
test_appointment = frappe.get_doc(args)
|
||||
test_appointment.insert()
|
||||
return test_appointment
|
||||
|
||||
|
||||
def create_lead(email, name="Existing Lead"):
|
||||
frappe.db.delete("Lead", {"email_id": email})
|
||||
return frappe.get_doc({"doctype": "Lead", "lead_name": name, "email_id": email}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
|
||||
|
||||
def set_booking_setting(field, value):
|
||||
frappe.db.set_single_value("Appointment Booking Settings", field, value)
|
||||
|
||||
|
||||
def slot_on(days_from_now, hour, minute=0):
|
||||
day = datetime.date.today() + datetime.timedelta(days=days_from_now)
|
||||
return datetime.datetime.combine(day, datetime.time(hour, minute))
|
||||
|
||||
|
||||
def backdate_creation(appointment_name, minutes):
|
||||
frappe.db.set_value(
|
||||
"Appointment",
|
||||
appointment_name,
|
||||
"creation",
|
||||
add_to_date(now_datetime(), minutes=-minutes),
|
||||
update_modified=False,
|
||||
)
|
||||
|
||||
|
||||
def get_status(appointment_name):
|
||||
return frappe.db.get_value("Appointment", appointment_name, "status")
|
||||
|
||||
|
||||
def get_assignees(appointment_name):
|
||||
return frappe.parse_json(frappe.db.get_value("Appointment", appointment_name, "_assign") or "[]")
|
||||
|
||||
|
||||
def get_todo_statuses(appointment_name):
|
||||
return frappe.get_all(
|
||||
"ToDo",
|
||||
filters={"reference_type": "Appointment", "reference_name": appointment_name},
|
||||
pluck="status",
|
||||
)
|
||||
|
||||
|
||||
def parse_verify_url(verify_url):
|
||||
parsed = urlparse(verify_url)
|
||||
return parsed, {key: value[0] for key, value in parse_qs(parsed.query).items()}
|
||||
|
||||
|
||||
class TestAppointment(ERPNextTestSuite):
|
||||
def setUp(self):
|
||||
set_booking_setting("verification_link_expiry_duration", VERIFICATION_EXPIRY_MINUTES)
|
||||
frappe.db.delete("Lead", {"email_id": LEAD_EMAIL})
|
||||
self.test_appointment = create_test_appointment()
|
||||
self.test_appointment.set_verified(self.test_appointment.customer_email)
|
||||
|
||||
def _configure_booking_settings(self, holiday_dates=None, agents=None):
|
||||
holiday_list = make_holiday_list(
|
||||
"_Test Appointment Holiday List",
|
||||
from_date=getdate(),
|
||||
to_date=add_to_date(getdate(), days=60),
|
||||
holiday_dates=holiday_dates or [],
|
||||
)
|
||||
|
||||
settings = frappe.get_doc("Appointment Booking Settings")
|
||||
settings.enable_scheduling = 1
|
||||
settings.enable_appointment_portal = 1
|
||||
settings.appointment_duration = 30
|
||||
settings.advance_booking_days = 30
|
||||
settings.verification_link_expiry_duration = VERIFICATION_EXPIRY_MINUTES
|
||||
settings.holiday_list = holiday_list.name
|
||||
settings.set("agent_list", [])
|
||||
for agent in agents or ["Administrator"]:
|
||||
settings.append("agent_list", {"user": agent})
|
||||
settings.set("availability_of_slots", [])
|
||||
for day in ALL_WEEKDAYS:
|
||||
settings.append(
|
||||
"availability_of_slots", {"day_of_week": day, "from_time": "09:00:00", "to_time": "17:00:00"}
|
||||
)
|
||||
settings.save()
|
||||
|
||||
def _create_portal_appointment(self, email, days_from_now=7, time="10:00:00"):
|
||||
"""Book as Guest. The verification email is mocked and kept on
|
||||
``self._verification_email_mock`` for assertions."""
|
||||
if not getattr(self, "_booking_settings_configured", False):
|
||||
self._configure_booking_settings()
|
||||
self._booking_settings_configured = True
|
||||
|
||||
with self.set_user("Guest"), patch.object(Appointment, "send_confirmation_email") as mock_send:
|
||||
appointment = create_appointment(
|
||||
date=str(datetime.date.today() + datetime.timedelta(days=days_from_now)),
|
||||
time=time,
|
||||
tz="UTC",
|
||||
contact={"name": "Portal Visitor", "email": email, "number": "123", "skype": "", "notes": ""},
|
||||
)
|
||||
self._verification_email_mock = mock_send
|
||||
return appointment
|
||||
|
||||
def _request_verification(self, appointment, verify_url=None):
|
||||
"""Simulate the GET request made by clicking the emailed verification link.
|
||||
|
||||
The confirmation email sent on successful verification is mocked and kept
|
||||
on ``self._confirmed_email_mock`` for assertions.
|
||||
"""
|
||||
parsed, params = parse_verify_url(verify_url or appointment._get_verify_url())
|
||||
|
||||
old_request = getattr(frappe.local, "request", None)
|
||||
old_form_dict = frappe.local.form_dict
|
||||
old_user = frappe.session.user
|
||||
try:
|
||||
# the real link is clicked by an anonymous visitor; set_user resets
|
||||
# form_dict, so switch the user before populating the request
|
||||
frappe.set_user("Guest")
|
||||
set_request(method="GET", path=f"{parsed.path}?{parsed.query}")
|
||||
frappe.local.form_dict = frappe._dict(params)
|
||||
context = frappe._dict()
|
||||
with patch.object(Appointment, "send_appointment_confirmed_email") as mock_confirmed:
|
||||
verify_index.get_context(context)
|
||||
self._confirmed_email_mock = mock_confirmed
|
||||
return context
|
||||
finally:
|
||||
frappe.set_user(old_user)
|
||||
frappe.local.request = old_request
|
||||
frappe.local.form_dict = old_form_dict
|
||||
frappe.local.flags.commit = False
|
||||
|
||||
def test_calendar_event_created(self):
|
||||
cal_event = frappe.get_doc("Event", self.test_appointment.calendar_event)
|
||||
@@ -39,3 +169,371 @@ class TestAppointment(ERPNextTestSuite):
|
||||
|
||||
def test_lead_linked(self):
|
||||
self.assertTrue(self.test_appointment.party)
|
||||
|
||||
def test_desk_created_appointment_skips_email_verification(self):
|
||||
"""Appointments created from the desk (created_through_portal unset) must be
|
||||
linked and confirmed immediately - no verification email should be sent."""
|
||||
with patch.object(Appointment, "send_confirmation_email") as mock_send:
|
||||
appointment = create_test_appointment(customer_email="another_desk_lead@example.com")
|
||||
|
||||
mock_send.assert_not_called()
|
||||
self.assertEqual(appointment.status, "Open")
|
||||
self.assertTrue(appointment.party)
|
||||
frappe.db.delete("Lead", {"email_id": "another_desk_lead@example.com"})
|
||||
|
||||
def test_portal_booking_stays_unverified_for_existing_lead(self):
|
||||
"""A portal booking whose email matches an existing Lead/Customer must NOT
|
||||
be auto-linked - it must stay Unverified until the email is confirmed."""
|
||||
create_lead("existing_lead@example.com")
|
||||
appointment = self._create_portal_appointment("existing_lead@example.com", days_from_now=5)
|
||||
|
||||
self._verification_email_mock.assert_called_once()
|
||||
self.assertTrue(appointment.created_through_portal)
|
||||
self.assertEqual(appointment.status, "Unverified")
|
||||
self.assertFalse(appointment.email_verified)
|
||||
self.assertFalse(appointment.party)
|
||||
|
||||
def test_verify_url_uses_opaque_token(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor@example.com")
|
||||
parsed, params = parse_verify_url(appointment._get_verify_url())
|
||||
|
||||
# the link carries only an opaque key - no email, name or signed params
|
||||
self.assertEqual(set(params), {"key"})
|
||||
self.assertNotIn("email", parsed.query)
|
||||
# only the hash of that key is stored on the appointment
|
||||
stored = frappe.db.get_value("Appointment", appointment.name, "verification_token")
|
||||
self.assertEqual(stored, sha256_hash(params["key"]))
|
||||
|
||||
def test_email_verification_within_expiry_window(self):
|
||||
# Link used within the validity window - verification succeeds and the
|
||||
# appointment gets linked, assigned and added to the calendar
|
||||
on_time = self._create_portal_appointment("portal_visitor_on_time@example.com")
|
||||
context = self._request_verification(on_time)
|
||||
|
||||
self.assertTrue(context.success)
|
||||
self._confirmed_email_mock.assert_called_once()
|
||||
on_time.reload()
|
||||
self.assertEqual(on_time.status, "Open")
|
||||
self.assertTrue(on_time.email_verified)
|
||||
self.assertTrue(on_time.party)
|
||||
self.assertTrue(on_time.calendar_event)
|
||||
|
||||
# Link used after the validity window - verification fails
|
||||
late = self._create_portal_appointment("portal_visitor_late@example.com", days_from_now=10)
|
||||
after_expiry = add_to_date(now_datetime(), minutes=VERIFICATION_EXPIRY_MINUTES + 1)
|
||||
with patch.object(verify_index, "now_datetime", return_value=after_expiry):
|
||||
context = self._request_verification(late)
|
||||
|
||||
self.assertFalse(context.success)
|
||||
self._confirmed_email_mock.assert_not_called()
|
||||
late.reload()
|
||||
self.assertEqual(late.status, "Unverified")
|
||||
self.assertFalse(late.email_verified)
|
||||
self.assertFalse(late.party)
|
||||
|
||||
def test_verification_link_reused_after_success(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor_twice@example.com")
|
||||
verify_url = appointment._get_verify_url()
|
||||
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
self.assertTrue(context.success)
|
||||
self._confirmed_email_mock.assert_called_once()
|
||||
|
||||
# re-clicking the link is idempotent and does not send another email
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
self.assertTrue(context.success)
|
||||
self.assertIn("already verified", context.message)
|
||||
self._confirmed_email_mock.assert_not_called()
|
||||
|
||||
def test_verification_link_for_deleted_appointment(self):
|
||||
"""A verification link can outlive its appointment - clicking it must
|
||||
render a friendly message, not crash."""
|
||||
appointment = self._create_portal_appointment("portal_visitor_gone@example.com")
|
||||
verify_url = appointment._get_verify_url()
|
||||
frappe.delete_doc("Appointment", appointment.name, ignore_permissions=True)
|
||||
|
||||
context = self._request_verification(appointment, verify_url=verify_url)
|
||||
|
||||
self.assertFalse(context.success)
|
||||
self.assertIn("book the appointment again", context.message)
|
||||
|
||||
def test_reschedule_syncs_calendar_event(self):
|
||||
new_time = add_to_date(self.test_appointment.scheduled_time, hours=1)
|
||||
self.test_appointment.scheduled_time = new_time
|
||||
self.test_appointment.save()
|
||||
|
||||
starts_on = frappe.db.get_value("Event", self.test_appointment.calendar_event, "starts_on")
|
||||
self.assertEqual(starts_on, new_time)
|
||||
|
||||
def test_portal_endpoint_disabled(self):
|
||||
self._configure_booking_settings()
|
||||
set_booking_setting("enable_appointment_portal", 0)
|
||||
|
||||
with self.set_user("Guest"), self.assertRaises(frappe.Redirect):
|
||||
create_appointment(
|
||||
date=str(datetime.date.today() + datetime.timedelta(days=3)),
|
||||
time="10:00:00",
|
||||
tz="UTC",
|
||||
contact={
|
||||
"name": "Blocked",
|
||||
"email": "blocked@example.com",
|
||||
"number": "1",
|
||||
"skype": "",
|
||||
"notes": "",
|
||||
},
|
||||
)
|
||||
|
||||
def test_booked_slot_unavailable_on_portal(self):
|
||||
from frappe.utils.data import get_system_timezone
|
||||
|
||||
self._configure_booking_settings()
|
||||
tz = get_system_timezone()
|
||||
day = datetime.date.today() + datetime.timedelta(days=2)
|
||||
|
||||
def get_availability():
|
||||
with self.set_user("Guest"):
|
||||
slots = get_appointment_slots(str(day), tz)
|
||||
return {slot["time"].strftime("%H:%M"): slot["availability"] for slot in slots}
|
||||
|
||||
booked = create_test_appointment(
|
||||
customer_email="slot_taken@example.com", scheduled_time=slot_on(2, 10)
|
||||
)
|
||||
|
||||
availability = get_availability()
|
||||
self.assertFalse(availability["10:00"])
|
||||
self.assertTrue(availability["13:00"])
|
||||
|
||||
# closing the appointment frees its slot on the portal
|
||||
booked.status = "Closed"
|
||||
booked.save()
|
||||
self.assertTrue(get_availability()["10:00"])
|
||||
|
||||
# an off-grid desk appointment blocks every portal slot it overlaps
|
||||
create_test_appointment(customer_email="off_grid@example.com", scheduled_time=slot_on(2, 13, 15))
|
||||
availability = get_availability()
|
||||
self.assertFalse(availability["13:00"])
|
||||
self.assertFalse(availability["13:30"])
|
||||
self.assertTrue(availability["14:00"])
|
||||
|
||||
def test_expired_unverified_appointments_are_closed(self):
|
||||
stale = self._create_portal_appointment("portal_visitor_stale@example.com", days_from_now=8)
|
||||
fresh = self._create_portal_appointment("portal_visitor_fresh@example.com", days_from_now=9)
|
||||
verify_url = stale._get_verify_url()
|
||||
|
||||
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
|
||||
set_booking_setting("action_for_expired_unverified_appointments", "Mark as Closed")
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertEqual(get_status(stale.name), "Closed")
|
||||
self.assertEqual(get_status(fresh.name), "Unverified")
|
||||
# Open appointments are never touched, regardless of age
|
||||
self.assertEqual(get_status(self.test_appointment.name), "Open")
|
||||
|
||||
# clicking the link of a closed appointment renders a friendly message
|
||||
context = self._request_verification(stale, verify_url=verify_url)
|
||||
self.assertFalse(context.success)
|
||||
self.assertIn("closed", context.message)
|
||||
|
||||
def test_expired_unverified_appointments_are_deleted(self):
|
||||
stale = self._create_portal_appointment("portal_visitor_purged@example.com", days_from_now=8)
|
||||
fresh = self._create_portal_appointment("portal_visitor_kept@example.com", days_from_now=9)
|
||||
|
||||
backdate_creation(stale.name, VERIFICATION_EXPIRY_MINUTES + 15)
|
||||
set_booking_setting("action_for_expired_unverified_appointments", "Delete Permanently")
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Appointment", stale.name))
|
||||
self.assertTrue(frappe.db.exists("Appointment", fresh.name))
|
||||
self.assertTrue(frappe.db.exists("Appointment", self.test_appointment.name))
|
||||
|
||||
def test_cleanup_skipped_when_expiry_not_configured(self):
|
||||
appointment = self._create_portal_appointment("portal_visitor_no_expiry@example.com")
|
||||
backdate_creation(appointment.name, 5)
|
||||
set_booking_setting("verification_link_expiry_duration", 0)
|
||||
|
||||
handle_expired_unverified_appointments()
|
||||
|
||||
self.assertEqual(get_status(appointment.name), "Unverified")
|
||||
|
||||
def test_status_transition_rules(self):
|
||||
# desk appointments can never be Unverified
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="desk_unverified@example.com", status="Unverified")
|
||||
|
||||
# portal appointments cannot be opened manually before verification
|
||||
unverified = self._create_portal_appointment("manual_open@example.com")
|
||||
unverified.status = "Open"
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
unverified.save(ignore_permissions=True)
|
||||
|
||||
# verified appointments cannot be reverted to Unverified
|
||||
verified = self._create_portal_appointment("revert_unverified@example.com", days_from_now=8)
|
||||
self._request_verification(verified)
|
||||
verified.reload()
|
||||
verified.status = "Unverified"
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
verified.save(ignore_permissions=True)
|
||||
|
||||
# both desk and verified portal appointments can be closed and reopened
|
||||
for appointment in (self.test_appointment, verified):
|
||||
appointment.reload()
|
||||
appointment.status = "Closed"
|
||||
appointment.save(ignore_permissions=True)
|
||||
appointment.status = "Open"
|
||||
appointment.save(ignore_permissions=True)
|
||||
self.assertEqual(appointment.status, "Open")
|
||||
|
||||
def test_agent_auto_assignment(self):
|
||||
agent_email = "appointment_agent@example.com"
|
||||
if not frappe.db.exists("User", agent_email):
|
||||
frappe.get_doc(
|
||||
{"doctype": "User", "email": agent_email, "first_name": "Appointment Agent"}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
self._configure_booking_settings(agents=["Administrator", agent_email])
|
||||
first = create_test_appointment(
|
||||
customer_email="assigned_one@example.com", scheduled_time=slot_on(2, 11)
|
||||
)
|
||||
second = create_test_appointment(
|
||||
customer_email="assigned_two@example.com", scheduled_time=slot_on(2, 11)
|
||||
)
|
||||
|
||||
# both appointments in the same slot get an agent, and never the same one
|
||||
self.assertTrue(get_assignees(first.name))
|
||||
self.assertTrue(get_assignees(second.name))
|
||||
self.assertNotEqual(get_assignees(first.name), get_assignees(second.name))
|
||||
|
||||
# closing an assigned appointment closes its ToDo without re-assigning
|
||||
first.reload()
|
||||
first.status = "Closed"
|
||||
first.save()
|
||||
self.assertTrue(get_todo_statuses(first.name))
|
||||
self.assertTrue(all(status == "Closed" for status in get_todo_statuses(first.name)))
|
||||
|
||||
# reopening brings the ToDos back
|
||||
first.status = "Open"
|
||||
first.save()
|
||||
self.assertTrue(all(status == "Open" for status in get_todo_statuses(first.name)))
|
||||
|
||||
def test_agent_busy_for_the_whole_appointment_duration(self):
|
||||
self._configure_booking_settings()
|
||||
slot = slot_on(3, 11)
|
||||
appointment = create_test_appointment(customer_email="busy_agent@example.com", scheduled_time=slot)
|
||||
assignee = get_assignees(appointment.name)[0]
|
||||
|
||||
# busy anywhere inside the 30-minute appointment window, free right after it
|
||||
self.assertFalse(_check_agent_availability(assignee, slot))
|
||||
self.assertFalse(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=15)))
|
||||
self.assertTrue(_check_agent_availability(assignee, slot + datetime.timedelta(minutes=30)))
|
||||
|
||||
def test_closed_appointment_closes_calendar_event(self):
|
||||
self.test_appointment.status = "Closed"
|
||||
self.test_appointment.save()
|
||||
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
|
||||
self.assertEqual(event_status, "Closed")
|
||||
|
||||
# reopening the appointment reopens the calendar event
|
||||
self.test_appointment.status = "Open"
|
||||
self.test_appointment.save()
|
||||
event_status = frappe.db.get_value("Event", self.test_appointment.calendar_event, "status")
|
||||
self.assertEqual(event_status, "Open")
|
||||
|
||||
def test_deleting_appointment_deletes_calendar_event(self):
|
||||
event = self.test_appointment.calendar_event
|
||||
self.assertTrue(frappe.db.exists("Event", event))
|
||||
|
||||
frappe.delete_doc("Appointment", self.test_appointment.name)
|
||||
|
||||
self.assertFalse(frappe.db.exists("Event", event))
|
||||
|
||||
def test_backdated_appointment_is_rejected(self):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="backdated@example.com",
|
||||
scheduled_time=add_to_date(now_datetime(), hours=-1),
|
||||
)
|
||||
|
||||
def test_booking_beyond_advance_window_is_rejected(self):
|
||||
self._configure_booking_settings()
|
||||
set_booking_setting("advance_booking_days", 7)
|
||||
|
||||
# within the advance booking window - allowed
|
||||
within = create_test_appointment(
|
||||
customer_email="advance_within@example.com", scheduled_time=slot_on(5, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", within.name))
|
||||
|
||||
# beyond the advance booking window - rejected
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="advance_beyond@example.com", scheduled_time=slot_on(8, 10)
|
||||
)
|
||||
|
||||
def test_appointment_on_holiday_is_rejected(self):
|
||||
holiday = add_to_date(getdate(), days=3)
|
||||
self._configure_booking_settings(
|
||||
holiday_dates=[{"holiday_date": holiday, "description": "Test Holiday"}]
|
||||
)
|
||||
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="on_holiday@example.com", scheduled_time=slot_on(3, 10))
|
||||
|
||||
# the day after the holiday is bookable
|
||||
after_holiday = create_test_appointment(
|
||||
customer_email="after_holiday@example.com", scheduled_time=slot_on(4, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", after_holiday.name))
|
||||
|
||||
def test_appointment_outside_slot_timing_is_rejected(self):
|
||||
self._configure_booking_settings()
|
||||
|
||||
# before the slot opens
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(customer_email="before_opening@example.com", scheduled_time=slot_on(2, 8))
|
||||
|
||||
# starts within the slot but would end after it closes
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="past_closing@example.com", scheduled_time=slot_on(2, 16, 45)
|
||||
)
|
||||
|
||||
# within the slot timings
|
||||
within = create_test_appointment(
|
||||
customer_email="within_slot@example.com", scheduled_time=slot_on(2, 10)
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", within.name))
|
||||
|
||||
def test_overlapping_time_slot_capacity(self):
|
||||
set_booking_setting("number_of_agents", 1)
|
||||
set_booking_setting("appointment_duration", 30)
|
||||
|
||||
slot = slot_on(1, 10)
|
||||
first = create_test_appointment(customer_email="slot_first@example.com", scheduled_time=slot)
|
||||
|
||||
# a booking starting inside the first appointment's duration is rejected
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
create_test_appointment(
|
||||
customer_email="slot_overlap@example.com",
|
||||
scheduled_time=slot + datetime.timedelta(minutes=15),
|
||||
)
|
||||
|
||||
# rescheduling must not count the appointment's own booked slot
|
||||
first.scheduled_time = slot + datetime.timedelta(minutes=10)
|
||||
first.save()
|
||||
|
||||
# a booking starting exactly when the rescheduled one ends is allowed
|
||||
adjacent = create_test_appointment(
|
||||
customer_email="slot_adjacent@example.com",
|
||||
scheduled_time=slot + datetime.timedelta(minutes=40),
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", adjacent.name))
|
||||
|
||||
# a closed (cancelled) appointment frees its slot
|
||||
first.status = "Closed"
|
||||
first.save()
|
||||
after_cancellation = create_test_appointment(
|
||||
customer_email="after_cancellation@example.com", scheduled_time=slot
|
||||
)
|
||||
self.assertTrue(frappe.db.exists("Appointment", after_cancellation.name))
|
||||
|
||||
@@ -1,48 +1,56 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"creation": "2019-08-27 10:56:48.309824",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"enable_scheduling",
|
||||
"agent_detail_section",
|
||||
"availability_of_slots",
|
||||
"number_of_agents",
|
||||
"agent_list",
|
||||
"holiday_list",
|
||||
"appointment_details_section",
|
||||
"appointment_duration",
|
||||
"email_reminders",
|
||||
"column_break_ehiq",
|
||||
"agent_list",
|
||||
"number_of_agents",
|
||||
"agent_detail_section",
|
||||
"enable_scheduling",
|
||||
"availability_of_slots",
|
||||
"section_break_bkln",
|
||||
"column_break_alwa",
|
||||
"advance_booking_days",
|
||||
"column_break_bspp",
|
||||
"holiday_list",
|
||||
"success_details",
|
||||
"success_redirect_url"
|
||||
"enable_appointment_portal",
|
||||
"verification_link_expiry_duration",
|
||||
"column_break_fovk",
|
||||
"success_redirect_url",
|
||||
"action_for_expired_unverified_appointments"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "availability_of_slots",
|
||||
"fieldtype": "Table",
|
||||
"label": "Availability Of Slots",
|
||||
"options": "Appointment Booking Slots",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Appointment Booking Slots"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "number_of_agents",
|
||||
"fieldtype": "Int",
|
||||
"hidden": 1,
|
||||
"in_list_view": 1,
|
||||
"label": "Number of Concurrent Appointments",
|
||||
"read_only": 1,
|
||||
"reqd": 1
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "holiday_list",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Holiday List",
|
||||
"options": "Holiday List",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Holiday List"
|
||||
},
|
||||
{
|
||||
"default": "60",
|
||||
@@ -60,29 +68,31 @@
|
||||
},
|
||||
{
|
||||
"default": "7",
|
||||
"depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"fieldname": "advance_booking_days",
|
||||
"fieldtype": "Int",
|
||||
"label": "Number of days appointments can be booked in advance",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;"
|
||||
},
|
||||
{
|
||||
"fieldname": "agent_list",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"label": "Agents",
|
||||
"options": "Assignment Rule User",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_scheduling === 1;",
|
||||
"options": "Assignment Rule User"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_scheduling",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Appointment Scheduling",
|
||||
"reqd": 1
|
||||
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;"
|
||||
},
|
||||
{
|
||||
"fieldname": "agent_detail_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Agent Details"
|
||||
"hide_border": 1,
|
||||
"label": "Appointment Scheduling"
|
||||
},
|
||||
{
|
||||
"fieldname": "appointment_details_section",
|
||||
@@ -92,20 +102,68 @@
|
||||
{
|
||||
"fieldname": "success_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Success Settings"
|
||||
"label": "Appointment Booking Portal Settings"
|
||||
},
|
||||
{
|
||||
"description": "Leave blank for home.\nThis is relative to site URL, for example \"about\" will redirect to \"https://yoursitename.com/about\"",
|
||||
"fieldname": "success_redirect_url",
|
||||
"fieldtype": "Data",
|
||||
"label": "Success Redirect URL"
|
||||
"label": "Success Redirect URL",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"default": "30",
|
||||
"depends_on": "eval: doc.enable_scheduling === 1;",
|
||||
"description": "In Minutes (min: 15 mins, max: 60 mins)",
|
||||
"fieldname": "verification_link_expiry_duration",
|
||||
"fieldtype": "Int",
|
||||
"label": "Verification Link Expiry Duration",
|
||||
"mandatory_depends_on": "eval:doc.enable_appointment_portal === 1;",
|
||||
"max_value": 60.0,
|
||||
"min_value": 15.0,
|
||||
"non_negative": 1,
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_ehiq",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "enable_appointment_portal",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Appointment Booking Through Portal",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_fovk",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "Mark as Closed",
|
||||
"fieldname": "action_for_expired_unverified_appointments",
|
||||
"fieldtype": "Select",
|
||||
"label": "Action for Expired Unverified Appointments",
|
||||
"options": "Mark as Closed\nDelete Permanently",
|
||||
"permlevel": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_bkln",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_alwa",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_bspp",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"hide_toolbar": 0,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-16 13:28:21.198138",
|
||||
"modified": "2026-07-20 00:11:18.996384",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "Appointment Booking Settings",
|
||||
@@ -139,6 +197,15 @@
|
||||
"role": "Sales Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"email": 1,
|
||||
"permlevel": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import getdate
|
||||
|
||||
|
||||
class AppointmentBookingSettings(Document):
|
||||
@@ -24,33 +24,43 @@ class AppointmentBookingSettings(Document):
|
||||
AppointmentBookingSlots,
|
||||
)
|
||||
|
||||
action_for_expired_unverified_appointments: DF.Literal["Mark as Closed", "Delete Permanently"]
|
||||
advance_booking_days: DF.Int
|
||||
agent_list: DF.TableMultiSelect[AssignmentRuleUser]
|
||||
appointment_duration: DF.Int
|
||||
availability_of_slots: DF.Table[AppointmentBookingSlots]
|
||||
email_reminders: DF.Check
|
||||
enable_appointment_portal: DF.Check
|
||||
enable_scheduling: DF.Check
|
||||
holiday_list: DF.Link
|
||||
holiday_list: DF.Link | None
|
||||
number_of_agents: DF.Int
|
||||
success_redirect_url: DF.Data | None
|
||||
verification_link_expiry_duration: DF.Int
|
||||
# end: auto-generated types
|
||||
|
||||
agent_list: typing.ClassVar[list] = [] # Hack
|
||||
min_date = "01/01/1970 "
|
||||
format_string = "%d/%m/%Y %H:%M:%S"
|
||||
|
||||
def validate(self):
|
||||
self.validate_availability_of_slots()
|
||||
|
||||
def save(self):
|
||||
self.number_of_agents = len(self.agent_list)
|
||||
super().save()
|
||||
self.validate_appointment_scheduling()
|
||||
self.validate_portal_booking()
|
||||
|
||||
def validate_appointment_scheduling(self):
|
||||
if not self.enable_scheduling:
|
||||
return
|
||||
|
||||
self.validate_availability_of_slots()
|
||||
self.validate_holiday_list()
|
||||
self.validate_advance_booking_days()
|
||||
|
||||
def validate_availability_of_slots(self):
|
||||
if not self.availability_of_slots:
|
||||
frappe.throw(
|
||||
_("Please fill up the Availability of Slots table to enable Appointment Scheduling.")
|
||||
)
|
||||
|
||||
format_string = "%Y-%m-%d %H:%M:%S"
|
||||
for record in self.availability_of_slots:
|
||||
from_time = datetime.datetime.strptime(self.min_date + record.from_time, self.format_string)
|
||||
to_time = datetime.datetime.strptime(self.min_date + record.to_time, self.format_string)
|
||||
to_time - from_time
|
||||
from_time = datetime.datetime.strptime(f"1970-01-01 {record.from_time}", format_string)
|
||||
to_time = datetime.datetime.strptime(f"1970-01-01 {record.to_time}", format_string)
|
||||
self.validate_from_and_to_time(from_time, to_time, record)
|
||||
self.duration_is_divisible(from_time, to_time)
|
||||
|
||||
@@ -65,3 +75,38 @@ class AppointmentBookingSettings(Document):
|
||||
timedelta = to_time - from_time
|
||||
if timedelta.total_seconds() % (self.appointment_duration * 60):
|
||||
frappe.throw(_("The difference between from time and To Time must be a multiple of Appointment"))
|
||||
|
||||
def validate_holiday_list(self):
|
||||
if not self.holiday_list:
|
||||
frappe.throw(_("Please select a Holiday List to enable Appointment Scheduling."))
|
||||
|
||||
hl_from_date, hl_to_date = frappe.get_cached_value(
|
||||
"Holiday List", self.holiday_list, ["from_date", "to_date"]
|
||||
)
|
||||
now = getdate()
|
||||
|
||||
if not (now >= hl_from_date and now <= hl_to_date):
|
||||
frappe.throw(_("Holiday List - {0} is not valid for current date.").format(self.holiday_list))
|
||||
|
||||
def validate_advance_booking_days(self):
|
||||
if not self.advance_booking_days:
|
||||
frappe.throw(_("Advance Booking Days is mandatory for Appointment Scheduling."))
|
||||
|
||||
def validate_portal_booking(self):
|
||||
if not self.enable_appointment_portal:
|
||||
return
|
||||
|
||||
if not self.enable_scheduling:
|
||||
frappe.throw(
|
||||
_("Appointment Scheduling needs to be enabled for Appointment Booking through portal.")
|
||||
)
|
||||
|
||||
self.validate_link_expiry_duration()
|
||||
|
||||
def validate_link_expiry_duration(self):
|
||||
if (
|
||||
not self.verification_link_expiry_duration
|
||||
or self.verification_link_expiry_duration > 60
|
||||
or self.verification_link_expiry_duration < 15
|
||||
):
|
||||
frappe.throw(_("'Verification Link Expiry Duration' must be between 15 to 60 minutes."))
|
||||
|
||||
@@ -1,10 +1,125 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
# import frappe
|
||||
import unittest
|
||||
|
||||
import datetime
|
||||
|
||||
import frappe
|
||||
from frappe.utils import add_to_date, getdate
|
||||
|
||||
from erpnext.setup.doctype.holiday_list.test_holiday_list import make_holiday_list
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestAppointmentBookingSettings(ERPNextTestSuite):
|
||||
pass
|
||||
def assert_invalid(self, settings):
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
settings.save()
|
||||
|
||||
def make_settings(self, appointment_duration=30):
|
||||
doc = frappe.new_doc("Appointment Booking Settings")
|
||||
doc.appointment_duration = appointment_duration
|
||||
return doc
|
||||
|
||||
def dt(self, hms):
|
||||
# the controller parses times against a fixed epoch date
|
||||
return datetime.datetime.strptime("1970-01-01 " + hms, "%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def get_valid_scheduling_settings(self):
|
||||
holiday_list = make_holiday_list(
|
||||
"_Test Booking Settings Holiday List",
|
||||
from_date=getdate(),
|
||||
to_date=add_to_date(getdate(), days=30),
|
||||
holiday_dates=[],
|
||||
)
|
||||
|
||||
settings = frappe.get_doc("Appointment Booking Settings")
|
||||
settings.enable_scheduling = 1
|
||||
settings.appointment_duration = 30
|
||||
settings.advance_booking_days = 7
|
||||
settings.verification_link_expiry_duration = 30
|
||||
settings.holiday_list = holiday_list.name
|
||||
settings.set("agent_list", [])
|
||||
settings.append("agent_list", {"user": "Administrator"})
|
||||
settings.set("availability_of_slots", [])
|
||||
settings.append(
|
||||
"availability_of_slots",
|
||||
{"day_of_week": "Monday", "from_time": "09:00:00", "to_time": "17:00:00"},
|
||||
)
|
||||
return settings
|
||||
|
||||
def test_from_time_must_precede_to_time(self):
|
||||
doc = self.make_settings()
|
||||
record = frappe._dict(day_of_week="Monday")
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
doc.validate_from_and_to_time,
|
||||
self.dt("18:00:00"),
|
||||
self.dt("09:00:00"),
|
||||
record,
|
||||
)
|
||||
doc.validate_from_and_to_time(self.dt("09:00:00"), self.dt("18:00:00"), record) # valid order
|
||||
|
||||
def test_slot_length_must_be_a_multiple_of_the_duration(self):
|
||||
doc = self.make_settings(appointment_duration=30)
|
||||
# 60 minutes is two 30-minute appointments -> fine
|
||||
doc.duration_is_divisible(self.dt("09:00:00"), self.dt("10:00:00"))
|
||||
# 45 minutes leaves a partial appointment -> rejected
|
||||
self.assertRaises(
|
||||
frappe.ValidationError, doc.duration_is_divisible, self.dt("09:00:00"), self.dt("09:45:00")
|
||||
)
|
||||
|
||||
def test_scheduling_requires_slots(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.set("availability_of_slots", [])
|
||||
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_validate_checks_every_slot(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.append(
|
||||
"availability_of_slots",
|
||||
{"day_of_week": "Tuesday", "from_time": "09:00:00", "to_time": "09:45:00"},
|
||||
)
|
||||
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_scheduling_requires_holiday_list_covering_today(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.holiday_list = None
|
||||
self.assert_invalid(settings)
|
||||
|
||||
expired_list = make_holiday_list(
|
||||
"_Test Booking Settings Expired Holiday List",
|
||||
from_date=add_to_date(getdate(), days=-60),
|
||||
to_date=add_to_date(getdate(), days=-30),
|
||||
holiday_dates=[],
|
||||
)
|
||||
settings.holiday_list = expired_list.name
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_scheduling_requires_advance_booking_days(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.advance_booking_days = 0
|
||||
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_portal_requires_scheduling(self):
|
||||
settings = frappe.get_doc("Appointment Booking Settings")
|
||||
settings.enable_scheduling = 0
|
||||
settings.enable_appointment_portal = 1
|
||||
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_portal_expiry_duration_bounds(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.enable_appointment_portal = 1
|
||||
settings.verification_link_expiry_duration = 5
|
||||
|
||||
self.assert_invalid(settings)
|
||||
|
||||
def test_number_of_agents_derived_from_agent_list(self):
|
||||
settings = self.get_valid_scheduling_settings()
|
||||
settings.number_of_agents = 99
|
||||
settings.save()
|
||||
|
||||
self.assertEqual(frappe.db.get_single_value("Appointment Booking Settings", "number_of_agents"), 1)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -433,8 +433,6 @@ scheduler_events = {
|
||||
"cron": {
|
||||
"0/15 * * * *": [
|
||||
"erpnext.manufacturing.doctype.bom_update_log.bom_update_log.resume_bom_cost_update_jobs",
|
||||
],
|
||||
"0/30 * * * *": [
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.run_parallel_reposting",
|
||||
],
|
||||
# Hourly but offset by 30 minutes
|
||||
@@ -449,6 +447,7 @@ scheduler_events = {
|
||||
],
|
||||
"hourly_long": [],
|
||||
"hourly_maintenance": [
|
||||
"erpnext.crm.doctype.appointment.appointment.handle_expired_unverified_appointments",
|
||||
"erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries",
|
||||
"erpnext.utilities.bulk_transaction.retry",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
|
||||
2522
erpnext/locale/ar.po
2522
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
2073
erpnext/locale/bg.po
2073
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
4469
erpnext/locale/bs.po
4469
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
2546
erpnext/locale/cs.po
2546
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
2081
erpnext/locale/da.po
2081
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
2486
erpnext/locale/de.po
2486
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
21926
erpnext/locale/eo.po
21926
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
2564
erpnext/locale/es.po
2564
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
2366
erpnext/locale/fa.po
2366
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
2550
erpnext/locale/fr.po
2550
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
2091
erpnext/locale/hi.po
2091
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
3487
erpnext/locale/hr.po
3487
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
2540
erpnext/locale/hu.po
2540
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
2419
erpnext/locale/id.po
2419
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
2535
erpnext/locale/it.po
2535
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
2208
erpnext/locale/ko.po
2208
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2080
erpnext/locale/my.po
2080
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
2210
erpnext/locale/nb.po
2210
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
2480
erpnext/locale/nl.po
2480
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
2526
erpnext/locale/pl.po
2526
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
2539
erpnext/locale/pt.po
2539
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2423
erpnext/locale/ru.po
2423
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
2197
erpnext/locale/sl.po
2197
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
2290
erpnext/locale/sr.po
2290
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2364
erpnext/locale/sv.po
2364
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
2283
erpnext/locale/th.po
2283
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
2267
erpnext/locale/tr.po
2267
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
2334
erpnext/locale/uz.po
2334
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
2331
erpnext/locale/vi.po
2331
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
2313
erpnext/locale/zh.po
2313
erpnext/locale/zh.po
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
|
||||
|
||||
@@ -445,8 +445,6 @@ frappe.ui.form.on("Production Plan", {
|
||||
frappe.throw(__("Select the Warehouse"));
|
||||
}
|
||||
|
||||
frm.set_value("consider_minimum_order_qty", 0);
|
||||
|
||||
if (!frm.doc.ignore_existing_ordered_qty) {
|
||||
frm.events.get_items_for_material_requests(frm);
|
||||
} else {
|
||||
|
||||
@@ -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(
|
||||
@@ -1823,7 +1831,13 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
if (ignore_existing_ordered_qty or get_parent_warehouse_data) and warehouses:
|
||||
new_mr_items = []
|
||||
for item in mr_items:
|
||||
get_materials_from_other_locations(item, warehouses, new_mr_items, company)
|
||||
get_materials_from_other_locations(
|
||||
item,
|
||||
warehouses,
|
||||
new_mr_items,
|
||||
company,
|
||||
consider_minimum_order_qty=doc.get("consider_minimum_order_qty"),
|
||||
)
|
||||
|
||||
mr_items = new_mr_items
|
||||
|
||||
@@ -1845,7 +1859,9 @@ def get_items_for_material_requests(doc, warehouses=None, get_parent_warehouse_d
|
||||
return mr_items
|
||||
|
||||
|
||||
def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
|
||||
def get_materials_from_other_locations(
|
||||
item, warehouses, new_mr_items, company, consider_minimum_order_qty=False
|
||||
):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import get_available_item_locations
|
||||
|
||||
purchase_uom = frappe.db.get_value("Item", item.get("item_code"), "purchase_uom")
|
||||
@@ -1888,7 +1904,8 @@ def get_materials_from_other_locations(item, warehouses, new_mr_items, company):
|
||||
|
||||
precision = frappe.get_precision("Material Request Plan Item", "quantity")
|
||||
if flt(required_qty, precision) > 0:
|
||||
required_qty = required_qty
|
||||
if consider_minimum_order_qty:
|
||||
required_qty = max(required_qty, flt(item.get("min_order_qty")))
|
||||
|
||||
if frappe.db.get_value("UOM", purchase_uom, "must_be_whole_number"):
|
||||
required_qty = ceil(required_qty)
|
||||
|
||||
@@ -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
|
||||
@@ -2030,6 +2142,18 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
for d in mr_items:
|
||||
self.assertEqual(d.get("quantity"), 1000.0)
|
||||
|
||||
source_warehouse = create_warehouse("MOQ Source Warehouse", company="_Test Company")
|
||||
make_stock_entry(item_code=rm_item, qty=7, rate=100, target=source_warehouse)
|
||||
|
||||
pln.ignore_existing_ordered_qty = 1
|
||||
mr_items = get_items_for_material_requests(
|
||||
pln.as_dict(), warehouses=[{"warehouse": source_warehouse}]
|
||||
)
|
||||
self.assertEqual(len(mr_items), 2)
|
||||
items_by_type = {d.get("material_request_type"): d for d in mr_items}
|
||||
self.assertEqual(items_by_type["Material Transfer"].get("quantity"), 7.0)
|
||||
self.assertEqual(items_by_type["Purchase"].get("quantity"), 1000.0)
|
||||
|
||||
def test_fg_item_quantity(self):
|
||||
fg_item = make_item(properties={"is_stock_item": 1}).name
|
||||
rm_item = make_item(properties={"is_stock_item": 1}).name
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fetch_from": "material_request.transaction_date",
|
||||
"fieldname": "material_request_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
@@ -41,13 +42,15 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:20.526011",
|
||||
"modified": "2026-07-17 22:06:35.428875",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "Production Plan Material Request",
|
||||
"naming_rule": "Random",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
@@ -2983,7 +2990,7 @@ def get_work_order_operation_data(work_order, operation, workstation):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_pick_list(source_name, target_doc=None, for_qty=None):
|
||||
def create_pick_list(source_name: str, target_doc: str | dict | None = None, for_qty: float | None = None):
|
||||
for_qty = for_qty or json.loads(target_doc).get("for_qty")
|
||||
max_finished_goods_qty = frappe.db.get_value("Work Order", source_name, "qty")
|
||||
|
||||
@@ -3013,6 +3020,7 @@ def create_pick_list(source_name, target_doc=None, for_qty=None):
|
||||
"Work Order": {"doctype": "Pick List", "validation": {"docstatus": ["=", 1]}},
|
||||
"Work Order Item": {
|
||||
"doctype": "Pick List Item",
|
||||
"field_no_map": ["transferred_qty"],
|
||||
"postprocess": update_item_quantity,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -490,3 +490,7 @@ erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
|
||||
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
|
||||
|
||||
33
erpnext/patches/v16_0/access_control_for_project_users.py
Normal file
33
erpnext/patches/v16_0/access_control_for_project_users.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
Project = frappe.qb.DocType("Project")
|
||||
ProjectUser = frappe.qb.DocType("Project User")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Project)
|
||||
.join(ProjectUser)
|
||||
.on(Project.name == ProjectUser.parent)
|
||||
.select(Project.name, ProjectUser.user)
|
||||
)
|
||||
|
||||
proj_users = query.run(as_dict=1)
|
||||
|
||||
project_mapped_users = get_project_mapped_users(proj_users)
|
||||
|
||||
for d in proj_users:
|
||||
if d.user in project_mapped_users[d.name]:
|
||||
continue
|
||||
|
||||
frappe.share.add_docshare("Project", d.name, user=d.user)
|
||||
|
||||
|
||||
def get_project_mapped_users(proj_users):
|
||||
projects = set([d.name for d in proj_users])
|
||||
project_mapped_users = {}
|
||||
|
||||
for d in projects:
|
||||
project_mapped_users[d] = [d.user for d in frappe.share.get_users("Project", d)]
|
||||
|
||||
return project_mapped_users
|
||||
@@ -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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user