mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-25 13:45:04 +00:00
Compare commits
158 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2863b0fe52 | ||
|
|
6096b56a10 | ||
|
|
88fdab99a9 | ||
|
|
7466d91e12 | ||
|
|
ad4fe2db9e | ||
|
|
13de175b0f | ||
|
|
a333ec28e9 | ||
|
|
e6d38d009f | ||
|
|
178eded259 | ||
|
|
5db7888a8d | ||
|
|
83e0ce4020 | ||
|
|
6f58614ef2 | ||
|
|
a3068c8bf6 | ||
|
|
bdeee94c18 | ||
|
|
db5013fca1 | ||
|
|
2712310c0a | ||
|
|
a624ae07f0 | ||
|
|
347a7e3b1f | ||
|
|
df4347a6cb | ||
|
|
7edbf25e60 | ||
|
|
7c85d7f163 | ||
|
|
6216547027 | ||
|
|
ac0a837bf3 | ||
|
|
8be8a73a36 | ||
|
|
f49f60b5f4 | ||
|
|
3a13c04a71 | ||
|
|
62e8d4753e | ||
|
|
18c7121230 | ||
|
|
37c1d665e3 | ||
|
|
0d9c711b6d | ||
|
|
3b0a1bce3d | ||
|
|
efd90f554f | ||
|
|
5992ce533e | ||
|
|
1c044759ac | ||
|
|
86a36b3ef4 | ||
|
|
cd830a6b6c | ||
|
|
dc1147d504 | ||
|
|
797b418af4 | ||
|
|
8cdbaacfff | ||
|
|
09d12f2f9f | ||
|
|
19f66c6018 | ||
|
|
de80215df5 | ||
|
|
017dc792e6 | ||
|
|
d920520843 | ||
|
|
ac901dee3c | ||
|
|
eed1abd272 | ||
|
|
ae23cdd8e9 | ||
|
|
5d4da9c68f | ||
|
|
990ada1656 | ||
|
|
1e40fb00f5 | ||
|
|
3d7827731b | ||
|
|
164333a730 | ||
|
|
e1afcb39db | ||
|
|
65e37756f6 | ||
|
|
c897220535 | ||
|
|
b04c06e034 | ||
|
|
4076798707 | ||
|
|
8b8427cbf5 | ||
|
|
e3110b3993 | ||
|
|
b5f8f2b6fa | ||
|
|
1a3652bee1 | ||
|
|
253b945816 | ||
|
|
d9d9230d4a | ||
|
|
668ec641c1 | ||
|
|
053765a466 | ||
|
|
288570acde | ||
|
|
4ef07f97c9 | ||
|
|
670579bde3 | ||
|
|
8bd1e8ff74 | ||
|
|
83f4970255 | ||
|
|
12903b11ed | ||
|
|
7658076070 | ||
|
|
b019d2f45e | ||
|
|
397bc96b40 | ||
|
|
f72d14b1ca | ||
|
|
0405d82b75 | ||
|
|
afe42ee3e9 | ||
|
|
49f66a8a51 | ||
|
|
0a09bb8d17 | ||
|
|
ba700bce90 | ||
|
|
22767410d5 | ||
|
|
89e7a260fd | ||
|
|
2a90bffb5f | ||
|
|
7434522ea1 | ||
|
|
158e1b28a9 | ||
|
|
a7672530f4 | ||
|
|
ad33735541 | ||
|
|
977f385006 | ||
|
|
b52b04a10c | ||
|
|
05b89b4121 | ||
|
|
36983c97cb | ||
|
|
23006601b2 | ||
|
|
37e9461ece | ||
|
|
126d79dcd2 | ||
|
|
93b2786865 | ||
|
|
2680f85430 | ||
|
|
2d7fde024d | ||
|
|
85bf9366b0 | ||
|
|
86f507bb6e | ||
|
|
81f19b950d | ||
|
|
7b4eac2294 | ||
|
|
aff4b62258 | ||
|
|
5798409f69 | ||
|
|
1b227b8b4f | ||
|
|
4eca7f0524 | ||
|
|
4bb66179ab | ||
|
|
d84a29e1a1 | ||
|
|
482a796212 | ||
|
|
41694d3751 | ||
|
|
82ca729e2b | ||
|
|
e67361257e | ||
|
|
e9f4a34d8d | ||
|
|
c6c4e31b32 | ||
|
|
ee5e4eccec | ||
|
|
a82fa8c26b | ||
|
|
97147a484d | ||
|
|
d8eddbfd03 | ||
|
|
977464e6a7 | ||
|
|
db84df0cbd | ||
|
|
1a7092d7b6 | ||
|
|
fcd05bb7e4 | ||
|
|
6c0694ff17 | ||
|
|
4988ff84df | ||
|
|
e5d0116924 | ||
|
|
38d3b17dfe | ||
|
|
89d305ac9f | ||
|
|
8b2be43897 | ||
|
|
0bd3b5aac2 | ||
|
|
8a995f28c9 | ||
|
|
e714b82802 | ||
|
|
7ec6ef3139 | ||
|
|
afc2d95736 | ||
|
|
1e9ffe351b | ||
|
|
0571ed0735 | ||
|
|
43941ecd5e | ||
|
|
8b9788ca74 | ||
|
|
eb38bcdab7 | ||
|
|
9876d1c7e6 | ||
|
|
5e8e6ef2f3 | ||
|
|
2055c4e8d3 | ||
|
|
97cdac10d7 | ||
|
|
df22902035 | ||
|
|
d67a439051 | ||
|
|
58a1383380 | ||
|
|
f5af5dd8c0 | ||
|
|
b9dd05f292 | ||
|
|
44f39e0b43 | ||
|
|
2b0281c510 | ||
|
|
03766e8980 | ||
|
|
e7b7377b7e | ||
|
|
a3a6d39436 | ||
|
|
76cfa28a42 | ||
|
|
afa7d2b595 | ||
|
|
c64dcf3423 | ||
|
|
3fa38d0b4e | ||
|
|
abb210bd18 | ||
|
|
3d3e116797 | ||
|
|
bd3a132868 |
@@ -4,7 +4,7 @@ import inspect
|
||||
import frappe
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "15.83.1"
|
||||
__version__ = "15.87.1"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -110,6 +110,7 @@ class Account(NestedSet):
|
||||
self.validate_parent_child_account_type()
|
||||
self.validate_root_details()
|
||||
self.validate_account_number()
|
||||
self.validate_disabled()
|
||||
self.validate_group_or_ledger()
|
||||
self.set_root_and_report_type()
|
||||
self.validate_mandatory()
|
||||
@@ -254,6 +255,14 @@ class Account(NestedSet):
|
||||
|
||||
self.create_account_for_child_company(parent_acc_name_map, descendants, parent_acc_name)
|
||||
|
||||
def validate_disabled(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save or cint(doc_before_save.disabled) == cint(self.disabled):
|
||||
return
|
||||
|
||||
if cint(self.disabled):
|
||||
self.validate_default_accounts_in_company()
|
||||
|
||||
def validate_group_or_ledger(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save or cint(doc_before_save.is_group) == cint(self.is_group):
|
||||
@@ -264,9 +273,32 @@ class Account(NestedSet):
|
||||
elif cint(self.is_group):
|
||||
if self.account_type and not self.flags.exclude_account_type_check:
|
||||
throw(_("Cannot covert to Group because Account Type is selected."))
|
||||
self.validate_default_accounts_in_company()
|
||||
elif self.check_if_child_exists():
|
||||
throw(_("Account with child nodes cannot be set as ledger"))
|
||||
|
||||
def validate_default_accounts_in_company(self):
|
||||
default_account_fields = get_company_default_account_fields()
|
||||
|
||||
company_default_accounts = frappe.db.get_value(
|
||||
"Company", self.company, list(default_account_fields.keys()), as_dict=1
|
||||
)
|
||||
|
||||
msg = _("Account {0} cannot be disabled as it is already set as {1} for {2}.")
|
||||
|
||||
if not self.disabled:
|
||||
msg = _("Account {0} cannot be converted to Group as it is already set as {1} for {2}.")
|
||||
|
||||
for d in default_account_fields:
|
||||
if company_default_accounts.get(d) == self.name:
|
||||
throw(
|
||||
msg.format(
|
||||
frappe.bold(self.name),
|
||||
frappe.bold(default_account_fields.get(d)),
|
||||
frappe.bold(self.company),
|
||||
)
|
||||
)
|
||||
|
||||
def validate_frozen_accounts_modifier(self):
|
||||
doc_before_save = self.get_doc_before_save()
|
||||
if not doc_before_save or doc_before_save.freeze_account == self.freeze_account:
|
||||
@@ -627,3 +659,27 @@ def _ensure_idle_system():
|
||||
).format(pretty_date(last_gl_update)),
|
||||
title=_("System In Use"),
|
||||
)
|
||||
|
||||
|
||||
def get_company_default_account_fields():
|
||||
return {
|
||||
"default_bank_account": "Default Bank Account",
|
||||
"default_cash_account": "Default Cash Account",
|
||||
"default_receivable_account": "Default Receivable Account",
|
||||
"default_payable_account": "Default Payable Account",
|
||||
"default_expense_account": "Default Expense Account",
|
||||
"default_income_account": "Default Income Account",
|
||||
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
|
||||
"stock_adjustment_account": "Stock Adjustment Account",
|
||||
"write_off_account": "Write Off Account",
|
||||
"default_discount_account": "Default Payment Discount Account",
|
||||
"unrealized_profit_loss_account": "Unrealized Profit / Loss Account",
|
||||
"exchange_gain_loss_account": "Exchange Gain / Loss Account",
|
||||
"unrealized_exchange_gain_loss_account": "Unrealized Exchange Gain / Loss Account",
|
||||
"round_off_account": "Round Off Account",
|
||||
"default_deferred_revenue_account": "Default Deferred Revenue Account",
|
||||
"default_deferred_expense_account": "Default Deferred Expense Account",
|
||||
"accumulated_depreciation_account": "Accumulated Depreciation Account",
|
||||
"depreciation_expense_account": "Depreciation Expense Account",
|
||||
"disposal_account": "Gain/Loss Account on Asset Disposal",
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"period_closing_settings_section",
|
||||
"acc_frozen_upto",
|
||||
"ignore_account_closing_balance",
|
||||
"use_legacy_controller_for_pcv",
|
||||
"column_break_25",
|
||||
"frozen_accounts_modifier",
|
||||
"tab_break_dpet",
|
||||
@@ -629,6 +630,12 @@
|
||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||
"fieldtype": "Check",
|
||||
"label": "Fetch Valuation Rate for Internal Transaction"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "use_legacy_controller_for_pcv",
|
||||
"fieldtype": "Check",
|
||||
"label": "Use Legacy Controller For Period Closing Voucher"
|
||||
}
|
||||
],
|
||||
"icon": "icon-cog",
|
||||
@@ -636,7 +643,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-18 13:56:47.192437",
|
||||
"modified": "2025-10-20 14:06:08.870427",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Accounts Settings",
|
||||
|
||||
@@ -73,6 +73,7 @@ class AccountsSettings(Document):
|
||||
submit_journal_entries: DF.Check
|
||||
unlink_advance_payment_on_cancelation_of_order: DF.Check
|
||||
unlink_payment_on_cancellation_of_invoice: DF.Check
|
||||
use_legacy_controller_for_pcv: DF.Check
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
|
||||
@@ -132,7 +132,8 @@
|
||||
"fieldtype": "Data",
|
||||
"in_list_view": 1,
|
||||
"label": "IBAN",
|
||||
"length": 30
|
||||
"length": 34,
|
||||
"options": "IBAN"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
@@ -208,6 +209,7 @@
|
||||
"label": "Disabled"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"links": [
|
||||
{
|
||||
"group": "Transactions",
|
||||
@@ -250,7 +252,7 @@
|
||||
"link_fieldname": "default_bank_account"
|
||||
}
|
||||
],
|
||||
"modified": "2024-10-30 09:41:14.113414",
|
||||
"modified": "2025-08-29 12:32:01.081687",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Account",
|
||||
@@ -282,6 +284,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "bank,account",
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
|
||||
@@ -9,7 +9,8 @@ from frappe.contacts.address_and_contact import (
|
||||
load_address_and_contact,
|
||||
)
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import comma_and, get_link_to_form
|
||||
from frappe.utils import comma_and, get_link_to_form, validate_iban
|
||||
from frappe.utils.deprecations import deprecated
|
||||
|
||||
|
||||
class BankAccount(Document):
|
||||
@@ -52,7 +53,6 @@ class BankAccount(Document):
|
||||
|
||||
def validate(self):
|
||||
self.validate_company()
|
||||
self.validate_iban()
|
||||
self.validate_account()
|
||||
self.update_default_bank_account()
|
||||
|
||||
@@ -72,34 +72,10 @@ class BankAccount(Document):
|
||||
if self.is_company_account and not self.company:
|
||||
frappe.throw(_("Company is manadatory for company account"))
|
||||
|
||||
@deprecated
|
||||
def validate_iban(self):
|
||||
"""
|
||||
Algorithm: https://en.wikipedia.org/wiki/International_Bank_Account_Number#Validating_the_IBAN
|
||||
"""
|
||||
# IBAN field is optional
|
||||
if not self.iban:
|
||||
return
|
||||
|
||||
def encode_char(c):
|
||||
# Position in the alphabet (A=1, B=2, ...) plus nine
|
||||
return str(9 + ord(c) - 64)
|
||||
|
||||
# remove whitespaces, upper case to get the right number from ord()
|
||||
iban = "".join(self.iban.split(" ")).upper()
|
||||
|
||||
# Move country code and checksum from the start to the end
|
||||
flipped = iban[4:] + iban[:4]
|
||||
|
||||
# Encode characters as numbers
|
||||
encoded = [encode_char(c) if ord(c) >= 65 and ord(c) <= 90 else c for c in flipped]
|
||||
|
||||
try:
|
||||
to_check = int("".join(encoded))
|
||||
except ValueError:
|
||||
frappe.throw(_("IBAN is not valid"))
|
||||
|
||||
if to_check % 97 != 1:
|
||||
frappe.throw(_("IBAN is not valid"))
|
||||
"""Kept for backward compatibility, will be removed in v16."""
|
||||
validate_iban(self.iban, throw=True)
|
||||
|
||||
def update_default_bank_account(self):
|
||||
if self.is_default and not self.disabled:
|
||||
|
||||
@@ -3,45 +3,8 @@
|
||||
|
||||
import unittest
|
||||
|
||||
import frappe
|
||||
from frappe import ValidationError
|
||||
|
||||
# test_records = frappe.get_test_records('Bank Account')
|
||||
|
||||
|
||||
class TestBankAccount(unittest.TestCase):
|
||||
def test_validate_iban(self):
|
||||
valid_ibans = [
|
||||
"GB82 WEST 1234 5698 7654 32",
|
||||
"DE91 1000 0000 0123 4567 89",
|
||||
"FR76 3000 6000 0112 3456 7890 189",
|
||||
]
|
||||
|
||||
invalid_ibans = [
|
||||
# wrong checksum (3rd place)
|
||||
"GB72 WEST 1234 5698 7654 32",
|
||||
"DE81 1000 0000 0123 4567 89",
|
||||
"FR66 3000 6000 0112 3456 7890 189",
|
||||
]
|
||||
|
||||
bank_account = frappe.get_doc({"doctype": "Bank Account"})
|
||||
|
||||
try:
|
||||
bank_account.validate_iban()
|
||||
except AttributeError:
|
||||
msg = "BankAccount.validate_iban() failed for empty IBAN"
|
||||
self.fail(msg=msg)
|
||||
|
||||
for iban in valid_ibans:
|
||||
bank_account.iban = iban
|
||||
try:
|
||||
bank_account.validate_iban()
|
||||
except ValidationError:
|
||||
msg = f"BankAccount.validate_iban() failed for valid IBAN {iban}"
|
||||
self.fail(msg=msg)
|
||||
|
||||
for not_iban in invalid_ibans:
|
||||
bank_account.iban = not_iban
|
||||
msg = f"BankAccount.validate_iban() accepted invalid IBAN {not_iban}"
|
||||
with self.assertRaises(ValidationError, msg=msg):
|
||||
bank_account.validate_iban()
|
||||
pass
|
||||
|
||||
@@ -9,13 +9,6 @@ cur_frm.add_fetch("bank", "swift_number", "swift_number");
|
||||
|
||||
frappe.ui.form.on("Bank Guarantee", {
|
||||
setup: function (frm) {
|
||||
frm.set_query("bank", function () {
|
||||
return {
|
||||
filters: {
|
||||
company: frm.doc.company,
|
||||
},
|
||||
};
|
||||
});
|
||||
frm.set_query("bank_account", function () {
|
||||
return {
|
||||
filters: {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"fieldname": "iban",
|
||||
"fieldtype": "Data",
|
||||
"label": "IBAN",
|
||||
"options": "IBAN",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -216,7 +217,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-25 23:44:12.473583",
|
||||
"modified": "2025-09-26 00:38:17.584694",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Guarantee",
|
||||
|
||||
@@ -123,8 +123,7 @@
|
||||
"fieldname": "transaction_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Transaction ID",
|
||||
"read_only": 1,
|
||||
"unique": 1
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
@@ -223,7 +222,8 @@
|
||||
{
|
||||
"fieldname": "bank_party_iban",
|
||||
"fieldtype": "Data",
|
||||
"label": "Party IBAN (Bank Statement)"
|
||||
"label": "Party IBAN (Bank Statement)",
|
||||
"options": "IBAN"
|
||||
},
|
||||
{
|
||||
"fieldname": "bank_party_account_number",
|
||||
@@ -238,7 +238,7 @@
|
||||
"grid_page_length": 50,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-26 17:06:29.207673",
|
||||
"modified": "2025-10-23 17:32:58.514807",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Transaction",
|
||||
|
||||
@@ -99,7 +99,7 @@ class FiscalYear(Document):
|
||||
)
|
||||
|
||||
overlap = False
|
||||
if not self.get("companies") or not company_for_existing:
|
||||
if not self.get("companies") and not company_for_existing:
|
||||
overlap = True
|
||||
|
||||
for d in self.get("companies"):
|
||||
|
||||
@@ -26,6 +26,27 @@ class TestFiscalYear(unittest.TestCase):
|
||||
|
||||
self.assertRaises(frappe.exceptions.InvalidDates, fy.insert)
|
||||
|
||||
def test_company_fiscal_year_overlap(self):
|
||||
for name in ["_Test Global FY 2001", "_Test Company FY 2001"]:
|
||||
if frappe.db.exists("Fiscal Year", name):
|
||||
frappe.delete_doc("Fiscal Year", name)
|
||||
|
||||
global_fy = frappe.new_doc("Fiscal Year")
|
||||
global_fy.year = "_Test Global FY 2001"
|
||||
global_fy.year_start_date = "2001-04-01"
|
||||
global_fy.year_end_date = "2002-03-31"
|
||||
global_fy.insert()
|
||||
|
||||
company_fy = frappe.new_doc("Fiscal Year")
|
||||
company_fy.year = "_Test Company FY 2001"
|
||||
company_fy.year_start_date = "2001-01-01"
|
||||
company_fy.year_end_date = "2001-12-31"
|
||||
company_fy.append("companies", {"company": "_Test Company"})
|
||||
|
||||
company_fy.insert()
|
||||
self.assertTrue(frappe.db.exists("Fiscal Year", global_fy.name))
|
||||
self.assertTrue(frappe.db.exists("Fiscal Year", company_fy.name))
|
||||
|
||||
|
||||
def test_record_generator():
|
||||
test_records = [
|
||||
|
||||
@@ -106,7 +106,6 @@
|
||||
"fieldname": "account_currency",
|
||||
"fieldtype": "Link",
|
||||
"label": "Account Currency",
|
||||
"no_copy": 1,
|
||||
"options": "Currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
@@ -272,7 +271,8 @@
|
||||
"label": "Advance Voucher Type",
|
||||
"no_copy": 1,
|
||||
"options": "DocType",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "advance_voucher_no",
|
||||
@@ -280,13 +280,14 @@
|
||||
"label": "Advance Voucher No",
|
||||
"no_copy": 1,
|
||||
"options": "advance_voucher_type",
|
||||
"read_only": 1
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
}
|
||||
],
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-29 13:01:48.916517",
|
||||
"modified": "2025-10-27 13:48:32.805100",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Journal Entry Account",
|
||||
|
||||
@@ -385,6 +385,16 @@ frappe.ui.form.on("Payment Reconciliation Allocation", {
|
||||
// filter payment
|
||||
let payment = frm.doc.payments.filter((x) => x.reference_name == row.reference_name);
|
||||
|
||||
let amount = payment[0].amount;
|
||||
for (const d of frm.doc.allocation) {
|
||||
if (row.reference_name == d.reference_name && amount) {
|
||||
if (d.allocated_amount <= amount) {
|
||||
d.amount = amount;
|
||||
amount -= d.allocated_amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
frm.call({
|
||||
doc: frm.doc,
|
||||
method: "calculate_difference_on_allocation_change",
|
||||
|
||||
@@ -226,7 +226,8 @@
|
||||
"fetch_from": "bank_account.iban",
|
||||
"fieldname": "iban",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "IBAN"
|
||||
"label": "IBAN",
|
||||
"options": "IBAN"
|
||||
},
|
||||
{
|
||||
"fetch_from": "bank_account.branch_code",
|
||||
@@ -443,11 +444,12 @@
|
||||
"label": "Phone Number"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"in_create": 1,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2024-12-27 21:29:10.361894",
|
||||
"modified": "2025-08-29 11:52:48.555415",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Payment Request",
|
||||
@@ -482,8 +484,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"show_preview_popup": 1,
|
||||
"sort_field": "modified",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
frappe.ui.form.on("Period Closing Voucher", {
|
||||
onload: function (frm) {
|
||||
if (!frm.doc.transaction_date) frm.doc.transaction_date = frappe.datetime.obj_to_str(new Date());
|
||||
|
||||
frm.ignore_doctypes_on_cancel_all = ["Process Period Closing Voucher"];
|
||||
},
|
||||
|
||||
setup: function (frm) {
|
||||
|
||||
@@ -132,7 +132,11 @@ class PeriodClosingVoucher(AccountsController):
|
||||
|
||||
def on_submit(self):
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
self.make_gl_entries()
|
||||
if frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.make_gl_entries()
|
||||
else:
|
||||
ppcv = frappe.get_doc({"doctype": "Process Period Closing Voucher", "parent_pcv": self.name})
|
||||
ppcv.save().submit()
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = (
|
||||
@@ -140,11 +144,29 @@ class PeriodClosingVoucher(AccountsController):
|
||||
"Stock Ledger Entry",
|
||||
"Payment Ledger Entry",
|
||||
"Account Closing Balance",
|
||||
"Process Period Closing Voucher",
|
||||
)
|
||||
self.block_if_future_closing_voucher_exists()
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "use_legacy_controller_for_pcv"):
|
||||
self.cancel_process_pcv_docs()
|
||||
|
||||
self.db_set("gle_processing_status", "In Progress")
|
||||
self.cancel_gl_entries()
|
||||
|
||||
def cancel_process_pcv_docs(self):
|
||||
ppcvs = frappe.db.get_all("Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": 1})
|
||||
for x in ppcvs:
|
||||
frappe.get_doc("Process Period Closing Voucher", x.name).cancel()
|
||||
|
||||
def on_trash(self):
|
||||
super().on_trash()
|
||||
ppcvs = frappe.db.get_all(
|
||||
"Process Period Closing Voucher", {"parent_pcv": self.name, "docstatus": ["in", [1, 2]]}
|
||||
)
|
||||
for x in ppcvs:
|
||||
frappe.delete_doc("Process Period Closing Voucher", x.name, force=True, ignore_permissions=True)
|
||||
|
||||
def make_gl_entries(self):
|
||||
if frappe.db.estimate_count("GL Entry") > 100_000:
|
||||
frappe.enqueue(
|
||||
|
||||
@@ -14,6 +14,10 @@ from erpnext.accounts.utils import get_fiscal_year
|
||||
|
||||
|
||||
class TestPeriodClosingVoucher(unittest.TestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
|
||||
def test_closing_entry(self):
|
||||
frappe.db.sql("delete from `tabGL Entry` where company='Test PCV Company'")
|
||||
frappe.db.sql("delete from `tabPeriod Closing Voucher` where company='Test PCV Company'")
|
||||
|
||||
@@ -161,6 +161,8 @@ def get_pos_invoices(start, end, pos_profile, user):
|
||||
`tabPOS Invoice`
|
||||
where
|
||||
owner = %s and docstatus = 1 and pos_profile = %s and ifnull(consolidated_invoice,'') = ''
|
||||
order by
|
||||
timestamp
|
||||
""",
|
||||
(user, pos_profile),
|
||||
as_dict=1,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("Process Period Closing Voucher", {
|
||||
refresh(frm) {
|
||||
if (frm.doc.docstatus == 1 && ["Queued"].find((x) => x == frm.doc.status)) {
|
||||
let execute_btn = __("Start");
|
||||
|
||||
frm.add_custom_button(execute_btn, () => {
|
||||
frm.call({
|
||||
method: "erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.start_pcv_processing",
|
||||
args: {
|
||||
docname: frm.doc.name,
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.exc) {
|
||||
frappe.show_alert(__("Job Started"));
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1 && ["Running"].find((x) => x == frm.doc.status)) {
|
||||
let execute_btn = __("Pause");
|
||||
|
||||
frm.add_custom_button(execute_btn, () => {
|
||||
frm.call({
|
||||
method: "erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.pause_pcv_processing",
|
||||
args: {
|
||||
docname: frm.doc.name,
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.exc) {
|
||||
frappe.show_alert(__("PCV Paused"));
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (frm.doc.docstatus == 1 && ["Paused"].find((x) => x == frm.doc.status)) {
|
||||
let execute_btn = __("Resume");
|
||||
|
||||
frm.add_custom_button(execute_btn, () => {
|
||||
frm.call({
|
||||
method: "erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.resume_pcv_processing",
|
||||
args: {
|
||||
docname: frm.doc.name,
|
||||
},
|
||||
}).then((r) => {
|
||||
if (!r.exc) {
|
||||
frappe.show_alert(__("PCV Resumed"));
|
||||
frm.reload_doc();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
// progress bar
|
||||
let progress = 0;
|
||||
|
||||
let normal_finished = frm.doc.normal_balances.filter((x) => x.status == "Completed").length;
|
||||
let opening_finished = frm.doc.z_opening_balances.filter((x) => x.status == "Completed").length;
|
||||
|
||||
progress =
|
||||
((normal_finished + opening_finished) /
|
||||
(frm.doc.normal_balances.length + frm.doc.z_opening_balances.length)) *
|
||||
100;
|
||||
frm.dashboard.add_progress("Books closure progress", progress, "");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"actions": [],
|
||||
"autoname": "format:Process-PCV-{###}",
|
||||
"creation": "2025-09-25 15:44:03.534699",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"parent_pcv",
|
||||
"status",
|
||||
"p_l_closing_balance",
|
||||
"normal_balances",
|
||||
"bs_closing_balance",
|
||||
"z_opening_balances",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "parent_pcv",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "PCV",
|
||||
"options": "Period Closing Voucher",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
"default": "Queued",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Queued\nRunning\nPaused\nCompleted\nCancelled"
|
||||
},
|
||||
{
|
||||
"fieldname": "amended_from",
|
||||
"fieldtype": "Link",
|
||||
"label": "Amended From",
|
||||
"no_copy": 1,
|
||||
"options": "Process Period Closing Voucher",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"search_index": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "p_l_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"label": "P&L Closing Balance",
|
||||
"no_copy": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "normal_balances",
|
||||
"fieldtype": "Table",
|
||||
"label": "Dates to Process",
|
||||
"no_copy": 1,
|
||||
"options": "Process Period Closing Voucher Detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "z_opening_balances",
|
||||
"fieldtype": "Table",
|
||||
"label": "Opening Balances",
|
||||
"no_copy": 1,
|
||||
"options": "Process Period Closing Voucher Detail"
|
||||
},
|
||||
{
|
||||
"fieldname": "bs_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"label": "Balance Sheet Closing Balance"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-11-05 11:40:24.996403",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
},
|
||||
{
|
||||
"cancel": 1,
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "Accounts Manager",
|
||||
"share": 1,
|
||||
"submit": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Count, Max, Min, Sum
|
||||
from frappe.utils import add_days, flt, get_datetime
|
||||
from frappe.utils.scheduler import is_scheduler_inactive
|
||||
|
||||
from erpnext.accounts.doctype.account_closing_balance.account_closing_balance import (
|
||||
make_closing_entries,
|
||||
)
|
||||
|
||||
|
||||
class ProcessPeriodClosingVoucher(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
from erpnext.accounts.doctype.process_period_closing_voucher_detail.process_period_closing_voucher_detail import (
|
||||
ProcessPeriodClosingVoucherDetail,
|
||||
)
|
||||
|
||||
amended_from: DF.Link | None
|
||||
bs_closing_balance: DF.JSON | None
|
||||
normal_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
||||
p_l_closing_balance: DF.JSON | None
|
||||
parent_pcv: DF.Link
|
||||
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
||||
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
||||
# end: auto-generated types
|
||||
|
||||
def validate(self):
|
||||
self.status = "Queued"
|
||||
self.populate_processing_tables()
|
||||
|
||||
def populate_processing_tables(self):
|
||||
self.generate_pcv_dates()
|
||||
self.generate_opening_balances_dates()
|
||||
|
||||
def get_dates(self, start, end):
|
||||
return [start + timedelta(days=x) for x in range((end - start).days + 1)]
|
||||
|
||||
def generate_pcv_dates(self):
|
||||
self.normal_balances = []
|
||||
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
|
||||
|
||||
dates = self.get_dates(get_datetime(pcv.period_start_date), get_datetime(pcv.period_end_date))
|
||||
for x in dates:
|
||||
self.append(
|
||||
"normal_balances",
|
||||
{"processing_date": x, "status": "Queued", "report_type": "Profit and Loss"},
|
||||
)
|
||||
self.append(
|
||||
"normal_balances", {"processing_date": x, "status": "Queued", "report_type": "Balance Sheet"}
|
||||
)
|
||||
|
||||
def generate_opening_balances_dates(self):
|
||||
self.z_opening_balances = []
|
||||
|
||||
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
|
||||
if pcv.is_first_period_closing_voucher():
|
||||
gl = qb.DocType("GL Entry")
|
||||
min = qb.from_(gl).select(Min(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
||||
max = qb.from_(gl).select(Max(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
||||
|
||||
dates = self.get_dates(get_datetime(min), get_datetime(max))
|
||||
for x in dates:
|
||||
self.append(
|
||||
"z_opening_balances",
|
||||
{"processing_date": x, "status": "Queued", "report_type": "Balance Sheet"},
|
||||
)
|
||||
|
||||
def on_submit(self):
|
||||
start_pcv_processing(self.name)
|
||||
|
||||
def on_cancel(self):
|
||||
cancel_pcv_processing(self.name)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def start_pcv_processing(docname: str):
|
||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||
if normal_balances := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
fields=["processing_date", "report_type", "parentfield"],
|
||||
order_by="parentfield, idx, processing_date",
|
||||
limit=4,
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
for x in normal_balances:
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": x.processing_date,
|
||||
"parent": docname,
|
||||
"report_type": x.report_type,
|
||||
"parentfield": x.parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=x.processing_date,
|
||||
report_type=x.report_type,
|
||||
parentfield=x.parentfield,
|
||||
)
|
||||
else:
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def pause_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
if queued_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Paused").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def cancel_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Cancelled").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
if queued_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Cancelled").where(ppcvd.name.isin(queued_dates)).run()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def resume_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
qb.update(ppcv).set(ppcv.status, "Running").where(ppcv.name.eq(docname)).run()
|
||||
|
||||
if paused_dates := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Paused"},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||
start_pcv_processing(docname)
|
||||
|
||||
|
||||
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
||||
for i, dimension in enumerate(dimension_fields):
|
||||
gl_entry[dimension] = dimension_values[i]
|
||||
|
||||
|
||||
def get_gle_for_pl_account(pcv, acc, balances, dimensions):
|
||||
balance_in_account_currency = flt(balances.debit_in_account_currency) - flt(
|
||||
balances.credit_in_account_currency
|
||||
)
|
||||
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
|
||||
gl_entry = frappe._dict(
|
||||
{
|
||||
"company": pcv.company,
|
||||
"posting_date": pcv.period_end_date,
|
||||
"account": acc,
|
||||
"account_currency": balances.account_currency,
|
||||
"debit_in_account_currency": abs(balance_in_account_currency)
|
||||
if balance_in_account_currency < 0
|
||||
else 0,
|
||||
"debit": abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0,
|
||||
"credit_in_account_currency": abs(balance_in_account_currency)
|
||||
if balance_in_account_currency > 0
|
||||
else 0,
|
||||
"credit": abs(balance_in_company_currency) if balance_in_company_currency > 0 else 0,
|
||||
"is_period_closing_voucher_entry": 1,
|
||||
"voucher_type": "Period Closing Voucher",
|
||||
"voucher_no": pcv.name,
|
||||
"fiscal_year": pcv.fiscal_year,
|
||||
"remarks": pcv.remarks,
|
||||
"is_opening": "No",
|
||||
}
|
||||
)
|
||||
# update dimensions
|
||||
update_default_dimensions(get_dimensions(), gl_entry, dimensions)
|
||||
return gl_entry
|
||||
|
||||
|
||||
def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
||||
balance_in_company_currency = flt(dimension_balance.balance_in_company_currency)
|
||||
debit = balance_in_company_currency if balance_in_company_currency > 0 else 0
|
||||
credit = abs(balance_in_company_currency) if balance_in_company_currency < 0 else 0
|
||||
|
||||
gl_entry = frappe._dict(
|
||||
{
|
||||
"company": pcv.company,
|
||||
"posting_date": pcv.period_end_date,
|
||||
"account": pcv.closing_account_head,
|
||||
"account_currency": frappe.db.get_value("Account", pcv.closing_account_head, "account_currency"),
|
||||
"debit_in_account_currency": debit,
|
||||
"debit": debit,
|
||||
"credit_in_account_currency": credit,
|
||||
"credit": credit,
|
||||
"is_period_closing_voucher_entry": 1,
|
||||
"voucher_type": "Period Closing Voucher",
|
||||
"voucher_no": pcv.name,
|
||||
"fiscal_year": pcv.fiscal_year,
|
||||
"remarks": pcv.remarks,
|
||||
"is_opening": "No",
|
||||
}
|
||||
)
|
||||
# update dimensions
|
||||
update_default_dimensions(get_dimensions(), gl_entry, dimensions)
|
||||
return gl_entry
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def schedule_next_date(docname: str):
|
||||
if to_process := frappe.db.get_all(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
fields=["processing_date", "report_type", "parentfield"],
|
||||
order_by="parentfield, idx, processing_date",
|
||||
limit=1,
|
||||
):
|
||||
if not is_scheduler_inactive():
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{
|
||||
"processing_date": to_process[0].processing_date,
|
||||
"parent": docname,
|
||||
"report_type": to_process[0].report_type,
|
||||
"parentfield": to_process[0].parentfield,
|
||||
},
|
||||
"status",
|
||||
"Running",
|
||||
)
|
||||
frappe.enqueue(
|
||||
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.process_individual_date",
|
||||
queue="long",
|
||||
timeout="3600",
|
||||
is_async=True,
|
||||
enqueue_after_commit=True,
|
||||
docname=docname,
|
||||
date=to_process[0].processing_date,
|
||||
report_type=to_process[0].report_type,
|
||||
parentfield=to_process[0].parentfield,
|
||||
)
|
||||
else:
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
total_no_of_dates = (
|
||||
qb.from_(ppcvd).select(Count(ppcvd.star)).where(ppcvd.parent.eq(docname)).run()[0][0]
|
||||
)
|
||||
completed = (
|
||||
qb.from_(ppcvd)
|
||||
.select(Count(ppcvd.star))
|
||||
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Completed"))
|
||||
.run()[0][0]
|
||||
)
|
||||
# Ensure both normal and opening balances are processed for all dates
|
||||
if total_no_of_dates == completed:
|
||||
summarize_and_post_ledger_entries(docname)
|
||||
|
||||
|
||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||
"""
|
||||
convert tuple -> str
|
||||
JSON doesn't support dictionary with tuple keys
|
||||
"""
|
||||
converted_dict = {}
|
||||
for k, v in dimension_wise_balance.items():
|
||||
str_key = [str(x) for x in k]
|
||||
str_key = ",".join(str_key)
|
||||
converted_dict[str_key] = v
|
||||
|
||||
return converted_dict
|
||||
|
||||
|
||||
def get_consolidated_gles(balances, report_type) -> list:
|
||||
gl_entries = []
|
||||
for x in balances:
|
||||
if x.report_type == report_type:
|
||||
closing_balances = [frappe._dict(gle) for gle in frappe.json.loads(x.closing_balance)]
|
||||
gl_entries.extend(closing_balances)
|
||||
return gl_entries
|
||||
|
||||
|
||||
def get_gl_entries(docname):
|
||||
"""
|
||||
Calculate total closing balance of all P&L accounts across PCV start and end date
|
||||
"""
|
||||
ppcv = frappe.get_doc("Process Period Closing Voucher", docname)
|
||||
|
||||
# calculate balance
|
||||
gl_entries = get_consolidated_gles(ppcv.normal_balances, "Profit and Loss")
|
||||
pl_dimension_wise_acc_balance = build_dimension_wise_balance_dict(gl_entries)
|
||||
|
||||
# save
|
||||
json_dict = make_dict_json_compliant(pl_dimension_wise_acc_balance)
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher", docname, "p_l_closing_balance", frappe.json.dumps(json_dict)
|
||||
)
|
||||
|
||||
# build gl map
|
||||
pcv = frappe.get_doc("Period Closing Voucher", ppcv.parent_pcv)
|
||||
pl_accounts_reverse_gle = []
|
||||
closing_account_gle = []
|
||||
|
||||
for dimensions, account_balances in pl_dimension_wise_acc_balance.items():
|
||||
for acc, balances in account_balances.items():
|
||||
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
|
||||
if balance_in_company_currency:
|
||||
pl_accounts_reverse_gle.append(get_gle_for_pl_account(pcv, acc, balances, dimensions))
|
||||
|
||||
closing_account_gle.append(get_gle_for_closing_account(pcv, account_balances["balances"], dimensions))
|
||||
|
||||
return pl_accounts_reverse_gle, closing_account_gle
|
||||
|
||||
|
||||
def calculate_balance_sheet_balance(docname):
|
||||
"""
|
||||
Calculate total closing balance of all P&L accounts across PCV start and end date.
|
||||
If it is first PCV, opening entries are also considered
|
||||
"""
|
||||
ppcv = frappe.get_doc("Process Period Closing Voucher", docname)
|
||||
gl_entries = get_consolidated_gles(ppcv.normal_balances + ppcv.z_opening_balances, "Balance Sheet")
|
||||
|
||||
# build dimension wise dictionary from all GLE's
|
||||
bs_dimension_wise_acc_balance = build_dimension_wise_balance_dict(gl_entries)
|
||||
|
||||
# save
|
||||
json_dict = make_dict_json_compliant(bs_dimension_wise_acc_balance)
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher", docname, "bs_closing_balance", frappe.json.dumps(json_dict)
|
||||
)
|
||||
return bs_dimension_wise_acc_balance
|
||||
|
||||
|
||||
def get_p_l_closing_entries(pl_gles, pcv):
|
||||
pl_closing_entries = copy.deepcopy(pl_gles)
|
||||
for d in pl_gles:
|
||||
# reverse debit and credit
|
||||
gle_copy = copy.deepcopy(d)
|
||||
gle_copy.debit = d.credit
|
||||
gle_copy.credit = d.debit
|
||||
gle_copy.debit_in_account_currency = d.credit_in_account_currency
|
||||
gle_copy.credit_in_account_currency = d.debit_in_account_currency
|
||||
gle_copy.is_period_closing_voucher_entry = 0
|
||||
gle_copy.period_closing_voucher = pcv.name
|
||||
pl_closing_entries.append(gle_copy)
|
||||
|
||||
return pl_closing_entries
|
||||
|
||||
|
||||
def get_bs_closing_entries(dimension_wise_balance, pcv):
|
||||
closing_entries = []
|
||||
for dimensions, account_balances in dimension_wise_balance.items():
|
||||
for acc, balances in account_balances.items():
|
||||
balance_in_company_currency = flt(balances.debit) - flt(balances.credit)
|
||||
if acc != "balances" and balance_in_company_currency:
|
||||
closing_entries.append(get_closing_entry(pcv, acc, balances, dimensions))
|
||||
|
||||
return closing_entries
|
||||
|
||||
|
||||
def get_closing_account_closing_entry(closing_account_gle, pcv):
|
||||
closing_entries_for_closing_account = copy.deepcopy(closing_account_gle)
|
||||
for d in closing_entries_for_closing_account:
|
||||
d.period_closing_voucher = pcv.name
|
||||
return closing_entries_for_closing_account
|
||||
|
||||
|
||||
def summarize_and_post_ledger_entries(docname):
|
||||
# P&L accounts
|
||||
pl_accounts_reverse_gle, closing_account_gle = get_gl_entries(docname)
|
||||
gl_entries = pl_accounts_reverse_gle + closing_account_gle
|
||||
from erpnext.accounts.general_ledger import make_gl_entries
|
||||
|
||||
if gl_entries:
|
||||
make_gl_entries(gl_entries, merge_entries=False)
|
||||
|
||||
pcv_name = frappe.db.get_value("Process Period Closing Voucher", docname, "parent_pcv")
|
||||
pcv = frappe.get_doc("Period Closing Voucher", pcv_name)
|
||||
|
||||
# Balance sheet accounts
|
||||
bs_dimension_wise_acc_balance = calculate_balance_sheet_balance(docname)
|
||||
|
||||
pl_closing_entries = get_p_l_closing_entries(pl_accounts_reverse_gle, pcv)
|
||||
bs_closing_entries = get_bs_closing_entries(bs_dimension_wise_acc_balance, pcv)
|
||||
closing_entries_for_closing_account = get_closing_account_closing_entry(closing_account_gle, pcv)
|
||||
closing_entries = pl_closing_entries + bs_closing_entries + closing_entries_for_closing_account
|
||||
|
||||
make_closing_entries(closing_entries, pcv.name, pcv.company, pcv.period_end_date)
|
||||
|
||||
frappe.db.set_value("Period Closing Voucher", pcv.name, "gle_processing_status", "Completed")
|
||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Completed")
|
||||
|
||||
|
||||
def get_closing_entry(pcv, account, balances, dimensions):
|
||||
closing_entry = frappe._dict(
|
||||
{
|
||||
"company": pcv.company,
|
||||
"closing_date": pcv.period_end_date,
|
||||
"period_closing_voucher": pcv.name,
|
||||
"account": account,
|
||||
"account_currency": balances.account_currency,
|
||||
"debit_in_account_currency": flt(balances.debit_in_account_currency),
|
||||
"debit": flt(balances.debit),
|
||||
"credit_in_account_currency": flt(balances.credit_in_account_currency),
|
||||
"credit": flt(balances.credit),
|
||||
"is_period_closing_voucher_entry": 0,
|
||||
}
|
||||
)
|
||||
# update dimensions
|
||||
update_default_dimensions(get_dimensions(), closing_entry, dimensions)
|
||||
return closing_entry
|
||||
|
||||
|
||||
def get_dimensions():
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import (
|
||||
get_accounting_dimensions,
|
||||
)
|
||||
|
||||
default_dimensions = ["cost_center", "finance_book", "project"]
|
||||
dimensions = default_dimensions + get_accounting_dimensions()
|
||||
return dimensions
|
||||
|
||||
|
||||
def get_dimension_key(res):
|
||||
return tuple([res.get(dimension) for dimension in get_dimensions()])
|
||||
|
||||
|
||||
def build_dimension_wise_balance_dict(gl_entries):
|
||||
dimension_balances = frappe._dict()
|
||||
for x in gl_entries:
|
||||
dimension_key = get_dimension_key(x)
|
||||
dimension_balances.setdefault(dimension_key, frappe._dict()).setdefault(
|
||||
x.account,
|
||||
frappe._dict(
|
||||
{
|
||||
"debit_in_account_currency": 0,
|
||||
"credit_in_account_currency": 0,
|
||||
"debit": 0,
|
||||
"credit": 0,
|
||||
"account_currency": x.account_currency,
|
||||
}
|
||||
),
|
||||
)
|
||||
dimension_balances[dimension_key][x.account].debit_in_account_currency += flt(
|
||||
x.debit_in_account_currency
|
||||
)
|
||||
dimension_balances[dimension_key][x.account].credit_in_account_currency += flt(
|
||||
x.credit_in_account_currency
|
||||
)
|
||||
dimension_balances[dimension_key][x.account].debit += flt(x.debit)
|
||||
dimension_balances[dimension_key][x.account].credit += flt(x.credit)
|
||||
|
||||
# dimension-wise total balances
|
||||
dimension_balances[dimension_key].setdefault(
|
||||
"balances",
|
||||
frappe._dict(
|
||||
{
|
||||
"balance_in_account_currency": 0,
|
||||
"balance_in_company_currency": 0,
|
||||
}
|
||||
),
|
||||
)
|
||||
balance_in_account_currency = flt(x.debit_in_account_currency) - flt(x.credit_in_account_currency)
|
||||
balance_in_company_currency = flt(x.debit) - flt(x.credit)
|
||||
dimension_balances[dimension_key][
|
||||
"balances"
|
||||
].balance_in_account_currency += balance_in_account_currency
|
||||
dimension_balances[dimension_key][
|
||||
"balances"
|
||||
].balance_in_company_currency += balance_in_company_currency
|
||||
|
||||
return dimension_balances
|
||||
|
||||
|
||||
def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
current_date_status = frappe.db.get_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
)
|
||||
if current_date_status != "Running":
|
||||
return
|
||||
|
||||
pcv_name = frappe.db.get_value("Process Period Closing Voucher", docname, "parent_pcv")
|
||||
company = frappe.db.get_value("Period Closing Voucher", pcv_name, "company")
|
||||
|
||||
dimensions = get_dimensions()
|
||||
|
||||
accounts = frappe.db.get_all(
|
||||
"Account", filters={"company": company, "report_type": report_type}, pluck="name"
|
||||
)
|
||||
|
||||
# summarize
|
||||
gle = qb.DocType("GL Entry")
|
||||
query = qb.from_(gle).select(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.select(gle[dim])
|
||||
query = query.select(
|
||||
Sum(gle.debit).as_("debit"),
|
||||
Sum(gle.credit).as_("credit"),
|
||||
Sum(gle.debit_in_account_currency).as_("debit_in_account_currency"),
|
||||
Sum(gle.credit_in_account_currency).as_("credit_in_account_currency"),
|
||||
gle.account_currency,
|
||||
).where(
|
||||
(gle.company.eq(company))
|
||||
& (gle.is_cancelled.eq(0))
|
||||
& (gle.posting_date.eq(date))
|
||||
& (gle.account.isin(accounts))
|
||||
)
|
||||
|
||||
if parentfield == "z_opening_balances":
|
||||
query = query.where(gle.is_opening.eq("Yes"))
|
||||
|
||||
query = query.groupby(gle.account)
|
||||
for dim in dimensions:
|
||||
query = query.groupby(gle[dim])
|
||||
res = query.run(as_dict=True)
|
||||
|
||||
# save results
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"closing_balance",
|
||||
frappe.json.dumps(res),
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
"Process Period Closing Voucher Detail",
|
||||
{"processing_date": date, "parent": docname, "report_type": report_type, "parentfield": parentfield},
|
||||
"status",
|
||||
"Completed",
|
||||
)
|
||||
|
||||
# chain call
|
||||
schedule_next_date(docname)
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-10-01 15:58:17.544153",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"processing_date",
|
||||
"report_type",
|
||||
"status",
|
||||
"closing_balance"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "processing_date",
|
||||
"fieldtype": "Date",
|
||||
"in_list_view": 1,
|
||||
"label": "Processing Date"
|
||||
},
|
||||
{
|
||||
"default": "Queued",
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Status",
|
||||
"options": "Queued\nRunning\nPaused\nCompleted\nCancelled"
|
||||
},
|
||||
{
|
||||
"fieldname": "closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"in_list_view": 1,
|
||||
"label": "Closing Balance"
|
||||
},
|
||||
{
|
||||
"default": "Profit and Loss",
|
||||
"fieldname": "report_type",
|
||||
"fieldtype": "Select",
|
||||
"in_list_view": 1,
|
||||
"label": "Report Type",
|
||||
"options": "Profit and Loss\nBalance Sheet"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-10-20 12:03:59.106931",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher Detail",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"row_format": "Dynamic",
|
||||
"rows_threshold_for_grid_search": 20,
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.model.document import Document
|
||||
|
||||
|
||||
class ProcessPeriodClosingVoucherDetail(Document):
|
||||
# begin: auto-generated types
|
||||
# This code is auto-generated. Do not modify anything in this block.
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
closing_balance: DF.JSON | None
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
processing_date: DF.Date | None
|
||||
report_type: DF.Literal["Profit and Loss", "Balance Sheet"]
|
||||
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
||||
# end: auto-generated types
|
||||
|
||||
pass
|
||||
@@ -2411,6 +2411,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
target.purchase_order = source.purchase_order
|
||||
target.po_detail = source.purchase_order_item
|
||||
|
||||
if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
|
||||
target.use_serial_batch_fields = 1
|
||||
|
||||
item_field_map = {
|
||||
"doctype": target_doctype + " Item",
|
||||
"field_no_map": ["income_account", "expense_account", "cost_center", "warehouse"],
|
||||
|
||||
@@ -648,6 +648,7 @@ def make_reverse_gl_entries(
|
||||
adv_adj=False,
|
||||
update_outstanding="Yes",
|
||||
partial_cancel=False,
|
||||
posting_date=None,
|
||||
):
|
||||
"""
|
||||
Get original gl entries of the voucher
|
||||
@@ -745,6 +746,8 @@ def make_reverse_gl_entries(
|
||||
if immutable_ledger_enabled:
|
||||
new_gle["is_cancelled"] = 0
|
||||
new_gle["posting_date"] = frappe.form_dict.get("posting_date") or getdate()
|
||||
elif posting_date:
|
||||
new_gle["posting_date"] = posting_date
|
||||
|
||||
if new_gle["debit"] or new_gle["credit"]:
|
||||
make_entry(new_gle, adv_adj, "Yes")
|
||||
|
||||
@@ -664,7 +664,16 @@ class ReceivablePayableReport:
|
||||
invoiced = d.base_payment_amount
|
||||
paid_amount = d.base_paid_amount
|
||||
|
||||
if company_currency == d.party_account_currency or self.filters.get("in_party_currency"):
|
||||
in_party_currency = self.filters.get("in_party_currency")
|
||||
# company, billing, and party account currencies are the same
|
||||
if company_currency == d.currency and company_currency == d.party_account_currency:
|
||||
in_party_currency = False
|
||||
|
||||
# When filtered by party currency and the billing currency not matches the party account currency
|
||||
if in_party_currency and d.currency != d.party_account_currency:
|
||||
in_party_currency = False
|
||||
|
||||
if in_party_currency:
|
||||
invoiced = d.payment_amount
|
||||
paid_amount = d.paid_amount
|
||||
|
||||
|
||||
@@ -199,6 +199,81 @@ class TestAccountsReceivable(AccountsTestMixin, FrappeTestCase):
|
||||
row = report[1]
|
||||
self.assertTrue(len(row) == 0)
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1},
|
||||
)
|
||||
def test_allow_multi_currency_invoices_against_single_party_account(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
"based_on_payment_terms": 1,
|
||||
"report_date": today(),
|
||||
"range": "30, 60, 90, 120",
|
||||
"show_remarks": True,
|
||||
"in_party_currency": 1,
|
||||
}
|
||||
|
||||
# CASE 1: Company currency and party account currency are the same
|
||||
si = self.create_sales_invoice(qty=1, no_payment_schedule=True, do_not_submit=True)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
filters.update(
|
||||
{
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
}
|
||||
)
|
||||
report = execute(filters)
|
||||
row = report[1][0]
|
||||
|
||||
expected_data = [8000, 8000, "No Remarks"] # Data in company currency
|
||||
|
||||
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
|
||||
|
||||
# CASE 2: Transaction currency and party account currency are the same
|
||||
self.create_customer(
|
||||
"USD Customer", currency="USD", default_account=self.debtors_usd, company=self.company
|
||||
)
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
currency="USD",
|
||||
conversion_rate=80,
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
si.save().submit()
|
||||
|
||||
filters.update(
|
||||
{
|
||||
"party_type": "Customer",
|
||||
"party": [self.customer],
|
||||
}
|
||||
)
|
||||
report = execute(filters)
|
||||
row = report[1][0]
|
||||
|
||||
expected_data = [100, 100, "No Remarks"] # Data in Part Account Currency
|
||||
|
||||
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
|
||||
|
||||
# View in Company currency
|
||||
filters.pop("in_party_currency")
|
||||
report = execute(filters)
|
||||
row = report[1][0]
|
||||
|
||||
expected_data = [8000, 8000, "No Remarks"] # Data in Company Currency
|
||||
|
||||
self.assertEqual(expected_data, [row.invoice_grand_total, row.invoiced, row.remarks])
|
||||
|
||||
def test_accounts_receivable_with_partial_payment(self):
|
||||
filters = {
|
||||
"company": self.company,
|
||||
|
||||
@@ -273,6 +273,7 @@ def get_asset_value_adjustment_map_by_category(filters):
|
||||
AND a.company = %(company)s
|
||||
AND a.purchase_date <= %(to_date)s
|
||||
AND gle.account = aca.fixed_asset_account
|
||||
AND gle.is_opening = 'No'
|
||||
GROUP BY a.asset_category
|
||||
""",
|
||||
{"from_date": filters.from_date, "to_date": filters.to_date, "company": filters.company},
|
||||
@@ -543,6 +544,7 @@ def get_asset_value_adjustment_map(filters):
|
||||
AND a.company = %(company)s
|
||||
AND a.purchase_date <= %(to_date)s
|
||||
AND gle.account = aca.fixed_asset_account
|
||||
AND gle.is_opening = 'No'
|
||||
GROUP BY a.name
|
||||
""",
|
||||
{"from_date": filters.from_date, "to_date": filters.to_date, "company": filters.company},
|
||||
|
||||
@@ -85,6 +85,12 @@ frappe.query_reports["Gross Profit"] = {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldname: "include_returned_invoices",
|
||||
label: __("Include Returned Invoices (Stand-alone)"),
|
||||
fieldtype: "Check",
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
tree: true,
|
||||
name_field: "parent",
|
||||
|
||||
@@ -231,6 +231,15 @@ def get_data_when_not_grouped_by_invoice(gross_profit_data, filters, group_wise_
|
||||
|
||||
group_columns = group_wise_columns.get(scrub(filters.group_by))
|
||||
|
||||
# removing customer_name from group columns
|
||||
customer_master_name = frappe.db.get_single_value("Selling Settings", "cust_master_name")
|
||||
supplier_master_name = frappe.db.get_single_value("Buying Settings", "supp_master_name")
|
||||
|
||||
if "customer_name" in group_columns and (
|
||||
supplier_master_name == "Supplier Name" and customer_master_name == "Customer Name"
|
||||
):
|
||||
group_columns = [col for col in group_columns if col != "customer_name"]
|
||||
|
||||
for src in gross_profit_data.grouped_data:
|
||||
total_base_amount += src.base_amount or 0.00
|
||||
total_buying_amount += src.buying_amount or 0.00
|
||||
@@ -850,7 +859,10 @@ class GrossProfitGenerator:
|
||||
if self.filters.to_date:
|
||||
conditions += " and posting_date <= %(to_date)s"
|
||||
|
||||
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
|
||||
if self.filters.include_returned_invoices:
|
||||
conditions += " and (is_return = 0 or (is_return=1 and return_against is null))"
|
||||
else:
|
||||
conditions += " and is_return = 0"
|
||||
|
||||
if self.filters.item_group:
|
||||
conditions += f" and {get_item_group_condition(self.filters.item_group)}"
|
||||
|
||||
@@ -442,7 +442,11 @@ class TestGrossProfit(FrappeTestCase):
|
||||
sinv = sinv.save().submit()
|
||||
|
||||
filters = frappe._dict(
|
||||
company=self.company, from_date=nowdate(), to_date=nowdate(), group_by="Invoice"
|
||||
company=self.company,
|
||||
from_date=nowdate(),
|
||||
to_date=nowdate(),
|
||||
group_by="Invoice",
|
||||
include_returned_invoices=1,
|
||||
)
|
||||
|
||||
columns, data = execute(filters=filters)
|
||||
|
||||
@@ -5,7 +5,9 @@ from erpnext.stock.doctype.item.test_item import create_item
|
||||
|
||||
|
||||
class AccountsTestMixin:
|
||||
def create_customer(self, customer_name="_Test Customer", currency=None):
|
||||
def create_customer(
|
||||
self, customer_name="_Test Customer", currency=None, default_account=None, company=None
|
||||
):
|
||||
if not frappe.db.exists("Customer", customer_name):
|
||||
customer = frappe.new_doc("Customer")
|
||||
customer.customer_name = customer_name
|
||||
@@ -13,9 +15,28 @@ class AccountsTestMixin:
|
||||
|
||||
if currency:
|
||||
customer.default_currency = currency
|
||||
if company and default_account:
|
||||
customer.append(
|
||||
"accounts",
|
||||
{
|
||||
"company": company,
|
||||
"account": default_account,
|
||||
},
|
||||
)
|
||||
customer.save()
|
||||
self.customer = customer.name
|
||||
else:
|
||||
if company and default_account:
|
||||
customer = frappe.get_doc("Customer", customer_name)
|
||||
customer.accounts = []
|
||||
customer.append(
|
||||
"accounts",
|
||||
{
|
||||
"company": company,
|
||||
"account": default_account,
|
||||
},
|
||||
)
|
||||
customer.save()
|
||||
self.customer = customer_name
|
||||
|
||||
def create_supplier(self, supplier_name="_Test Supplier", currency=None):
|
||||
|
||||
@@ -518,6 +518,7 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.docstatus > 0",
|
||||
"fieldname": "additional_asset_cost",
|
||||
"fieldtype": "Currency",
|
||||
@@ -596,7 +597,7 @@
|
||||
"link_fieldname": "target_asset"
|
||||
}
|
||||
],
|
||||
"modified": "2025-05-20 00:44:06.229177",
|
||||
"modified": "2025-10-23 22:43:33.634452",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Assets",
|
||||
"name": "Asset",
|
||||
@@ -641,4 +642,4 @@
|
||||
"states": [],
|
||||
"title_field": "asset_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ class Asset(AccountsController):
|
||||
default_finance_book: DF.Link | None
|
||||
department: DF.Link | None
|
||||
depr_entry_posting_status: DF.Literal["", "Successful", "Failed"]
|
||||
depreciation_completed: DF.Check
|
||||
depreciation_method: DF.Literal["", "Straight Line", "Double Declining Balance", "Manual"]
|
||||
disposal_date: DF.Date | None
|
||||
finance_books: DF.Table[AssetFinanceBook]
|
||||
@@ -152,7 +153,9 @@ class Asset(AccountsController):
|
||||
)
|
||||
self.validate_expected_value_after_useful_life()
|
||||
self.set_total_booked_depreciations()
|
||||
self.total_asset_cost = self.gross_purchase_amount
|
||||
|
||||
def before_save(self):
|
||||
self.total_asset_cost = self.gross_purchase_amount + self.additional_asset_cost
|
||||
self.status = self.get_status()
|
||||
|
||||
def on_submit(self):
|
||||
@@ -936,7 +939,7 @@ def make_post_gl_entry():
|
||||
assets = frappe.db.sql_list(
|
||||
""" select name from `tabAsset`
|
||||
where asset_category = %s and ifnull(booked_fixed_asset, 0) = 0
|
||||
and available_for_use_date = %s""",
|
||||
and available_for_use_date = %s and docstatus = 1""",
|
||||
(asset_category.name, nowdate()),
|
||||
)
|
||||
|
||||
|
||||
@@ -644,9 +644,8 @@ class PurchaseOrder(BuyingController):
|
||||
if not self.is_against_so():
|
||||
return
|
||||
for item in removed_items:
|
||||
prev_ordered_qty = (
|
||||
prev_ordered_qty = flt(
|
||||
frappe.get_cached_value("Sales Order Item", item.get("sales_order_item"), "ordered_qty")
|
||||
or 0.0
|
||||
)
|
||||
|
||||
frappe.db.set_value(
|
||||
|
||||
@@ -2026,7 +2026,7 @@ class AccountsController(TransactionBase):
|
||||
discount_amount * self.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
dr_or_cr + "_in_account_currency": flt(
|
||||
dr_or_cr + "_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
@@ -2047,7 +2047,7 @@ class AccountsController(TransactionBase):
|
||||
discount_amount * self.get("conversion_rate"),
|
||||
item.precision("discount_amount"),
|
||||
),
|
||||
rev_dr_cr + "_in_account_currency": flt(
|
||||
rev_dr_cr + "_in_transaction_currency": flt(
|
||||
discount_amount, item.precision("discount_amount")
|
||||
),
|
||||
"cost_center": item.cost_center,
|
||||
@@ -3073,9 +3073,7 @@ def set_balance_in_account_currency(
|
||||
_("Account: {0} with currency: {1} can not be selected").format(gl_dict.account, account_currency)
|
||||
)
|
||||
|
||||
gl_dict["account_currency"] = (
|
||||
company_currency if account_currency == company_currency else account_currency
|
||||
)
|
||||
gl_dict["account_currency"] = account_currency
|
||||
|
||||
# set debit/credit in account currency if not provided
|
||||
if flt(gl_dict.debit) and not flt(gl_dict.debit_in_account_currency):
|
||||
@@ -3663,8 +3661,15 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
child_doctype = "Sales Order Item" if parent_doctype == "Sales Order" else "Purchase Order Item"
|
||||
return set_order_defaults(parent_doctype, parent_doctype_name, child_doctype, child_docname, item_row)
|
||||
|
||||
def is_allowed_zero_qty():
|
||||
if parent_doctype == "Sales Order":
|
||||
return frappe.db.get_single_value("Selling Settings", "allow_zero_qty_in_sales_order") or False
|
||||
elif parent_doctype == "Purchase Order":
|
||||
return frappe.db.get_single_value("Buying Settings", "allow_zero_qty_in_purchase_order") or False
|
||||
return False
|
||||
|
||||
def validate_quantity(child_item, new_data):
|
||||
if not flt(new_data.get("qty")):
|
||||
if not flt(new_data.get("qty")) and not is_allowed_zero_qty():
|
||||
frappe.throw(
|
||||
_("Row #{0}: Quantity for Item {1} cannot be zero.").format(
|
||||
new_data.get("idx"), frappe.bold(new_data.get("item_code"))
|
||||
@@ -3800,6 +3805,11 @@ def update_child_qty_rate(parent_doctype, trans_items, parent_doctype_name, chil
|
||||
conv_fac_precision = child_item.precision("conversion_factor") or 2
|
||||
qty_precision = child_item.precision("qty") or 2
|
||||
|
||||
prev_rate, new_rate = flt(child_item.get("rate")), flt(d.get("rate"))
|
||||
rate_unchanged = prev_rate == new_rate
|
||||
if not rate_unchanged and not child_item.get("qty") and is_allowed_zero_qty():
|
||||
frappe.throw(_("Rate of '{}' items cannot be changed").format(frappe.bold(_("Unit Price"))))
|
||||
|
||||
# Amount cannot be lesser than billed amount, except for negative amounts
|
||||
row_rate = flt(d.get("rate"), rate_precision)
|
||||
amount_below_billed_amt = flt(child_item.billed_amt, rate_precision) > flt(
|
||||
|
||||
@@ -878,6 +878,7 @@ class BuyingController(SubcontractingController):
|
||||
"asset_category": item_data.get("asset_category"),
|
||||
"location": row.asset_location,
|
||||
"company": self.company,
|
||||
"status": "Draft",
|
||||
"supplier": self.supplier,
|
||||
"purchase_date": self.posting_date,
|
||||
"calculate_depreciation": 0,
|
||||
|
||||
@@ -480,6 +480,13 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None, return_agai
|
||||
target_doc.subcontracting_order_item = source_doc.subcontracting_order_item
|
||||
target_doc.rejected_warehouse = source_doc.rejected_warehouse
|
||||
target_doc.subcontracting_receipt_item = source_doc.name
|
||||
if return_against_rejected_qty:
|
||||
target_doc.qty = -1 * flt(source_doc.rejected_qty - (returned_qty_map.get("qty") or 0))
|
||||
target_doc.rejected_qty = 0.0
|
||||
target_doc.rejected_warehouse = ""
|
||||
target_doc.warehouse = source_doc.rejected_warehouse
|
||||
target_doc.received_qty = target_doc.qty
|
||||
target_doc.return_qty_from_rejected_warehouse = 1
|
||||
else:
|
||||
target_doc.purchase_order = source_doc.purchase_order
|
||||
target_doc.purchase_order_item = source_doc.purchase_order_item
|
||||
|
||||
@@ -253,6 +253,14 @@ class StockController(AccountsController):
|
||||
"do_not_submit": True if not via_landed_cost_voucher else False,
|
||||
}
|
||||
|
||||
if self.is_internal_transfer() and row.get("from_warehouse") and not self.is_return:
|
||||
self.update_bundle_details(bundle_details, table_name, row)
|
||||
bundle_details["type_of_transaction"] = "Outward"
|
||||
bundle_details["warehouse"] = row.get("from_warehouse")
|
||||
bundle_details["qty"] = row.get("stock_qty") or row.get("qty")
|
||||
self.create_serial_batch_bundle(bundle_details, row)
|
||||
continue
|
||||
|
||||
if row.get("qty") or row.get("consumed_qty") or row.get("stock_qty"):
|
||||
self.update_bundle_details(bundle_details, table_name, row, parent_details=parent_details)
|
||||
self.create_serial_batch_bundle(bundle_details, row)
|
||||
@@ -1544,7 +1552,9 @@ def get_stock_ledger_preview(doc, filters):
|
||||
|
||||
if doc.get("update_stock") or doc.doctype in ("Purchase Receipt", "Delivery Note"):
|
||||
doc.docstatus = 1
|
||||
doc.make_bundle_using_old_serial_batch_fields()
|
||||
doc.update_stock_ledger()
|
||||
|
||||
columns = get_sl_columns(filters)
|
||||
sl_entries = get_sl_entries_for_preview(doc.doctype, doc.name, fields)
|
||||
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"oldfieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_bulk_edit": 1,
|
||||
"fieldname": "items",
|
||||
"fieldtype": "Table",
|
||||
"label": "Items",
|
||||
@@ -590,6 +591,7 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval:doc.track_semi_finished_goods === 0",
|
||||
"fieldname": "fg_based_operating_cost",
|
||||
"fieldtype": "Check",
|
||||
"label": "Finished Goods based Operating Cost"
|
||||
@@ -638,7 +640,7 @@
|
||||
"image_field": "image",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-06-16 16:13:22.497695",
|
||||
"modified": "2025-10-29 17:43:12.966753",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Manufacturing",
|
||||
"name": "BOM",
|
||||
|
||||
@@ -158,6 +158,7 @@ class BOM(WebsiteGenerator):
|
||||
show_operations: DF.Check
|
||||
thumbnail: DF.Data | None
|
||||
total_cost: DF.Currency
|
||||
track_semi_finished_goods: DF.Check
|
||||
transfer_material_against: DF.Literal["", "Work Order", "Job Card"]
|
||||
uom: DF.Link | None
|
||||
web_long_description: DF.TextEditor | None
|
||||
|
||||
@@ -408,7 +408,7 @@ frappe.ui.form.on("Job Card", {
|
||||
function updateStopwatch(increment) {
|
||||
var hours = Math.floor(increment / 3600);
|
||||
var minutes = Math.floor((increment - hours * 3600) / 60);
|
||||
var seconds = increment - hours * 3600 - minutes * 60;
|
||||
var seconds = Math.floor(increment - hours * 3600 - minutes * 60);
|
||||
|
||||
$(section)
|
||||
.find(".hours")
|
||||
@@ -431,7 +431,7 @@ frappe.ui.form.on("Job Card", {
|
||||
frm.dashboard.refresh();
|
||||
const timer = `
|
||||
<div class="stopwatch" style="font-weight:bold;margin:0px 13px 0px 2px;
|
||||
color:#545454;font-size:18px;display:inline-block;vertical-align:text-bottom;>
|
||||
color:#545454;font-size:18px;display:inline-block;vertical-align:text-bottom;">
|
||||
<span class="hours">00</span>
|
||||
<span class="colon">:</span>
|
||||
<span class="minutes">00</span>
|
||||
@@ -441,20 +441,34 @@ frappe.ui.form.on("Job Card", {
|
||||
|
||||
var section = frm.toolbar.page.add_inner_message(timer);
|
||||
|
||||
let currentIncrement = frm.doc.current_time || 0;
|
||||
let currentIncrement = frm.events.get_current_time(frm);
|
||||
if (frm.doc.started_time || frm.doc.current_time) {
|
||||
if (frm.doc.status == "On Hold") {
|
||||
updateStopwatch(currentIncrement);
|
||||
} else {
|
||||
currentIncrement += moment(frappe.datetime.now_datetime()).diff(
|
||||
moment(frm.doc.started_time),
|
||||
"seconds"
|
||||
);
|
||||
initialiseTimer();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get_current_time(frm) {
|
||||
let current_time = 0;
|
||||
|
||||
frm.doc.time_logs.forEach((d) => {
|
||||
if (d.to_time) {
|
||||
if (d.time_in_mins) {
|
||||
current_time += flt(d.time_in_mins, 2) * 60;
|
||||
} else {
|
||||
current_time += get_seconds_diff(d.to_time, d.from_time);
|
||||
}
|
||||
} else {
|
||||
current_time += get_seconds_diff(frappe.datetime.now_datetime(), d.from_time);
|
||||
}
|
||||
});
|
||||
|
||||
return current_time;
|
||||
},
|
||||
|
||||
hide_timer: function (frm) {
|
||||
frm.toolbar.page.inner_toolbar.find(".stopwatch").remove();
|
||||
},
|
||||
@@ -519,3 +533,7 @@ frappe.ui.form.on("Job Card Time Log", {
|
||||
frm.set_value("started_time", "");
|
||||
},
|
||||
});
|
||||
|
||||
function get_seconds_diff(d1, d2) {
|
||||
return moment(d1).diff(d2, "seconds");
|
||||
}
|
||||
|
||||
@@ -724,6 +724,9 @@ class ProductionPlan(Document):
|
||||
if not wo_list:
|
||||
frappe.msgprint(_("No Work Orders were created"))
|
||||
|
||||
if not po_list:
|
||||
frappe.msgprint(_("No Purchase Orders were created"))
|
||||
|
||||
def make_work_order_for_finished_goods(self, wo_list, default_warehouses):
|
||||
items_data = self.get_production_items()
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ def get_exploded_items(bom, data, indent=0, qty=1):
|
||||
)
|
||||
|
||||
for item in exploded_items:
|
||||
print(item.bom_no, indent)
|
||||
item["indent"] = indent
|
||||
data.append(
|
||||
{
|
||||
|
||||
@@ -422,3 +422,5 @@ execute:frappe.db.set_single_value("Accounts Settings", "fetch_valuation_rate_fo
|
||||
erpnext.patches.v15_0.add_company_payment_gateway_account
|
||||
erpnext.patches.v15_0.update_uae_zero_rated_fetch
|
||||
erpnext.patches.v15_0.update_fieldname_in_accounting_dimension_filter
|
||||
erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
erpnext.patches.v15_0.toggle_legacy_controller_for_period_closing
|
||||
|
||||
13
erpnext/patches/v15_0/set_asset_status_if_not_already_set.py
Normal file
13
erpnext/patches/v15_0/set_asset_status_if_not_already_set.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import frappe
|
||||
from frappe.query_builder import DocType
|
||||
|
||||
|
||||
def execute():
|
||||
Asset = DocType("Asset")
|
||||
|
||||
query = (
|
||||
frappe.qb.update(Asset)
|
||||
.set(Asset.status, "Draft")
|
||||
.where((Asset.docstatus == 0) & ((Asset.status.isnull()) | (Asset.status == "")))
|
||||
)
|
||||
query.run()
|
||||
@@ -0,0 +1,9 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
"""
|
||||
Description:
|
||||
Enable Legacy controller for Period Closing Voucher
|
||||
"""
|
||||
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 1)
|
||||
@@ -245,6 +245,7 @@
|
||||
"fieldname": "act_start_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual Start Date (via Timesheet)",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "act_start_date",
|
||||
"oldfieldtype": "Date",
|
||||
"read_only": 1
|
||||
@@ -253,6 +254,7 @@
|
||||
"fieldname": "actual_time",
|
||||
"fieldtype": "Float",
|
||||
"label": "Actual Time in Hours (via Timesheet)",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -263,6 +265,7 @@
|
||||
"fieldname": "act_end_date",
|
||||
"fieldtype": "Date",
|
||||
"label": "Actual End Date (via Timesheet)",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "act_end_date",
|
||||
"oldfieldtype": "Date",
|
||||
"read_only": 1
|
||||
@@ -277,6 +280,7 @@
|
||||
"fieldname": "total_costing_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Costing Amount (via Timesheet)",
|
||||
"no_copy": 1,
|
||||
"oldfieldname": "actual_budget",
|
||||
"oldfieldtype": "Currency",
|
||||
"options": "Company:company:default_currency",
|
||||
@@ -290,6 +294,7 @@
|
||||
"fieldname": "total_billing_amount",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Total Billable Amount (via Timesheet)",
|
||||
"no_copy": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
@@ -399,7 +404,7 @@
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"max_attachments": 5,
|
||||
"modified": "2024-01-08 16:00:41.296203",
|
||||
"modified": "2025-10-16 08:39:12.214577",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Projects",
|
||||
"name": "Task",
|
||||
@@ -429,4 +434,4 @@
|
||||
"timeline_field": "project",
|
||||
"title_field": "subject",
|
||||
"track_seen": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
|
||||
frappe.query_reports["Delayed Tasks Summary"] = {
|
||||
filters: [
|
||||
{
|
||||
fieldname: "project",
|
||||
label: __("Project"),
|
||||
fieldtype: "Link",
|
||||
options: "Project",
|
||||
},
|
||||
{
|
||||
fieldname: "from_date",
|
||||
label: __("From Date"),
|
||||
|
||||
@@ -23,6 +23,7 @@ def get_data(filters):
|
||||
fields=[
|
||||
"name",
|
||||
"subject",
|
||||
"project",
|
||||
"exp_start_date",
|
||||
"exp_end_date",
|
||||
"status",
|
||||
@@ -56,7 +57,7 @@ def get_data(filters):
|
||||
|
||||
def get_conditions(filters):
|
||||
conditions = frappe._dict()
|
||||
keys = ["priority", "status"]
|
||||
keys = ["priority", "status", "project"]
|
||||
for key in keys:
|
||||
if filters.get(key):
|
||||
conditions[key] = filters.get(key)
|
||||
@@ -89,6 +90,13 @@ def get_columns():
|
||||
columns = [
|
||||
{"fieldname": "name", "fieldtype": "Link", "label": _("Task"), "options": "Task", "width": 150},
|
||||
{"fieldname": "subject", "fieldtype": "Data", "label": _("Subject"), "width": 200},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": _("Project"),
|
||||
"options": "Project",
|
||||
"width": 150,
|
||||
},
|
||||
{"fieldname": "status", "fieldtype": "Data", "label": _("Status"), "width": 100},
|
||||
{"fieldname": "priority", "fieldtype": "Data", "label": _("Priority"), "width": 80},
|
||||
{"fieldname": "progress", "fieldtype": "Data", "label": _("Progress (%)"), "width": 120},
|
||||
|
||||
@@ -237,10 +237,10 @@ erpnext.accounts.pos = {
|
||||
frappe.ui.form.on(doctype, {
|
||||
mode_of_payment: function(frm, cdt, cdn) {
|
||||
var d = locals[cdt][cdn];
|
||||
get_payment_mode_account(frm, d.mode_of_payment, function(account){
|
||||
frappe.model.set_value(cdt, cdn, 'account', account)
|
||||
})
|
||||
}
|
||||
erpnext.accounts.pos.get_payment_mode_account(frm, d.mode_of_payment, function (account) {
|
||||
frappe.model.set_value(cdt, cdn, "account", account);
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -394,6 +394,10 @@ erpnext.PointOfSale.Payment = class {
|
||||
const payments = doc.payments;
|
||||
const currency = doc.currency;
|
||||
|
||||
if (!this.$payment_modes.is(":visible")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$payment_modes.html(
|
||||
`${payments
|
||||
.map((p, i) => {
|
||||
@@ -612,6 +616,10 @@ erpnext.PointOfSale.Payment = class {
|
||||
const currency = doc.currency;
|
||||
const label = __("Change Amount");
|
||||
|
||||
if (!this.$totals.is(":visible")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$totals.html(
|
||||
`<div class="col">
|
||||
<div class="total-label">${__("Grand Total")}</div>
|
||||
|
||||
@@ -181,11 +181,35 @@ class Company(NestedSet):
|
||||
["Stock Received But Not Billed Account", "stock_received_but_not_billed"],
|
||||
["Stock Adjustment Account", "stock_adjustment_account"],
|
||||
["Expense Included In Valuation Account", "expenses_included_in_valuation"],
|
||||
["Write Off Account", "write_off_account"],
|
||||
["Default Payment Discount Account", "default_discount_account"],
|
||||
["Unrealized Profit / Loss Account", "unrealized_profit_loss_account"],
|
||||
["Exchange Gain / Loss Account", "exchange_gain_loss_account"],
|
||||
["Unrealized Exchange Gain / Loss Account", "unrealized_exchange_gain_loss_account"],
|
||||
["Round Off Account", "round_off_account"],
|
||||
["Default Deferred Revenue Account", "default_deferred_revenue_account"],
|
||||
["Default Deferred Expense Account", "default_deferred_expense_account"],
|
||||
["Accumulated Depreciation Account", "accumulated_depreciation_account"],
|
||||
["Depreciation Expense Account", "depreciation_expense_account"],
|
||||
["Gain/Loss Account on Asset Disposal", "disposal_account"],
|
||||
]
|
||||
|
||||
for account in accounts:
|
||||
if self.get(account[1]):
|
||||
for_company = frappe.db.get_value("Account", self.get(account[1]), "company")
|
||||
for_company, is_group, disabled = frappe.db.get_value(
|
||||
"Account", self.get(account[1]), ["company", "is_group", "disabled"]
|
||||
)
|
||||
|
||||
if disabled:
|
||||
frappe.throw(_("Account {0} is disabled.").format(frappe.bold(self.get(account[1]))))
|
||||
|
||||
if is_group:
|
||||
frappe.throw(
|
||||
_("{0}: {1} is a group account.").format(
|
||||
frappe.bold(account[0]), frappe.bold(self.get(account[1]))
|
||||
)
|
||||
)
|
||||
|
||||
if for_company != self.name:
|
||||
frappe.throw(
|
||||
_("Account {0} does not belong to company: {1}").format(
|
||||
@@ -763,13 +787,11 @@ def update_company_current_month_sales(company):
|
||||
|
||||
def update_company_monthly_sales(company):
|
||||
"""Cache past year monthly sales of every company based on sales invoices"""
|
||||
import json
|
||||
|
||||
from frappe.utils.goal import get_monthly_results
|
||||
|
||||
filter_str = f"company = {frappe.db.escape(company)} and status != 'Draft' and docstatus=1"
|
||||
filter_dict = {"company": company, "status": ["!=", "Draft"], "docstatus": 1}
|
||||
month_to_value_dict = get_monthly_results(
|
||||
"Sales Invoice", "base_grand_total", "posting_date", filter_str, "sum"
|
||||
"Sales Invoice", "base_grand_total", "posting_date", filter_dict, "sum"
|
||||
)
|
||||
|
||||
frappe.db.set_value("Company", company, "sales_monthly_history", json.dumps(month_to_value_dict))
|
||||
|
||||
@@ -24,6 +24,7 @@ frappe.ui.form.on("Driver", {
|
||||
|
||||
transporter: function (frm, cdt, cdn) {
|
||||
// this assumes that supplier's address has same title as supplier's name
|
||||
if (!frm.doc.transporter) return;
|
||||
frappe.db
|
||||
.get_doc("Address", null, { address_title: frm.doc.transporter })
|
||||
.then((r) => {
|
||||
|
||||
@@ -814,7 +814,8 @@
|
||||
"depends_on": "eval:doc.salary_mode == 'Bank'",
|
||||
"fieldname": "iban",
|
||||
"fieldtype": "Data",
|
||||
"label": "IBAN"
|
||||
"label": "IBAN",
|
||||
"options": "IBAN"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-user",
|
||||
@@ -822,7 +823,7 @@
|
||||
"image_field": "image",
|
||||
"is_tree": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-04 08:29:34.347269",
|
||||
"modified": "2025-08-29 11:52:12.819878",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Setup",
|
||||
"name": "Employee",
|
||||
@@ -864,6 +865,7 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"search_fields": "employee_name",
|
||||
"show_name_in_global_search": 1,
|
||||
"sort_field": "modified",
|
||||
@@ -871,4 +873,4 @@
|
||||
"states": [],
|
||||
"title_field": "employee_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ from frappe.utils import cint, flt
|
||||
from erpnext.accounts.party import get_due_date
|
||||
from erpnext.controllers.accounts_controller import get_taxes_and_charges, merge_taxes
|
||||
from erpnext.controllers.selling_controller import SellingController
|
||||
from erpnext.stock.stock_ledger import validate_reserved_stock
|
||||
|
||||
form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
|
||||
|
||||
@@ -469,6 +470,10 @@ class DeliveryNote(SellingController):
|
||||
self.make_bundle_using_old_serial_batch_fields(table_name)
|
||||
|
||||
self.validate_standalone_serial_nos_customer()
|
||||
|
||||
if not self.is_return:
|
||||
self.validate_reserved_stock()
|
||||
|
||||
self.update_stock_reservation_entries()
|
||||
|
||||
# Updating stock ledger should always be called after updating prevdoc status,
|
||||
@@ -506,6 +511,66 @@ class DeliveryNote(SellingController):
|
||||
|
||||
self.delete_auto_created_batches()
|
||||
|
||||
def validate_reserved_stock(self):
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_against_so_for_dn,
|
||||
)
|
||||
|
||||
if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
|
||||
return
|
||||
|
||||
# fetch reserved stock data from bin
|
||||
reserved_stocks = self.get_reserved_stock_details()
|
||||
|
||||
for row in self.items:
|
||||
if reserved_stocks.get((row.item_code, row.warehouse)) > 0:
|
||||
args = frappe._dict(
|
||||
{
|
||||
"item_code": row.item_code,
|
||||
"warehouse": row.warehouse,
|
||||
"batch_nos": [row.batch_no] if row.batch_no else [],
|
||||
"serial_nos": row.serial_no.split("\n") if row.serial_no else [],
|
||||
"serial_and_batch_bundle": row.serial_and_batch_bundle,
|
||||
"voucher_type": self.doctype,
|
||||
"voucher_no": self.name,
|
||||
"voucher_detail_no": row.name,
|
||||
"actual_qty": row.qty * -1,
|
||||
"posting_date": self.posting_date,
|
||||
"posting_time": self.posting_time,
|
||||
}
|
||||
)
|
||||
|
||||
if row.against_sales_order and row.so_detail:
|
||||
args.ignore_voucher_nos = get_sre_against_so_for_dn(
|
||||
row.against_sales_order, row.so_detail
|
||||
)
|
||||
|
||||
validate_reserved_stock(args)
|
||||
|
||||
def get_reserved_stock_details(self):
|
||||
"""
|
||||
Create dict from bin based on item and warehouse:
|
||||
{(item_code, warehouse): reserved_stock}
|
||||
|
||||
Use: to quickly retrieve/check reserved stock value instead of looping n times
|
||||
"""
|
||||
item_codes = set()
|
||||
warehouses = set()
|
||||
|
||||
for row in self.items:
|
||||
item_codes.add(row.item_code)
|
||||
warehouses.add(row.warehouse)
|
||||
|
||||
bins = frappe.db.get_all(
|
||||
"Bin",
|
||||
{"item_code": ["in", item_codes], "warehouse": ["in", warehouses]},
|
||||
["item_code", "warehouse", "reserved_stock"],
|
||||
)
|
||||
|
||||
reserved_stock_lookup = {(b.item_code, b.warehouse): flt(b.reserved_stock) for b in bins}
|
||||
|
||||
return reserved_stock_lookup
|
||||
|
||||
def validate_against_stock_reservation_entries(self):
|
||||
"""Validates if Stock Reservation Entries are available for the Sales Order Item reference."""
|
||||
|
||||
@@ -1310,6 +1375,9 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
if source.get("use_serial_batch_fields"):
|
||||
target.set("use_serial_batch_fields", 1)
|
||||
|
||||
if (source.get("serial_no") or source.get("batch_no")) and not source.get("serial_and_batch_bundle"):
|
||||
target.set("use_serial_batch_fields", 1)
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
doctype,
|
||||
source_name,
|
||||
|
||||
@@ -39,7 +39,7 @@ from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import
|
||||
create_stock_reconciliation,
|
||||
set_valuation_method,
|
||||
)
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import get_warehouse
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse, get_warehouse
|
||||
from erpnext.stock.stock_ledger import get_previous_sle
|
||||
|
||||
|
||||
@@ -2719,6 +2719,75 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
serial_batch_map[row.item_code].batch_no_valuation[entry.batch_no],
|
||||
)
|
||||
|
||||
@change_settings("Stock Settings", {"allow_negative_stock": 0, "enable_stock_reservation": 1})
|
||||
def test_partial_delivery_note_against_reserved_stock(self):
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_stock_reservation_entries_for_voucher,
|
||||
)
|
||||
|
||||
# create batch item
|
||||
batch_item = make_item(
|
||||
"_Test Batch Item For DN Reserve Check",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "TBDNR.#####",
|
||||
},
|
||||
)
|
||||
serial_item = make_item(
|
||||
"_Test Serial Item For DN Reserve Check",
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_serial_no": 1,
|
||||
"serial_no_series": "TSNDNR.#####",
|
||||
},
|
||||
)
|
||||
|
||||
company = "_Test Company"
|
||||
|
||||
warehouse = create_warehouse("Test Partial DN Reserved Stock", company=company)
|
||||
customer = "_Test Customer"
|
||||
|
||||
items = [batch_item.name, serial_item.name]
|
||||
|
||||
for idx, item in enumerate(items):
|
||||
# make inward entry for batch item
|
||||
se = make_stock_entry(item_code=item, purpose="Material Receipt", qty=10, to_warehouse=warehouse)
|
||||
sabb = se.items[0].serial_and_batch_bundle
|
||||
|
||||
batch_no = get_batch_from_bundle(sabb) if not idx else None
|
||||
serial_nos = get_serial_nos_from_bundle(sabb) if idx else None
|
||||
|
||||
# make sales order and reserve the quantites against the so
|
||||
so = make_sales_order(item_code=item, qty=10, rate=100, customer=customer, warehouse=warehouse)
|
||||
so.submit()
|
||||
so.create_stock_reservation_entries()
|
||||
so.reload()
|
||||
|
||||
# create a delivery note with partial quantity from resreved quantity
|
||||
dn = create_dn_against_so(so=so.name, delivered_qty=5, do_not_submit=True)
|
||||
dn.items[0].use_serial_batch_fields = 1
|
||||
if batch_no:
|
||||
dn.items[0].batch_no = batch_no
|
||||
else:
|
||||
dn.items[0].serial_no = "\n".join(serial_nos[:5])
|
||||
|
||||
dn.save()
|
||||
dn.submit()
|
||||
|
||||
against_sales_order = dn.items[0].against_sales_order
|
||||
so_detail = dn.items[0].so_detail
|
||||
|
||||
sre_details = get_stock_reservation_entries_for_voucher(
|
||||
so.doctype, against_sales_order, so_detail, ["reserved_qty", "delivered_qty", "status"]
|
||||
)
|
||||
|
||||
# check partially delivered reserved stock
|
||||
self.assertEqual(sre_details[0].status, "Partially Delivered")
|
||||
self.assertEqual(sre_details[0].reserved_qty, so.items[0].qty)
|
||||
self.assertEqual(sre_details[0].delivered_qty, dn.items[0].qty)
|
||||
|
||||
|
||||
def create_delivery_note(**args):
|
||||
dn = frappe.new_doc("Delivery Note")
|
||||
|
||||
@@ -4287,6 +4287,67 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "set_valuation_rate_for_rejected_materials", 0)
|
||||
|
||||
@change_settings(
|
||||
"Buying Settings",
|
||||
{"bill_for_rejected_quantity_in_purchase_invoice": 1, "set_valuation_rate_for_rejected_materials": 1},
|
||||
)
|
||||
def test_valuation_rate_for_rejected_materials_with_serial_no(self):
|
||||
item = make_item(
|
||||
"Test Serial Item with Rej Material Valuation",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "SNU-TSIRMV-.#####"},
|
||||
)
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
warehouse = create_warehouse(
|
||||
"_Test In-ward Warehouse",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
rej_warehouse = create_warehouse(
|
||||
"_Test Warehouse - Rejected Material",
|
||||
company="_Test Company with perpetual inventory",
|
||||
)
|
||||
|
||||
pr = make_purchase_receipt(
|
||||
item_code=item.name,
|
||||
qty=10,
|
||||
rate=100,
|
||||
company=company,
|
||||
warehouse=warehouse,
|
||||
rejected_qty=5,
|
||||
rejected_warehouse=rej_warehouse,
|
||||
)
|
||||
|
||||
stock_received_but_not_billed_account = frappe.get_value(
|
||||
"Company",
|
||||
company,
|
||||
"stock_received_but_not_billed",
|
||||
)
|
||||
|
||||
rejected_item_cost = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"warehouse": rej_warehouse,
|
||||
},
|
||||
"stock_value_difference",
|
||||
)
|
||||
|
||||
self.assertEqual(rejected_item_cost, 500)
|
||||
|
||||
srbnb_cost = frappe.db.get_value(
|
||||
"GL Entry",
|
||||
{
|
||||
"voucher_type": "Purchase Receipt",
|
||||
"voucher_no": pr.name,
|
||||
"account": stock_received_but_not_billed_account,
|
||||
},
|
||||
"credit",
|
||||
)
|
||||
|
||||
self.assertEqual(srbnb_cost, 1500)
|
||||
|
||||
def test_valuation_rate_for_rejected_materials_withoout_accepted_materials(self):
|
||||
item = make_item("Test Item with Rej Material Valuation WO Accepted", {"is_stock_item": 1})
|
||||
company = "_Test Company with perpetual inventory"
|
||||
|
||||
@@ -253,6 +253,9 @@ class SerialandBatchBundle(Document):
|
||||
}
|
||||
)
|
||||
|
||||
if self.voucher_type == "Delivery Note":
|
||||
kwargs["ignore_voucher_nos"] = self.get_sre_against_dn()
|
||||
|
||||
available_serial_nos = get_available_serial_nos(frappe._dict(kwargs))
|
||||
|
||||
serial_no_warehouse = {}
|
||||
@@ -667,8 +670,12 @@ class SerialandBatchBundle(Document):
|
||||
if batches and valuation_method == "FIFO":
|
||||
stock_queue = parse_json(prev_sle.stock_queue)
|
||||
|
||||
set_valuation_rate_for_rejected_materials = frappe.db.get_single_value(
|
||||
"Buying Settings", "set_valuation_rate_for_rejected_materials"
|
||||
)
|
||||
|
||||
for d in self.entries:
|
||||
if self.is_rejected:
|
||||
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
|
||||
rate = 0.0
|
||||
elif (d.incoming_rate == rate) and not stock_queue and d.qty and d.stock_value_difference:
|
||||
continue
|
||||
@@ -1376,6 +1383,20 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
self.set("entries", [])
|
||||
|
||||
def get_sre_against_dn(self):
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_against_so_for_dn,
|
||||
)
|
||||
|
||||
so_name, so_detail_no = frappe.db.get_value(
|
||||
"Delivery Note Item", self.voucher_detail_no, ["against_sales_order", "so_detail"]
|
||||
)
|
||||
|
||||
if so_name and so_detail_no:
|
||||
sre_names = get_sre_against_so_for_dn(so_name, so_detail_no)
|
||||
|
||||
return sre_names
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def download_blank_csv_template(content):
|
||||
|
||||
@@ -539,7 +539,7 @@ frappe.ui.form.on("Stock Entry", {
|
||||
const item = locals[cdt][cdn];
|
||||
item.transfer_qty = flt(item.qty) * flt(item.conversion_factor);
|
||||
|
||||
const args = {
|
||||
let args = {
|
||||
item_code: item.item_code,
|
||||
posting_date: frm.doc.posting_date,
|
||||
posting_time: frm.doc.posting_time,
|
||||
@@ -553,6 +553,10 @@ frappe.ui.form.on("Stock Entry", {
|
||||
allow_zero_valuation: 1,
|
||||
};
|
||||
|
||||
if (item.batch_no && frm.doc.purpose == "Material Receipt") {
|
||||
args.qty = Math.abs(args.qty) * -1;
|
||||
}
|
||||
|
||||
if (item.item_code || item.serial_no) {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.utils.get_incoming_rate",
|
||||
|
||||
@@ -241,6 +241,8 @@ class StockEntry(StockController):
|
||||
self.reset_default_field_value("from_warehouse", "items", "s_warehouse")
|
||||
self.reset_default_field_value("to_warehouse", "items", "t_warehouse")
|
||||
|
||||
self.validate_same_source_target_warehouse_during_material_transfer()
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.make_bundle_using_old_serial_batch_fields()
|
||||
@@ -796,6 +798,53 @@ class StockEntry(StockController):
|
||||
title=_("Missing Item"),
|
||||
)
|
||||
|
||||
def validate_same_source_target_warehouse_during_material_transfer(self):
|
||||
"""
|
||||
Validate Material Transfer entries where source and target warehouses are identical.
|
||||
|
||||
For Material Transfer purpose, if an item has the same source and target warehouse,
|
||||
require that at least one inventory dimension (if configured) differs between source
|
||||
and target to ensure a meaningful transfer is occurring.
|
||||
|
||||
Raises:
|
||||
frappe.ValidationError: If warehouses are same and no inventory dimensions differ
|
||||
"""
|
||||
from erpnext.stock.doctype.inventory_dimension.inventory_dimension import get_inventory_dimensions
|
||||
|
||||
inventory_dimensions = get_inventory_dimensions()
|
||||
if self.purpose == "Material Transfer":
|
||||
for item in self.items:
|
||||
if cstr(item.s_warehouse) == cstr(item.t_warehouse):
|
||||
if not inventory_dimensions:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Source and Target Warehouse cannot be the same for Material Transfer"
|
||||
).format(item.idx),
|
||||
title=_("Invalid Source and Target Warehouse"),
|
||||
)
|
||||
else:
|
||||
difference_found = False
|
||||
for dimension in inventory_dimensions:
|
||||
fieldname = (
|
||||
dimension.source_fieldname
|
||||
if dimension.source_fieldname.startswith("to_")
|
||||
else f"to_{dimension.source_fieldname}"
|
||||
)
|
||||
if (
|
||||
item.get(dimension.source_fieldname)
|
||||
and item.get(fieldname)
|
||||
and item.get(dimension.source_fieldname) != item.get(fieldname)
|
||||
):
|
||||
difference_found = True
|
||||
break
|
||||
if not difference_found:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Source, Target Warehouse and Inventory Dimensions cannot be the exact same for Material Transfer"
|
||||
).format(item.idx),
|
||||
title=_("Invalid Source and Target Warehouse"),
|
||||
)
|
||||
|
||||
def get_matched_items(self, item_code):
|
||||
for row in self.items:
|
||||
if row.item_code == item_code or row.original_item == item_code:
|
||||
|
||||
@@ -7,7 +7,10 @@ frappe.provide("erpnext.accounts.dimensions");
|
||||
frappe.ui.form.on("Stock Reconciliation", {
|
||||
setup(frm) {
|
||||
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
|
||||
frm.barcode_scanner = new erpnext.utils.BarcodeScanner({ frm });
|
||||
frm.barcode_scanner = new erpnext.utils.BarcodeScanner({
|
||||
frm: frm,
|
||||
uom_field: "stock_uom",
|
||||
});
|
||||
},
|
||||
|
||||
onload: function (frm) {
|
||||
|
||||
@@ -738,7 +738,7 @@ def get_sre_reserved_qty_for_voucher_detail_no(
|
||||
|
||||
|
||||
def get_sre_reserved_serial_nos_details(
|
||||
item_code: str, warehouse: str, serial_nos: list | None = None
|
||||
item_code: str, warehouse: str, serial_nos: list | None = None, ignore_voucher_nos: list | None = None
|
||||
) -> dict:
|
||||
"""Returns a dict of `Serial No` reserved in Stock Reservation Entry. The dict is like {serial_no: sre_name, ...}"""
|
||||
|
||||
@@ -753,8 +753,7 @@ def get_sre_reserved_serial_nos_details(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.item_code == item_code)
|
||||
& (sre.warehouse == warehouse)
|
||||
& (sre.reserved_qty > sre.delivered_qty)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.delivered_qty < sre.reserved_qty)
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
.orderby(sb_entry.creation)
|
||||
@@ -763,10 +762,15 @@ def get_sre_reserved_serial_nos_details(
|
||||
if serial_nos:
|
||||
query = query.where(sb_entry.serial_no.isin(serial_nos))
|
||||
|
||||
if ignore_voucher_nos:
|
||||
query = query.where(sre.name.notin(ignore_voucher_nos))
|
||||
|
||||
return frappe._dict(query.run())
|
||||
|
||||
|
||||
def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos: list | None = None) -> dict:
|
||||
def get_sre_reserved_batch_nos_details(
|
||||
item_code: str, warehouse: str, batch_nos: list | None = None, ignore_voucher_nos: list | None = None
|
||||
) -> dict:
|
||||
"""Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}"""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
@@ -784,7 +788,7 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos
|
||||
& (sre.item_code == item_code)
|
||||
& (sre.warehouse == warehouse)
|
||||
& ((sre.reserved_qty - sre.delivered_qty) > 0)
|
||||
& (sre.status.notin(["Delivered", "Cancelled"]))
|
||||
& (sre.delivered_qty < sre.reserved_qty)
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
)
|
||||
.groupby(sb_entry.batch_no)
|
||||
@@ -794,6 +798,9 @@ def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos
|
||||
if batch_nos:
|
||||
query = query.where(sb_entry.batch_no.isin(batch_nos))
|
||||
|
||||
if ignore_voucher_nos:
|
||||
query = query.where(sre.name.notin(ignore_voucher_nos))
|
||||
|
||||
return frappe._dict(query.run())
|
||||
|
||||
|
||||
@@ -1175,3 +1182,24 @@ def get_stock_reservation_entries_for_voucher(
|
||||
query = query.where(sre.status.notin(["Delivered", "Cancelled"]))
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_sre_against_so_for_dn(so_name: str, so_detail_no: str) -> list[str]:
|
||||
"""Returns list of Stock Reservation Entries against Delivery Note with Sales Order Reference."""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.select(sre.name)
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.voucher_type == "Sales Order")
|
||||
& (sre.voucher_no == so_name)
|
||||
& (sre.voucher_detail_no == so_detail_no)
|
||||
)
|
||||
)
|
||||
|
||||
result = query.run(as_list=True)
|
||||
|
||||
return result[0] if result else []
|
||||
|
||||
@@ -36,17 +36,6 @@
|
||||
"show_barcode_field",
|
||||
"clean_description_html",
|
||||
"allow_internal_transfer_at_arms_length_price",
|
||||
"quality_inspection_settings_section",
|
||||
"action_if_quality_inspection_is_not_submitted",
|
||||
"column_break_23",
|
||||
"action_if_quality_inspection_is_rejected",
|
||||
"stock_reservation_tab",
|
||||
"enable_stock_reservation",
|
||||
"column_break_rx3e",
|
||||
"allow_partial_reservation",
|
||||
"auto_reserve_stock_for_sales_order_on_purchase",
|
||||
"serial_and_batch_reservation_section",
|
||||
"auto_reserve_serial_and_batch",
|
||||
"serial_and_batch_item_settings_tab",
|
||||
"section_break_7",
|
||||
"allow_existing_serial_no",
|
||||
@@ -548,8 +537,8 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-05-06 02:39:24.284587",
|
||||
"modified_by": "Administrator",
|
||||
"modified": "2025-10-17 18:32:35.829395",
|
||||
"modified_by": "hello@aerele.in",
|
||||
"module": "Stock",
|
||||
"name": "Stock Settings",
|
||||
"owner": "Administrator",
|
||||
@@ -569,8 +558,9 @@
|
||||
}
|
||||
],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,26 @@ frappe.query_reports["Incorrect Serial and Batch Bundle"] = {
|
||||
},
|
||||
|
||||
onload(report) {
|
||||
report.page.add_inner_button(__("Remove SABB Entry"), () => {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
report.page
|
||||
.add_inner_button(__("Fix SABB Entry"), () => {
|
||||
let indexes = frappe.query_report.datatable.rowmanager.getCheckedRows();
|
||||
let selected_rows = indexes.map((i) => frappe.query_report.data[i]);
|
||||
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select a row to create a Reposting Entry"));
|
||||
} else {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.incorrect_serial_and_batch_bundle.incorrect_serial_and_batch_bundle.remove_sabb_entry",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
},
|
||||
callback: function (r) {
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
if (!selected_rows.length) {
|
||||
frappe.throw(__("Please select at least one row to fix"));
|
||||
} else {
|
||||
frappe.call({
|
||||
method: "erpnext.stock.report.incorrect_serial_and_batch_bundle.incorrect_serial_and_batch_bundle.fix_sabb_entries",
|
||||
freeze: true,
|
||||
args: {
|
||||
selected_rows: selected_rows,
|
||||
},
|
||||
callback: function (r) {
|
||||
frappe.query_report.refresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
})
|
||||
.addClass("btn-primary");
|
||||
},
|
||||
};
|
||||
|
||||
@@ -13,7 +13,10 @@ def execute(filters: dict | None = None):
|
||||
every time the report is refreshed or a filter is updated.
|
||||
"""
|
||||
columns = get_columns()
|
||||
data = get_data(filters)
|
||||
unlinked_bundles = get_unlinked_serial_batch_bundles(filters) or []
|
||||
linked_cancelled_bundles = get_linked_cancelled_sabb(filters) or []
|
||||
|
||||
data = unlinked_bundles + linked_cancelled_bundles
|
||||
|
||||
return columns, data
|
||||
|
||||
@@ -50,14 +53,17 @@ def get_columns() -> list[dict]:
|
||||
"fieldtype": "Data",
|
||||
"width": 200,
|
||||
},
|
||||
{
|
||||
"label": _("Is Cancelled"),
|
||||
"fieldname": "is_cancelled",
|
||||
"fieldtype": "Check",
|
||||
"width": 200,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def get_data(filters) -> list[list]:
|
||||
"""Return data for the report.
|
||||
|
||||
The report data is a list of rows, with each row being a list of cell values.
|
||||
"""
|
||||
def get_unlinked_serial_batch_bundles(filters) -> list[list]:
|
||||
# SABB has not been linked to any SLE
|
||||
|
||||
SABB = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
SLE = frappe.qb.DocType("Stock Ledger Entry")
|
||||
@@ -77,6 +83,7 @@ def get_data(filters) -> list[list]:
|
||||
SABB.voucher_type,
|
||||
SABB.voucher_no,
|
||||
SABB.voucher_detail_no,
|
||||
SABB.is_cancelled,
|
||||
)
|
||||
.where(
|
||||
(SLE.serial_and_batch_bundle.isnull())
|
||||
@@ -94,14 +101,63 @@ def get_data(filters) -> list[list]:
|
||||
return data
|
||||
|
||||
|
||||
def get_linked_cancelled_sabb(filters):
|
||||
# SABB has cancelled but voucher is not cancelled
|
||||
|
||||
SABB = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
SLE = frappe.qb.DocType("Stock Ledger Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(SABB)
|
||||
.inner_join(SLE)
|
||||
.on(SABB.name == SLE.serial_and_batch_bundle)
|
||||
.select(
|
||||
SABB.name,
|
||||
SABB.voucher_type,
|
||||
SABB.voucher_no,
|
||||
SABB.voucher_detail_no,
|
||||
SABB.is_cancelled,
|
||||
)
|
||||
.where(
|
||||
(SLE.serial_and_batch_bundle.isnotnull())
|
||||
& (SABB.docstatus == 2)
|
||||
& (SABB.is_cancelled == 1)
|
||||
& (SLE.is_cancelled == 0)
|
||||
)
|
||||
)
|
||||
|
||||
for field in filters:
|
||||
query = query.where(SABB[field] == filters[field])
|
||||
|
||||
data = query.run(as_dict=1)
|
||||
return data
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def remove_sabb_entry(selected_rows):
|
||||
def fix_sabb_entries(selected_rows):
|
||||
if isinstance(selected_rows, str):
|
||||
selected_rows = frappe.parse_json(selected_rows)
|
||||
|
||||
for row in selected_rows:
|
||||
doc = frappe.get_doc("Serial and Batch Bundle", row.get("name"))
|
||||
doc.cancel()
|
||||
doc.delete()
|
||||
if doc.is_cancelled == 0 and not frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"serial_and_batch_bundle": doc.name, "is_cancelled": 0},
|
||||
"name",
|
||||
):
|
||||
doc.db_set({"is_cancelled": 1, "docstatus": 2})
|
||||
|
||||
frappe.msgprint(_("Selected Serial and Batch Bundle entries have been removed."))
|
||||
for row in doc.entries:
|
||||
row.db_set("docstatus", 2)
|
||||
|
||||
elif doc.is_cancelled == 1 and frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"serial_and_batch_bundle": doc.name, "is_cancelled": 0},
|
||||
"name",
|
||||
):
|
||||
doc.db_set({"is_cancelled": 0, "docstatus": 1})
|
||||
|
||||
for row in doc.entries:
|
||||
row.db_set("docstatus", 1)
|
||||
|
||||
frappe.msgprint(_("Selected Serial and Batch Bundle entries have been fixed."))
|
||||
|
||||
@@ -18,16 +18,41 @@ def execute(filters=None):
|
||||
|
||||
def get_data(filters):
|
||||
data = get_stock_ledger_entries(filters)
|
||||
serial_nos_data = prepare_serial_nos(data)
|
||||
bundles = get_bundles(data)
|
||||
serial_nos_data = prepare_serial_nos(data, bundles)
|
||||
data = get_incorrect_serial_nos(serial_nos_data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def prepare_serial_nos(data):
|
||||
def get_bundles(data):
|
||||
bundles = [d.serial_and_batch_bundle for d in data if d.serial_and_batch_bundle]
|
||||
bundle_dict = frappe._dict()
|
||||
serial_nos_data = frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
fields=["parent", "serial_no", "incoming_rate", "qty"],
|
||||
filters={"parent": ("in", bundles), "serial_no": ("is", "set")},
|
||||
)
|
||||
|
||||
for entry in serial_nos_data:
|
||||
bundle_dict.setdefault(entry.parent, []).append(entry)
|
||||
|
||||
return bundle_dict
|
||||
|
||||
|
||||
def prepare_serial_nos(data, bundles):
|
||||
serial_no_wise_data = {}
|
||||
for row in data:
|
||||
if not row.serial_nos:
|
||||
if not row.serial_nos and not row.serial_and_batch_bundle:
|
||||
continue
|
||||
|
||||
if row.serial_and_batch_bundle:
|
||||
for bundle in bundles.get(row.serial_and_batch_bundle, []):
|
||||
sle = copy.deepcopy(row)
|
||||
sle.serial_no = bundle.serial_no
|
||||
sle.qty = bundle.qty
|
||||
sle.valuation_rate = bundle.incoming_rate * (1 if sle.qty > 0 else -1)
|
||||
serial_no_wise_data.setdefault(bundle.serial_no, []).append(sle)
|
||||
continue
|
||||
|
||||
for serial_no in get_serial_nos(row.serial_nos):
|
||||
@@ -54,6 +79,9 @@ def get_incorrect_serial_nos(serial_nos_data):
|
||||
total_value.qty += total_dict.qty
|
||||
total_value.valuation_rate += total_dict.valuation_rate
|
||||
|
||||
if total_dict.qty == 0 and abs(total_dict.valuation_rate) == 0:
|
||||
continue
|
||||
|
||||
result.append(total_dict)
|
||||
result.append({})
|
||||
|
||||
@@ -81,6 +109,7 @@ def get_stock_ledger_entries(report_filters):
|
||||
"voucher_no",
|
||||
"item_code",
|
||||
"serial_no as serial_nos",
|
||||
"serial_and_batch_bundle",
|
||||
"actual_qty",
|
||||
"posting_date",
|
||||
"posting_time",
|
||||
@@ -89,7 +118,8 @@ def get_stock_ledger_entries(report_filters):
|
||||
"(stock_value_difference / actual_qty) as valuation_rate",
|
||||
]
|
||||
|
||||
filters = {"serial_no": ("is", "set"), "is_cancelled": 0}
|
||||
filters = {"is_cancelled": 0}
|
||||
or_filters = {"serial_no": ("is", "set"), "serial_and_batch_bundle": ("is", "set")}
|
||||
|
||||
if report_filters.get("item_code"):
|
||||
filters["item_code"] = report_filters.get("item_code")
|
||||
@@ -104,6 +134,7 @@ def get_stock_ledger_entries(report_filters):
|
||||
"Stock Ledger Entry",
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
or_filters=or_filters,
|
||||
order_by="posting_date asc, posting_time asc, creation asc",
|
||||
)
|
||||
|
||||
|
||||
@@ -731,13 +731,55 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
for ledger in entries:
|
||||
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
|
||||
self.available_qty[ledger.batch_no] += flt(ledger.qty)
|
||||
self.total_qty[ledger.batch_no] += flt(ledger.total_qty)
|
||||
|
||||
entries = self.get_batch_wise_total_available_qty()
|
||||
for row in entries:
|
||||
self.total_qty[row.batch_no] += flt(row.total_qty)
|
||||
|
||||
self.calculate_avg_rate_from_deprecarated_ledgers()
|
||||
self.calculate_avg_rate_for_non_batchwise_valuation()
|
||||
self.set_stock_value_difference()
|
||||
|
||||
def get_batch_wise_total_available_qty(self) -> list[dict]:
|
||||
# Get total qty of each batch no from Serial and Batch Bundle without checking time condition
|
||||
if not self.batchwise_valuation_batches:
|
||||
return []
|
||||
|
||||
parent = frappe.qb.DocType("Serial and Batch Bundle")
|
||||
child = frappe.qb.DocType("Serial and Batch Entry")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(parent)
|
||||
.inner_join(child)
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
child.batch_no,
|
||||
Sum(child.qty).as_("total_qty"),
|
||||
)
|
||||
.where(
|
||||
(parent.warehouse == self.sle.warehouse)
|
||||
& (parent.item_code == self.sle.item_code)
|
||||
& (child.batch_no.isin(self.batchwise_valuation_batches))
|
||||
& (parent.docstatus == 1)
|
||||
& (parent.is_cancelled == 0)
|
||||
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
|
||||
)
|
||||
.for_update()
|
||||
.groupby(child.batch_no)
|
||||
)
|
||||
|
||||
# Important to exclude the current voucher detail no / voucher no to calculate the correct stock value difference
|
||||
if self.sle.voucher_detail_no:
|
||||
query = query.where(parent.voucher_detail_no != self.sle.voucher_detail_no)
|
||||
elif self.sle.voucher_no:
|
||||
query = query.where(parent.voucher_no != self.sle.voucher_no)
|
||||
|
||||
query = query.where(parent.voucher_type != "Pick List")
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
def get_batch_no_ledgers(self) -> list[dict]:
|
||||
# Get batch wise stock value difference from Serial and Batch Bundle considering time condition
|
||||
if not self.batchwise_valuation_batches:
|
||||
return []
|
||||
|
||||
@@ -765,11 +807,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
.on(parent.name == child.parent)
|
||||
.select(
|
||||
child.batch_no,
|
||||
Sum(Case().when(timestamp_condition, child.stock_value_difference).else_(0)).as_(
|
||||
"incoming_rate"
|
||||
),
|
||||
Sum(Case().when(timestamp_condition, child.qty).else_(0)).as_("qty"),
|
||||
Sum(child.qty).as_("total_qty"),
|
||||
Sum(child.stock_value_difference).as_("incoming_rate"),
|
||||
Sum(child.qty).as_("qty"),
|
||||
)
|
||||
.where(
|
||||
(parent.warehouse == self.sle.warehouse)
|
||||
@@ -790,6 +829,8 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
|
||||
query = query.where(parent.voucher_no != self.sle.voucher_no)
|
||||
|
||||
query = query.where(parent.voucher_type != "Pick List")
|
||||
if timestamp_condition:
|
||||
query = query.where(timestamp_condition)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -893,10 +893,19 @@ class update_entries_after:
|
||||
sle.stock_queue = json.dumps(self.wh_data.stock_queue)
|
||||
|
||||
sle.stock_value_difference = stock_value_difference
|
||||
if sle.is_adjustment_entry and flt(sle.qty_after_transaction, self.flt_precision) == 0:
|
||||
if (
|
||||
sle.is_adjustment_entry
|
||||
and flt(sle.qty_after_transaction, self.flt_precision) == 0
|
||||
and flt(sle.stock_value, self.currency_precision) != 0
|
||||
):
|
||||
sle.stock_value_difference = (
|
||||
get_stock_value_difference(
|
||||
sle.item_code, sle.warehouse, sle.posting_date, sle.posting_time, sle.voucher_no
|
||||
sle.item_code,
|
||||
sle.warehouse,
|
||||
sle.posting_date,
|
||||
sle.posting_time,
|
||||
voucher_detail_no=sle.voucher_detail_no,
|
||||
creation=sle.creation,
|
||||
)
|
||||
* -1
|
||||
)
|
||||
@@ -1905,7 +1914,7 @@ def get_valuation_rate(
|
||||
)
|
||||
|
||||
last_valuation_rate = query.run()
|
||||
if last_valuation_rate:
|
||||
if last_valuation_rate and last_valuation_rate[0][0] is not None:
|
||||
return flt(last_valuation_rate[0][0])
|
||||
|
||||
# Get moving average rate of a specific batch number
|
||||
@@ -2157,7 +2166,7 @@ def validate_negative_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
)
|
||||
frappe.throw(message, NegativeStockError, title=_("Insufficient Stock for Batch"))
|
||||
|
||||
if args.reserved_stock:
|
||||
if args.reserved_stock and args.voucher_type != "Delivery Note":
|
||||
validate_reserved_stock(args)
|
||||
|
||||
|
||||
@@ -2227,11 +2236,10 @@ def get_future_sle_with_negative_batch_qty(sle_args):
|
||||
|
||||
def validate_reserved_stock(kwargs):
|
||||
if kwargs.serial_no:
|
||||
serial_nos = kwargs.serial_no.split("\n")
|
||||
validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos)
|
||||
validate_reserved_serial_nos(kwargs)
|
||||
|
||||
elif kwargs.batch_no:
|
||||
validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, [kwargs.batch_no])
|
||||
validate_reserved_batch_nos(kwargs)
|
||||
|
||||
elif kwargs.serial_and_batch_bundle:
|
||||
sbb_entries = frappe.db.get_all(
|
||||
@@ -2245,9 +2253,11 @@ def validate_reserved_stock(kwargs):
|
||||
)
|
||||
|
||||
if serial_nos := [entry.serial_no for entry in sbb_entries if entry.serial_no]:
|
||||
validate_reserved_serial_nos(kwargs.item_code, kwargs.warehouse, serial_nos)
|
||||
kwargs.serial_nos = serial_nos
|
||||
validate_reserved_serial_nos(kwargs)
|
||||
elif batch_nos := [entry.batch_no for entry in sbb_entries if entry.batch_no]:
|
||||
validate_reserved_batch_nos(kwargs.item_code, kwargs.warehouse, batch_nos)
|
||||
kwargs.batch_nos = batch_nos
|
||||
validate_reserved_batch_nos(kwargs)
|
||||
|
||||
# Qty based validation for non-serial-batch items OR SRE with Reservation Based On Qty.
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
@@ -2265,9 +2275,13 @@ def validate_reserved_stock(kwargs):
|
||||
frappe.throw(msg, title=_("Reserved Stock"))
|
||||
|
||||
|
||||
def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
||||
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(item_code, warehouse, serial_nos):
|
||||
if common_serial_nos := list(set(serial_nos).intersection(set(reserved_serial_nos_details.keys()))):
|
||||
def validate_reserved_serial_nos(kwargs):
|
||||
if reserved_serial_nos_details := get_sre_reserved_serial_nos_details(
|
||||
kwargs.item_code, kwargs.warehouse, kwargs.serial_nos, kwargs.ignore_voucher_nos
|
||||
):
|
||||
if common_serial_nos := list(
|
||||
set(kwargs.serial_nos).intersection(set(reserved_serial_nos_details.keys()))
|
||||
):
|
||||
msg = _(
|
||||
"Serial Nos are reserved in Stock Reservation Entries, you need to unreserve them before proceeding."
|
||||
)
|
||||
@@ -2281,22 +2295,25 @@ def validate_reserved_serial_nos(item_code, warehouse, serial_nos):
|
||||
frappe.throw(msg, title=_("Reserved Serial No."))
|
||||
|
||||
|
||||
def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
||||
if reserved_batches_map := get_sre_reserved_batch_nos_details(item_code, warehouse, batch_nos):
|
||||
def validate_reserved_batch_nos(kwargs):
|
||||
if reserved_batches_map := get_sre_reserved_batch_nos_details(
|
||||
kwargs.item_code, kwargs.warehouse, kwargs.batch_nos, kwargs.ignore_voucher_nos
|
||||
):
|
||||
available_batches = get_auto_batch_nos(
|
||||
frappe._dict(
|
||||
{
|
||||
"item_code": item_code,
|
||||
"warehouse": warehouse,
|
||||
"posting_date": nowdate(),
|
||||
"posting_time": nowtime(),
|
||||
"item_code": kwargs.item_code,
|
||||
"warehouse": kwargs.warehouse,
|
||||
"posting_date": kwargs.posting_date,
|
||||
"posting_time": kwargs.posting_time,
|
||||
"ignore_voucher_nos": kwargs.ignore_voucher_nos,
|
||||
}
|
||||
)
|
||||
)
|
||||
available_batches_map = {row.batch_no: row.qty for row in available_batches}
|
||||
precision = cint(frappe.db.get_default("float_precision")) or 2
|
||||
|
||||
for batch_no in batch_nos:
|
||||
for batch_no in kwargs.batch_nos:
|
||||
diff = flt(
|
||||
available_batches_map.get(batch_no, 0) - reserved_batches_map.get(batch_no, 0), precision
|
||||
)
|
||||
@@ -2304,7 +2321,7 @@ def validate_reserved_batch_nos(item_code, warehouse, batch_nos):
|
||||
msg = _("{0} units of {1} needed in {2} on {3} {4} to complete this transaction.").format(
|
||||
abs(diff),
|
||||
frappe.get_desk_link("Batch", batch_no),
|
||||
frappe.get_desk_link("Warehouse", warehouse),
|
||||
frappe.get_desk_link("Warehouse", kwargs.warehouse),
|
||||
nowdate(),
|
||||
nowtime(),
|
||||
)
|
||||
@@ -2353,24 +2370,32 @@ def is_internal_transfer(sle):
|
||||
return True
|
||||
|
||||
|
||||
def get_stock_value_difference(item_code, warehouse, posting_date, posting_time, voucher_no=None):
|
||||
def get_stock_value_difference(
|
||||
item_code, warehouse, posting_date, posting_time, voucher_no=None, voucher_detail_no=None, creation=None
|
||||
):
|
||||
table = frappe.qb.DocType("Stock Ledger Entry")
|
||||
posting_datetime = get_combine_datetime(posting_date, posting_time)
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.select(Sum(table.stock_value_difference).as_("value"))
|
||||
.where(
|
||||
(table.is_cancelled == 0)
|
||||
& (table.item_code == item_code)
|
||||
& (table.warehouse == warehouse)
|
||||
& (table.posting_datetime <= posting_datetime)
|
||||
)
|
||||
.where((table.is_cancelled == 0) & (table.item_code == item_code) & (table.warehouse == warehouse))
|
||||
)
|
||||
|
||||
if voucher_no:
|
||||
if voucher_detail_no:
|
||||
query = query.where(table.voucher_detail_no != voucher_detail_no)
|
||||
|
||||
elif voucher_no:
|
||||
query = query.where(table.voucher_no != voucher_no)
|
||||
|
||||
if creation:
|
||||
query = query.where(
|
||||
(table.posting_datetime < posting_datetime)
|
||||
| ((table.posting_datetime == posting_datetime) & (table.creation < creation))
|
||||
)
|
||||
else:
|
||||
query = query.where(table.posting_datetime <= posting_datetime)
|
||||
|
||||
difference_amount = query.run()
|
||||
return flt(difference_amount[0][0]) if difference_amount else 0
|
||||
|
||||
|
||||
@@ -82,10 +82,53 @@ frappe.ui.form.on("Subcontracting Receipt", {
|
||||
frm.add_custom_button(
|
||||
__("Subcontract Return"),
|
||||
() => {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
|
||||
frm: frm,
|
||||
const make_standard_return = () => {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return",
|
||||
frm: frm,
|
||||
});
|
||||
};
|
||||
|
||||
let has_rejected_items = frm.doc.items.filter((item) => {
|
||||
if (item.rejected_qty > 0) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
if (has_rejected_items && has_rejected_items.length > 0) {
|
||||
frappe.prompt(
|
||||
[
|
||||
{
|
||||
label: __("Return Qty from Rejected Warehouse"),
|
||||
fieldtype: "Check",
|
||||
fieldname: "return_for_rejected_warehouse",
|
||||
default: 1,
|
||||
},
|
||||
],
|
||||
function (values) {
|
||||
if (values.return_for_rejected_warehouse) {
|
||||
frappe.call({
|
||||
method: "erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt.make_subcontract_return_against_rejected_warehouse",
|
||||
args: {
|
||||
source_name: frm.doc.name,
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frappe.model.sync(r.message);
|
||||
frappe.set_route("Form", r.message.doctype, r.message.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
} else {
|
||||
make_standard_return();
|
||||
}
|
||||
},
|
||||
__("Return Qty"),
|
||||
__("Make Return Entry")
|
||||
);
|
||||
} else {
|
||||
make_standard_return();
|
||||
}
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
|
||||
@@ -774,6 +774,13 @@ class SubcontractingReceipt(SubcontractingController):
|
||||
make_purchase_receipt(self, save=True, notify=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_subcontract_return_against_rejected_warehouse(source_name):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
return make_return_doc("Subcontracting Receipt", source_name, return_against_rejected_qty=True)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_subcontract_return(source_name, target_doc=None):
|
||||
from erpnext.controllers.sales_and_purchase_return import make_return_doc
|
||||
|
||||
@@ -1196,6 +1196,136 @@ class TestSubcontractingReceipt(FrappeTestCase):
|
||||
scr.cancel()
|
||||
self.assertTrue(scr.docstatus == 2)
|
||||
|
||||
def test_subcontract_return_from_rejected_warehouse(self):
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
from erpnext.subcontracting.doctype.subcontracting_receipt.subcontracting_receipt import (
|
||||
make_subcontract_return_against_rejected_warehouse,
|
||||
)
|
||||
|
||||
# Create subcontracted item
|
||||
fg_item = make_item(
|
||||
"_Test Subcontract Item Return from Rejected Warehouse",
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"is_sub_contracted_item": 1,
|
||||
},
|
||||
).name
|
||||
|
||||
# Create service item
|
||||
service_item = make_item(
|
||||
"_Test Service Item Return from Rejected Warehouse", properties={"is_stock_item": 0}
|
||||
).name
|
||||
|
||||
# Create BOM for the subcontracted item with required raw materials
|
||||
rm_item1 = make_item(
|
||||
"_Test RM Item 1 Return from Rejected Warehouse", properties={"is_stock_item": 1}
|
||||
).name
|
||||
|
||||
rm_item2 = make_item(
|
||||
"_Test RM Item 2 Return from Rejected Warehouse", properties={"is_stock_item": 1}
|
||||
).name
|
||||
|
||||
make_bom(item=fg_item, raw_materials=[rm_item1, rm_item2])
|
||||
|
||||
# Create warehouses
|
||||
rejected_warehouse = create_warehouse("_Test Subcontract Rejected Warehouse Return Qty Warehouse")
|
||||
|
||||
# Create service items for subcontracting order
|
||||
service_items = [
|
||||
{
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"item_code": service_item,
|
||||
"qty": 10,
|
||||
"rate": 100,
|
||||
"fg_item": fg_item,
|
||||
"fg_item_qty": 10,
|
||||
},
|
||||
]
|
||||
|
||||
# Create Subcontracting Order
|
||||
sco = get_subcontracting_order(service_items=service_items)
|
||||
|
||||
# Stock raw materials
|
||||
make_stock_entry(item_code=rm_item1, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
|
||||
make_stock_entry(item_code=rm_item2, qty=100, target="_Test Warehouse 1 - _TC", basic_rate=100)
|
||||
|
||||
# Transfer raw materials
|
||||
rm_items = get_rm_items(sco.supplied_items)
|
||||
itemwise_details = make_stock_in_entry(rm_items=rm_items)
|
||||
make_stock_transfer_entry(
|
||||
sco_no=sco.name,
|
||||
rm_items=rm_items,
|
||||
itemwise_details=copy.deepcopy(itemwise_details),
|
||||
)
|
||||
|
||||
# Step 1: Create Subcontracting Receipt with rejected quantity
|
||||
sr = make_subcontracting_receipt(sco.name)
|
||||
sr.items[0].qty = 8 # Accepted quantity
|
||||
sr.items[0].rejected_qty = 2
|
||||
sr.items[0].rejected_warehouse = rejected_warehouse
|
||||
sr.save()
|
||||
sr.submit()
|
||||
|
||||
# Verify initial state
|
||||
sr.reload()
|
||||
self.assertEqual(sr.items[0].qty, 8)
|
||||
self.assertEqual(sr.items[0].rejected_qty, 2)
|
||||
self.assertEqual(sr.items[0].rejected_warehouse, rejected_warehouse)
|
||||
|
||||
# Step 2: Create Subcontract Return from Rejected Warehouse
|
||||
sr_return = make_subcontract_return_against_rejected_warehouse(sr.name)
|
||||
|
||||
# Verify the return document properties
|
||||
self.assertEqual(sr_return.doctype, "Subcontracting Receipt")
|
||||
self.assertEqual(sr_return.is_return, 1)
|
||||
self.assertEqual(sr_return.return_against, sr.name)
|
||||
|
||||
# Verify item details in return document
|
||||
self.assertEqual(len(sr_return.items), 1)
|
||||
self.assertEqual(sr_return.items[0].item_code, fg_item)
|
||||
self.assertEqual(sr_return.items[0].warehouse, rejected_warehouse)
|
||||
self.assertEqual(sr_return.items[0].qty, -2.0) # Negative for return
|
||||
self.assertEqual(sr_return.items[0].rejected_qty, 0.0)
|
||||
self.assertEqual(sr_return.items[0].rejected_warehouse, "")
|
||||
|
||||
# Check specific fields that should be set for subcontracting returns
|
||||
self.assertEqual(sr_return.items[0].subcontracting_order, sco.name)
|
||||
self.assertEqual(sr_return.items[0].subcontracting_order_item, sr.items[0].subcontracting_order_item)
|
||||
self.assertEqual(sr_return.items[0].return_qty_from_rejected_warehouse, 1)
|
||||
|
||||
# For returns from rejected warehouse, supplied_items might be empty initially
|
||||
# They might get populated when the document is saved/submitted
|
||||
# Or they might not be needed since we're returning finished goods
|
||||
|
||||
# Save and submit the return
|
||||
sr_return.save()
|
||||
sr_return.submit()
|
||||
|
||||
# Verify final state
|
||||
sr_return.reload()
|
||||
self.assertEqual(sr_return.docstatus, 1)
|
||||
self.assertEqual(sr_return.status, "Return")
|
||||
|
||||
# Verify stock ledger entries for the return
|
||||
sle = frappe.get_all(
|
||||
"Stock Ledger Entry",
|
||||
filters={
|
||||
"voucher_type": "Subcontracting Receipt",
|
||||
"voucher_no": sr_return.name,
|
||||
"warehouse": rejected_warehouse,
|
||||
},
|
||||
fields=["item_code", "actual_qty", "warehouse"],
|
||||
)
|
||||
|
||||
self.assertEqual(len(sle), 1)
|
||||
self.assertEqual(sle[0].item_code, fg_item)
|
||||
self.assertEqual(sle[0].actual_qty, -2.0) # Outward entry from rejected warehouse
|
||||
self.assertEqual(sle[0].warehouse, rejected_warehouse)
|
||||
|
||||
# Verify that the original document's rejected quantity is not affected
|
||||
sr.reload()
|
||||
self.assertEqual(sr.items[0].rejected_qty, 2) # Should remain the same
|
||||
|
||||
@change_settings("Buying Settings", {"auto_create_purchase_receipt": 1})
|
||||
def test_auto_create_purchase_receipt(self):
|
||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||
|
||||
@@ -484,10 +484,16 @@ def get_documents_with_active_service_level_agreement():
|
||||
|
||||
|
||||
def set_documents_with_active_service_level_agreement():
|
||||
active = frozenset(
|
||||
sla.document_type for sla in frappe.get_all("Service Level Agreement", fields=["document_type"])
|
||||
)
|
||||
frappe.cache.set_value("doctypes_with_active_sla", active)
|
||||
try:
|
||||
active = frozenset(
|
||||
sla.document_type for sla in frappe.get_all("Service Level Agreement", fields=["document_type"])
|
||||
)
|
||||
frappe.cache.set_value("doctypes_with_active_sla", active)
|
||||
except (frappe.DoesNotExistError, frappe.db.TableMissingError):
|
||||
# This happens during install / uninstall when wildcard hook for SLA intercepts some doc action.
|
||||
# In both cases, the error can be safely ignored.
|
||||
active = frozenset()
|
||||
|
||||
return active
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user