mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-30 15:54:35 +00:00
Compare commits
7 Commits
v15.118.3
...
version-15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2ec60b7a5 | ||
|
|
1602639a80 | ||
|
|
848335086c | ||
|
|
9a596594da | ||
|
|
455d6d4ac1 | ||
|
|
7cecff9fa4 | ||
|
|
94d63ebb49 |
@@ -1239,7 +1239,7 @@ def get_values_from_purchase_doc(purchase_doc_name, item_code, doctype):
|
||||
return {
|
||||
"company": purchase_doc.company,
|
||||
"purchase_date": purchase_doc.get("posting_date"),
|
||||
"gross_purchase_amount": flt(first_item.base_net_amount),
|
||||
"gross_purchase_amount": flt(first_item.valuation_rate) * flt(first_item.qty),
|
||||
"asset_quantity": first_item.qty,
|
||||
"cost_center": first_item.cost_center or purchase_doc.get("cost_center"),
|
||||
"asset_location": first_item.get("asset_location"),
|
||||
|
||||
@@ -446,3 +446,4 @@ erpnext.patches.v16_0.access_control_for_project_users
|
||||
erpnext.patches.v16_0.rename_ar_ap_ageing_filter
|
||||
erpnext.patches.v15_0.fix_titles
|
||||
erpnext.patches.v16_0.backfill_repost_accounting_ledger_status
|
||||
erpnext.patches.v16_0.merge_seeded_item_group_root
|
||||
|
||||
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
23
erpnext/patches/v16_0/merge_seeded_item_group_root.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import frappe
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
SEEDED_ROOT = "All Item Groups"
|
||||
|
||||
|
||||
def execute():
|
||||
"""Collapse the "All Item Groups" node seeded under a pre-existing root.
|
||||
|
||||
Setup seeding always inserted "All Item Groups" as a parentless group. On a
|
||||
site where another app had already created the root (under a translated
|
||||
name), it was re-parented instead, leaving a second group-root holding the
|
||||
standard Item Groups.
|
||||
"""
|
||||
root = get_root_of("Item Group")
|
||||
if not root or root == SEEDED_ROOT:
|
||||
return
|
||||
|
||||
seeded = frappe.db.get_value("Item Group", SEEDED_ROOT, ["parent_item_group", "is_group"], as_dict=True)
|
||||
if not seeded or not seeded.is_group or seeded.parent_item_group != root:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item Group", SEEDED_ROOT, root, merge=True, show_alert=False)
|
||||
@@ -16,6 +16,8 @@ from frappe.utils.nestedset import (
|
||||
|
||||
test_records = frappe.get_test_records("Item Group")
|
||||
|
||||
TRANSLATED_ROOT = "Todos os Grupos de Itens"
|
||||
|
||||
|
||||
class TestItem(unittest.TestCase):
|
||||
def test_basic_tree(self, records=None):
|
||||
@@ -234,3 +236,46 @@ class TestItem(unittest.TestCase):
|
||||
"_Test Item Group B - 3",
|
||||
merge=True,
|
||||
)
|
||||
|
||||
def test_patch_merges_seeded_root_into_existing_root(self):
|
||||
from erpnext.patches.v16_0.merge_seeded_item_group_root import execute
|
||||
|
||||
self.nest_root_under(TRANSLATED_ROOT)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "All Item Groups", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
|
||||
execute()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item Group", "All Item Groups"))
|
||||
self.assertEqual(self.get_root_names(), [TRANSLATED_ROOT])
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item Group", "_Test Item Group B", "parent_item_group"), TRANSLATED_ROOT
|
||||
)
|
||||
self.test_basic_tree()
|
||||
|
||||
# restore the original root name for the tests that follow
|
||||
frappe.rename_doc("Item Group", TRANSLATED_ROOT, "All Item Groups")
|
||||
self.assertEqual(self.get_root_names(), ["All Item Groups"])
|
||||
self.test_basic_tree()
|
||||
|
||||
def nest_root_under(self, new_root):
|
||||
"""Recreate the tree left behind by seeding a root under a pre-existing one."""
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": new_root,
|
||||
"is_group": 1,
|
||||
"parent_item_group": "All Item Groups",
|
||||
}
|
||||
).insert()
|
||||
|
||||
ig = frappe.qb.DocType("Item Group")
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, "").where(ig.name == new_root).run()
|
||||
frappe.qb.update(ig).set(ig.parent_item_group, new_root).where(ig.name == "All Item Groups").run()
|
||||
rebuild_tree("Item Group", "parent_item_group")
|
||||
|
||||
def get_root_names(self):
|
||||
return frappe.db.sql_list(
|
||||
"""select name from `tabItem Group` where ifnull(parent_item_group, '')=''"""
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from frappe.desk.doctype.global_search_settings.global_search_settings import (
|
||||
)
|
||||
from frappe.desk.page.setup_wizard.setup_wizard import make_records
|
||||
from frappe.utils import cstr, getdate
|
||||
from frappe.utils.nestedset import get_root_of
|
||||
|
||||
from erpnext.accounts.doctype.account.account import RootNotEditable
|
||||
from erpnext.regional.address_template.setup import set_up_address_templates
|
||||
@@ -24,46 +25,48 @@ def read_lines(filename: str) -> list[str]:
|
||||
|
||||
|
||||
def install(country=None):
|
||||
root_item_group = get_root_of("Item Group") or _("All Item Groups")
|
||||
records = [
|
||||
# ensure at least an empty Address Template exists for this Country
|
||||
{"doctype": "Address Template", "country": country},
|
||||
# item group
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("All Item Groups"),
|
||||
"item_group_name": root_item_group,
|
||||
"is_group": 1,
|
||||
"parent_item_group": "",
|
||||
"__condition": lambda: not frappe.db.exists("Item Group", root_item_group),
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Products"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
"show_in_website": 1,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Raw Material"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Services"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Sub Assemblies"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
{
|
||||
"doctype": "Item Group",
|
||||
"item_group_name": _("Consumable"),
|
||||
"is_group": 0,
|
||||
"parent_item_group": _("All Item Groups"),
|
||||
"parent_item_group": root_item_group,
|
||||
},
|
||||
# Stock Entry Type
|
||||
{
|
||||
|
||||
@@ -706,6 +706,76 @@ class TestDeliveryNote(FrappeTestCase):
|
||||
|
||||
self.assertEqual(gle_warehouse_amount, 1400)
|
||||
|
||||
def test_return_bundle_voucher_detail_no_as_packed_item(self):
|
||||
"""Return bundle whose voucher_detail_no is the Packed Item (SLE-driven path) must still value on repost."""
|
||||
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_return
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
packed_item = make_item(
|
||||
properties={
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"create_new_batch": 1,
|
||||
"batch_number_series": "BATCH-DN-RET-VDN-.#####",
|
||||
}
|
||||
).name
|
||||
bundle_item = make_item(properties={"is_stock_item": 0, "is_sales_item": 1}).name
|
||||
make_product_bundle(bundle_item, [packed_item], qty=20)
|
||||
|
||||
make_stock_entry(item_code=packed_item, target=warehouse, qty=60, basic_rate=35)
|
||||
|
||||
dn = create_delivery_note(item_code=bundle_item, warehouse=warehouse, qty=3)
|
||||
|
||||
return_dn = make_sales_return(dn.name)
|
||||
return_dn.items[0].qty = -2
|
||||
return_dn.submit()
|
||||
return_dn.reload()
|
||||
|
||||
packed_row = return_dn.packed_items[0]
|
||||
bundle = frappe.get_doc("Serial and Batch Bundle", packed_row.serial_and_batch_bundle)
|
||||
|
||||
# Reproduce the reported state: bundle points at the Packed Item (not the DN Item), valuation at 0.
|
||||
bundle.db_set("voucher_detail_no", packed_row.name)
|
||||
bundle.db_set({"avg_rate": 0, "total_amount": 0})
|
||||
for entry in bundle.entries:
|
||||
entry.db_set({"incoming_rate": 0, "stock_value_difference": 0})
|
||||
packed_row.db_set("incoming_rate", 0)
|
||||
frappe.db.set_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
{"incoming_rate": 0, "stock_value_difference": 0},
|
||||
)
|
||||
|
||||
frappe.get_doc(
|
||||
doctype="Repost Item Valuation",
|
||||
based_on="Transaction",
|
||||
voucher_type="Delivery Note",
|
||||
voucher_no=return_dn.name,
|
||||
posting_date=return_dn.posting_date,
|
||||
posting_time=return_dn.posting_time,
|
||||
).submit()
|
||||
|
||||
bundle.reload()
|
||||
self.assertEqual(flt(bundle.avg_rate), 35)
|
||||
|
||||
incoming_rate, stock_value_difference = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{
|
||||
"voucher_type": "Delivery Note",
|
||||
"voucher_no": return_dn.name,
|
||||
"item_code": packed_item,
|
||||
"is_cancelled": 0,
|
||||
},
|
||||
["incoming_rate", "stock_value_difference"],
|
||||
)
|
||||
self.assertEqual(flt(incoming_rate), 35)
|
||||
self.assertEqual(flt(stock_value_difference), 1400)
|
||||
|
||||
def test_bin_details_of_packed_item(self):
|
||||
from erpnext.selling.doctype.product_bundle.test_product_bundle import make_product_bundle
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -404,29 +404,10 @@ class PurchaseReceipt(BuyingController):
|
||||
self.set_consumed_qty_in_subcontract_order()
|
||||
self.reserve_stock_for_sales_order()
|
||||
|
||||
def check_next_docstatus(self):
|
||||
submit_rv = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
(self.name),
|
||||
)
|
||||
if submit_rv:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(self.submit_rv[0][0]))
|
||||
|
||||
def on_cancel(self):
|
||||
super().on_cancel()
|
||||
|
||||
self.check_on_hold_or_closed_status()
|
||||
# Check if Purchase Invoice has been submitted against current Purchase Order
|
||||
submitted = frappe.db.sql(
|
||||
"""select t1.name
|
||||
from `tabPurchase Invoice` t1,`tabPurchase Invoice Item` t2
|
||||
where t1.name = t2.parent and t2.purchase_receipt = %s and t1.docstatus = 1""",
|
||||
self.name,
|
||||
)
|
||||
if submitted:
|
||||
frappe.throw(_("Purchase Invoice {0} is already submitted").format(submitted[0][0]))
|
||||
|
||||
self.update_prevdoc_status()
|
||||
self.update_billing_status()
|
||||
|
||||
@@ -5446,6 +5446,32 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
srbnb_credit = sum(flt(row.credit) for row in gl_entries if row.account == srbnb_account)
|
||||
self.assertAlmostEqual(srbnb_credit, pi_base_net_amount, places=2)
|
||||
|
||||
def test_cancel_blocked_by_submitted_invoice_rolls_back(self):
|
||||
"""A submitted Purchase Invoice must block cancelling its Purchase Receipt. Frappe's backlink
|
||||
check rejects the cancel only after on_cancel has run stock, GL, and status work, so the whole
|
||||
transaction has to roll back: the receipt stays submitted with no leaked ledger entries."""
|
||||
pr = make_purchase_receipt()
|
||||
pi = make_purchase_invoice(pr.name)
|
||||
pi.insert()
|
||||
pi.submit()
|
||||
|
||||
pr.reload()
|
||||
status_before = pr.status
|
||||
sle_before = frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name})
|
||||
gle_before = frappe.db.count("GL Entry", {"voucher_no": pr.name})
|
||||
|
||||
frappe.db.savepoint("before_blocked_cancel")
|
||||
with self.assertRaises(frappe.LinkExistsError) as cm:
|
||||
pr.cancel()
|
||||
self.assertIn(pi.name, str(cm.exception))
|
||||
frappe.db.rollback(save_point="before_blocked_cancel") # mimic the request-level rollback
|
||||
|
||||
pr.reload()
|
||||
self.assertEqual(pr.docstatus, 1)
|
||||
self.assertEqual(pr.status, status_before)
|
||||
self.assertEqual(frappe.db.count("Stock Ledger Entry", {"voucher_no": pr.name}), sle_before)
|
||||
self.assertEqual(frappe.db.count("GL Entry", {"voucher_no": pr.name}), gle_before)
|
||||
|
||||
|
||||
def prepare_data_for_internal_transfer():
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_internal_supplier
|
||||
|
||||
@@ -505,6 +505,11 @@ class SerialandBatchBundle(Document):
|
||||
self.child_table, self.voucher_detail_no, field
|
||||
)
|
||||
|
||||
if not return_against_voucher_detail_no and self.voucher_type in ("Delivery Note", "Sales Invoice"):
|
||||
# Bundles built via the use_serial_batch_fields / SLE-driven path keep the Packed Item
|
||||
# as voucher_detail_no (not remapped to the DN/SI Item), so the lookup above misses.
|
||||
return_against_voucher_detail_no = self.get_return_against_packed_item(field)
|
||||
|
||||
filters = [
|
||||
["Serial and Batch Bundle", "voucher_no", "=", return_against],
|
||||
["Serial and Batch Entry", "docstatus", "=", 1],
|
||||
@@ -548,6 +553,16 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
return valuation_details
|
||||
|
||||
def get_return_against_packed_item(self, field):
|
||||
"""Resolve the original DN/SI Item when a return bundle's voucher_detail_no is the Packed Item."""
|
||||
parent_detail_docname = frappe.db.get_value(
|
||||
"Packed Item", self.voucher_detail_no, "parent_detail_docname"
|
||||
)
|
||||
if not parent_detail_docname:
|
||||
return
|
||||
|
||||
return frappe.db.get_value(self.child_table, parent_detail_docname, field)
|
||||
|
||||
def get_legacy_valuation_rate_for_return_entry(
|
||||
self, return_against, return_against_voucher_detail_no, return_warehouse=None
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user