mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-21 02:09:58 +00:00
chore(stock): merge version-15-hotfix into pick list partial-transfer backport
This commit is contained in:
@@ -22,17 +22,27 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
refresh: function (frm) {
|
||||
if (frm.doc.docstatus == 1) {
|
||||
frappe.call({
|
||||
method: "check_journal_entry_condition",
|
||||
method: "check_journal_and_reversal",
|
||||
doc: frm.doc,
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
if (!r.message.journals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_jv(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
} else if (!r.message.reversals_posted) {
|
||||
frm.add_custom_button(
|
||||
__("Reversal Journal Entries"),
|
||||
function () {
|
||||
return frm.events.make_reverse_journal(frm);
|
||||
},
|
||||
__("Create")
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -100,6 +110,14 @@ frappe.ui.form.on("Exchange Rate Revaluation", {
|
||||
},
|
||||
});
|
||||
},
|
||||
make_reverse_journal: function (frm) {
|
||||
frappe.call({
|
||||
method: "make_reverse_journal",
|
||||
doc: frm.doc,
|
||||
freeze: true,
|
||||
freeze_message: __("Reversing Journals..."),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
frappe.ui.form.on("Exchange Rate Revaluation Account", {
|
||||
|
||||
@@ -8,7 +8,7 @@ from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.query_builder import Criterion, Order
|
||||
from frappe.query_builder.functions import NullIf, Sum
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
from frappe.utils import flt, get_link_to_form, nowdate
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import get_balance_on
|
||||
@@ -90,25 +90,31 @@ class ExchangeRateRevaluation(Document):
|
||||
)
|
||||
|
||||
def on_cancel(self):
|
||||
self.ignore_linked_doctypes = "GL Entry"
|
||||
self.ignore_linked_doctypes = ["GL Entry", "Payment Ledger Entry"]
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_journal_entry_condition(self):
|
||||
def check_journal_and_reversal(self):
|
||||
exchange_gain_loss_account = self.get_for_unrealized_gain_loss_account()
|
||||
|
||||
journals_posted = False
|
||||
reversals_posted = False
|
||||
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(jea)
|
||||
.select(jea.parent)
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run()
|
||||
.run(pluck="name")
|
||||
)
|
||||
|
||||
if journals:
|
||||
gle = qb.DocType("GL Entry")
|
||||
total_amt = (
|
||||
@@ -123,12 +129,31 @@ class ExchangeRateRevaluation(Document):
|
||||
.run()
|
||||
)
|
||||
|
||||
if total_amt and total_amt[0][0] != self.total_gain_loss:
|
||||
return True
|
||||
if total_amt and total_amt[0][0] == self.total_gain_loss:
|
||||
journals_posted = True
|
||||
else:
|
||||
return False
|
||||
journals_posted = False
|
||||
|
||||
return True
|
||||
# reverse journals
|
||||
reverse_journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.notnull())
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if reverse_journals:
|
||||
reversals_posted = True
|
||||
else:
|
||||
reversals_posted = False
|
||||
|
||||
return {"journals_posted": journals_posted, "reversals_posted": reversals_posted}
|
||||
|
||||
def fetch_and_calculate_accounts_data(self):
|
||||
accounts = self.get_accounts_data()
|
||||
@@ -342,6 +367,7 @@ class ExchangeRateRevaluation(Document):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_jv_entries(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
zero_balance_jv = self.make_jv_for_zero_balance()
|
||||
if zero_balance_jv:
|
||||
frappe.msgprint(
|
||||
@@ -571,6 +597,38 @@ class ExchangeRateRevaluation(Document):
|
||||
journal_entry.save()
|
||||
return journal_entry
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_reverse_journal(self):
|
||||
frappe.has_permission("Journal Entry", "write", throw=True)
|
||||
je = qb.DocType("Journal Entry")
|
||||
jea = qb.DocType("Journal Entry Account")
|
||||
journals = (
|
||||
qb.from_(je)
|
||||
.join(jea)
|
||||
.on(je.name == jea.parent)
|
||||
.select(je.name)
|
||||
.distinct()
|
||||
.where(
|
||||
(jea.reference_type == "Exchange Rate Revaluation")
|
||||
& (jea.reference_name == self.name)
|
||||
& (jea.docstatus == 1)
|
||||
& (je.reversal_of.isnull()) # omit journals that have reversals
|
||||
)
|
||||
.run(pluck="name")
|
||||
)
|
||||
if journals:
|
||||
from erpnext.accounts.doctype.journal_entry.journal_entry import make_reverse_journal_entry
|
||||
|
||||
for x in journals:
|
||||
reversal = make_reverse_journal_entry(x)
|
||||
reversal.posting_date = nowdate()
|
||||
reversal.submit()
|
||||
frappe.msgprint(
|
||||
_("Revaluation journal for {0} has been created: {1}").format(
|
||||
frappe.bold(x), get_link_to_form("Journal Entry", reversal.name)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def calculate_exchange_rate_using_last_gle(company, account, party_type, party):
|
||||
"""
|
||||
|
||||
@@ -130,7 +130,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -213,7 +214,8 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
err = err.save().submit()
|
||||
|
||||
# Create JV for ERR
|
||||
self.assertTrue(err.check_journal_entry_condition())
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("zero_balance_jv"))
|
||||
je = je.submit()
|
||||
@@ -287,3 +289,83 @@ class TestExchangeRateRevaluation(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
for key, _val in expected_data.items():
|
||||
self.assertEqual(expected_data.get(key), account_details.get(key))
|
||||
|
||||
@change_settings(
|
||||
"Accounts Settings",
|
||||
{"allow_multi_currency_invoices_against_single_party_account": 1, "allow_stale": 0},
|
||||
)
|
||||
def test_05_revaluation_journal_reversal(self):
|
||||
"""
|
||||
Test reversing of revaluation journals
|
||||
"""
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debtors_usd,
|
||||
posting_date=today(),
|
||||
parent_cost_center=self.cost_center,
|
||||
cost_center=self.cost_center,
|
||||
rate=100,
|
||||
price_list_rate=100,
|
||||
do_not_submit=1,
|
||||
)
|
||||
si.currency = "USD"
|
||||
si.conversion_rate = 80
|
||||
si.save().submit()
|
||||
|
||||
err = frappe.new_doc("Exchange Rate Revaluation")
|
||||
err.company = self.company
|
||||
err.posting_date = today()
|
||||
err.fetch_and_calculate_accounts_data()
|
||||
self.assertEqual(len(err.accounts), 1)
|
||||
err.save().submit()
|
||||
|
||||
gain_loss_account = err.get_for_unrealized_gain_loss_account()
|
||||
usd_account = err.accounts[0].account
|
||||
old_balance = err.accounts[0].balance_in_base_currency
|
||||
new_balance = err.accounts[0].new_balance_in_base_currency
|
||||
total_gain_loss = err.total_gain_loss
|
||||
|
||||
# Create JV for ERR
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertFalse(ret.get("journals_posted"))
|
||||
err_journals = err.make_jv_entries()
|
||||
je = frappe.get_doc("Journal Entry", err_journals.get("revaluation_jv"))
|
||||
je = je.submit()
|
||||
|
||||
je.reload()
|
||||
self.assertEqual(je.voucher_type, "Exchange Rate Revaluation")
|
||||
self.assertEqual(len(je.accounts), 3)
|
||||
expected = [
|
||||
(usd_account, new_balance, 0.0, 100.0, 0.0),
|
||||
(usd_account, 0.0, old_balance, 0.0, 100.0),
|
||||
(gain_loss_account, 0.0, total_gain_loss, 0.0, total_gain_loss),
|
||||
]
|
||||
actual = []
|
||||
for acc in je.accounts:
|
||||
actual.append(
|
||||
(
|
||||
acc.account,
|
||||
acc.debit,
|
||||
acc.credit,
|
||||
acc.debit_in_account_currency,
|
||||
acc.credit_in_account_currency,
|
||||
)
|
||||
)
|
||||
self.assertEqual(expected, actual)
|
||||
|
||||
# Assert reversals are not posted
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertFalse(ret.get("reversals_posted"))
|
||||
|
||||
err.make_reverse_journal()
|
||||
ret = err.check_journal_and_reversal()
|
||||
self.assertTrue(ret.get("journals_posted"))
|
||||
self.assertTrue(ret.get("reversals_posted"))
|
||||
|
||||
reverse_jv = frappe.db.get_all(
|
||||
"Journal Entry", filters={"reversal_of": err_journals.get("revaluation_jv")}, pluck="name"
|
||||
)
|
||||
self.assertIsNotNone(reverse_jv)
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
frappe.listview_settings["Journal Entry"] = {
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark"],
|
||||
add_fields: ["voucher_type", "posting_date", "total_debit", "company", "user_remark", "reversal_of"],
|
||||
get_indicator: function (doc) {
|
||||
if (doc.docstatus == 0) {
|
||||
return [__("Draft", "red", "docstatus,=,0")];
|
||||
} else if (doc.docstatus == 2) {
|
||||
return [__("Cancelled", "grey", "docstatus,=,2")];
|
||||
} else {
|
||||
return [__(doc.voucher_type), "blue", "voucher_type,=," + doc.voucher_type];
|
||||
} else if (doc.docstatus === 1) {
|
||||
if (doc.reversal_of && doc.voucher_type == "Exchange Rate Revaluation") {
|
||||
return [__("Reversal Of Exchange Rate Revaluation"), "blue"];
|
||||
}
|
||||
return [__(doc.voucher_type), "blue", `voucher_type,=,${doc.voucher_type}`];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding Amount",
|
||||
"options": "Company:company:default_currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -115,7 +116,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2022-03-21 19:31:45.382656",
|
||||
"modified": "2026-07-02 15:17:11.938499",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
@@ -126,4 +127,4 @@
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,10 +263,12 @@ class ReceivablePayableReport:
|
||||
|
||||
# Build and use a separate row for Employee Advances.
|
||||
# This allows Payments or Journals made against Emp Advance to be processed.
|
||||
if (
|
||||
not row
|
||||
and ple.against_voucher_type == "Employee Advance"
|
||||
and self.filters.handle_employee_advances
|
||||
if not row and (
|
||||
(ple.against_voucher_type == "Employee Advance" and self.filters.handle_employee_advances)
|
||||
or (
|
||||
ple.against_voucher_type == "Exchange Rate Revaluation"
|
||||
and self.filters.for_revaluation_journals
|
||||
)
|
||||
):
|
||||
_d = self.build_voucher_dict(ple)
|
||||
_d.voucher_type = ple.against_voucher_type
|
||||
|
||||
@@ -177,6 +177,68 @@ def update_variant_attribute_values(item_attribute):
|
||||
frappe.flags.attribute_values = None
|
||||
|
||||
|
||||
def get_attribute_abbr_renames(item_attribute):
|
||||
"""Return the set of (current) attribute values whose abbreviation was renamed."""
|
||||
if item_attribute.numeric_values:
|
||||
return set()
|
||||
|
||||
db_value = item_attribute.get_doc_before_save()
|
||||
if not db_value:
|
||||
return set()
|
||||
|
||||
old_abbrs = {d.name: d.abbr for d in db_value.item_attribute_values}
|
||||
changed_values = set()
|
||||
|
||||
for row in item_attribute.item_attribute_values:
|
||||
if row.name in old_abbrs and old_abbrs[row.name] != row.abbr:
|
||||
changed_values.add(row.attribute_value)
|
||||
|
||||
return changed_values
|
||||
|
||||
|
||||
def update_variant_item_codes_for_abbr_renames(item_attribute):
|
||||
"""Rebuild item_code/item_name of variant Items affected by a renamed Item Attribute abbreviation."""
|
||||
changed_values = get_attribute_abbr_renames(item_attribute)
|
||||
if not changed_values:
|
||||
return
|
||||
|
||||
item_variant_table = frappe.qb.DocType("Item Variant Attribute")
|
||||
variant_names = (
|
||||
frappe.qb.from_(item_variant_table)
|
||||
.select(item_variant_table.parent)
|
||||
.where(item_variant_table.attribute == item_attribute.name)
|
||||
.where(item_variant_table.attribute_value.isin(list(changed_values)))
|
||||
.distinct()
|
||||
.run(pluck=True)
|
||||
)
|
||||
|
||||
for variant_name in variant_names:
|
||||
rename_variant_item_code(variant_name)
|
||||
|
||||
|
||||
def rename_variant_item_code(variant_name):
|
||||
"""Recompute a variant's item_code/item_name from its template and current attribute abbreviations,
|
||||
renaming the Item if it has changed."""
|
||||
variant = frappe.get_doc("Item", variant_name)
|
||||
if not variant.variant_of:
|
||||
return
|
||||
|
||||
template = frappe.get_cached_doc("Item", variant.variant_of)
|
||||
|
||||
new_code = frappe._dict({"item_code": None, "item_name": None, "attributes": variant.attributes})
|
||||
make_variant_item_code(template.item_code, template.item_name, new_code)
|
||||
|
||||
if not new_code.item_code or new_code.item_code == variant.item_code:
|
||||
return
|
||||
|
||||
frappe.rename_doc("Item", variant.item_code, new_code.item_code)
|
||||
|
||||
# Keep item_name in lockstep with item_code: both are derived from the same abbreviation, so
|
||||
# item_name is always rebuilt here too, even if it had since been customized away from that pattern.
|
||||
if new_code.item_name and new_code.item_name != variant.item_name:
|
||||
frappe.db.set_value("Item", new_code.item_code, "item_name", new_code.item_name)
|
||||
|
||||
|
||||
def validate_item_attribute_value(attributes_list, attribute, attribute_value, item, from_variant=True):
|
||||
allow_rename_attribute_value = frappe.db.get_single_value(
|
||||
"Item Variant Settings", "allow_rename_attribute_value"
|
||||
|
||||
@@ -361,13 +361,24 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
# based_on_cols, based_on_select, based_on_group_by, addl_tables
|
||||
if based_on == "Item":
|
||||
based_on_details["based_on_cols"] = ["Item:Link/Item:120", "Item Name:Data:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{"label": _("Item"), "fieldtype": "Link", "options": "Item", "width": 120, "fieldname": "item"},
|
||||
{"label": _("Item Name"), "fieldtype": "Data", "width": 120, "fieldname": "item_name"},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_code, t2.item_name,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_code"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Item Group":
|
||||
based_on_details["based_on_cols"] = ["Item Group:Link/Item Group:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Item Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Item Group",
|
||||
"width": 120,
|
||||
"fieldname": "item_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.item_group,"
|
||||
based_on_details["based_on_group_by"] = "t2.item_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -375,32 +386,80 @@ def based_wise_columns_query(based_on, trans):
|
||||
elif based_on == "Customer":
|
||||
if trans == "Quotation":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Party:Link/Customer:120",
|
||||
"Party Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Party"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "party",
|
||||
},
|
||||
{"label": _("Party Name"), "fieldtype": "Data", "width": 120, "fieldname": "party_name"},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.party_name, t1.customer_name, t1.territory,"
|
||||
else:
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Customer:Link/Customer:120",
|
||||
"Customer Name:Data:120",
|
||||
"Territory:Link/Territory:120",
|
||||
{
|
||||
"label": _("Customer"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer",
|
||||
"width": 120,
|
||||
"fieldname": "customer",
|
||||
},
|
||||
{
|
||||
"label": _("Customer Name"),
|
||||
"fieldtype": "Data",
|
||||
"width": 120,
|
||||
"fieldname": "customer_name",
|
||||
},
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer, t1.customer_name, t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.party_name" if trans == "Quotation" else "t1.customer"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Customer Group":
|
||||
based_on_details["based_on_cols"] = ["Customer Group:Link/Customer Group"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Customer Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Customer Group",
|
||||
"fieldname": "customer_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.customer_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.customer_group"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Supplier":
|
||||
based_on_details["based_on_cols"] = [
|
||||
"Supplier:Link/Supplier:120",
|
||||
"Supplier Name:Data:120",
|
||||
"Supplier Group:Link/Supplier Group:140",
|
||||
{
|
||||
"label": _("Supplier"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier",
|
||||
"width": 120,
|
||||
"fieldname": "supplier",
|
||||
},
|
||||
{"label": _("Supplier Name"), "fieldtype": "Data", "width": 120, "fieldname": "supplier_name"},
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
},
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.supplier, t1.supplier_name, t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t1.supplier"
|
||||
@@ -408,26 +467,58 @@ def based_wise_columns_query(based_on, trans):
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Supplier Group":
|
||||
based_on_details["based_on_cols"] = ["Supplier Group:Link/Supplier Group:140"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Supplier Group"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Supplier Group",
|
||||
"width": 140,
|
||||
"fieldname": "supplier_group",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t3.supplier_group,"
|
||||
based_on_details["based_on_group_by"] = "t3.supplier_group"
|
||||
based_on_details["addl_tables"] = ",`tabSupplier` t3"
|
||||
based_on_details["addl_tables_relational_cond"] = " and t1.supplier = t3.name"
|
||||
|
||||
elif based_on == "Territory":
|
||||
based_on_details["based_on_cols"] = ["Territory:Link/Territory:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Territory"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Territory",
|
||||
"width": 120,
|
||||
"fieldname": "territory",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.territory,"
|
||||
based_on_details["based_on_group_by"] = "t1.territory"
|
||||
based_on_details["addl_tables"] = ""
|
||||
|
||||
elif based_on == "Project":
|
||||
if trans in ["Sales Invoice", "Delivery Note", "Sales Order"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t1.project,"
|
||||
based_on_details["based_on_group_by"] = "t1.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
elif trans in ["Purchase Order", "Purchase Invoice", "Purchase Receipt"]:
|
||||
based_on_details["based_on_cols"] = ["Project:Link/Project:120"]
|
||||
based_on_details["based_on_cols"] = [
|
||||
{
|
||||
"label": _("Project"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Project",
|
||||
"width": 120,
|
||||
"fieldname": "project",
|
||||
}
|
||||
]
|
||||
based_on_details["based_on_select"] = "t2.project,"
|
||||
based_on_details["based_on_group_by"] = "t2.project"
|
||||
based_on_details["addl_tables"] = ""
|
||||
@@ -435,7 +526,15 @@ def based_wise_columns_query(based_on, trans):
|
||||
frappe.throw(_("Project-wise data is not available for Quotation"))
|
||||
|
||||
based_on_details["based_on_select"] += "t4.default_currency as currency,"
|
||||
based_on_details["based_on_cols"].append("Currency:Link/Currency:120")
|
||||
based_on_details["based_on_cols"].append(
|
||||
{
|
||||
"label": _("Currency"),
|
||||
"fieldtype": "Link",
|
||||
"options": "Currency",
|
||||
"width": 120,
|
||||
"fieldname": "currency",
|
||||
}
|
||||
)
|
||||
based_on_details["addl_tables"] += ", `tabCompany` t4"
|
||||
based_on_details["addl_tables_relational_cond"] = (
|
||||
based_on_details.get("addl_tables_relational_cond", "") + " and t1.company = t4.name"
|
||||
@@ -446,6 +545,14 @@ def based_wise_columns_query(based_on, trans):
|
||||
|
||||
def group_wise_column(group_by):
|
||||
if group_by:
|
||||
return [group_by + ":Link/" + group_by + ":120"]
|
||||
return [
|
||||
{
|
||||
"label": _(group_by),
|
||||
"fieldtype": "Link",
|
||||
"options": group_by,
|
||||
"width": 120,
|
||||
"fieldname": frappe.scrub(group_by),
|
||||
}
|
||||
]
|
||||
else:
|
||||
return []
|
||||
|
||||
@@ -441,7 +441,11 @@ frappe.ui.form.on("BOM", {
|
||||
},
|
||||
|
||||
routing(frm) {
|
||||
if (frm.doc.routing && frm.doc.with_operations && !frm.doc.operations.length) {
|
||||
// Refetch operations whenever the routing is (re)selected, so that
|
||||
// changing the routing - e.g. on a new BOM version copied from another
|
||||
// BOM - replaces the operations with those of the newly selected routing
|
||||
// instead of keeping the old ones.
|
||||
if (frm.doc.routing && frm.doc.with_operations) {
|
||||
frappe.call({
|
||||
doc: frm.doc,
|
||||
method: "get_routing",
|
||||
|
||||
@@ -1545,6 +1545,38 @@ class TestWorkOrder(FrappeTestCase):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred(self):
|
||||
"""Stock Entry created from a Pick List that picked only one of the required items:
|
||||
min-fraction keeps material_transferred_for_manufacturing at 0, but the work order must
|
||||
still move to In Process because material is already in WIP."""
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import create_pick_list
|
||||
from erpnext.stock.doctype.pick_list.pick_list import create_stock_entry
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item", target="Stores - _TC", qty=10, basic_rate=5000.0
|
||||
)
|
||||
test_stock_entry.make_stock_entry(
|
||||
item_code="_Test Item Home Desktop 100", target="Stores - _TC", qty=10, basic_rate=1000.0
|
||||
)
|
||||
|
||||
pick_list = create_pick_list(work_order.name, for_qty=work_order.qty)
|
||||
# pick only _Test Item; the other required item is left out of this pick list
|
||||
pick_list.pick_manually = 1
|
||||
pick_list.locations = [loc for loc in pick_list.locations if loc.item_code == "_Test Item"]
|
||||
pick_list.save()
|
||||
pick_list.submit()
|
||||
|
||||
stock_entry = frappe.get_doc(create_stock_entry(frappe.as_json(pick_list.as_dict())))
|
||||
self.assertEqual(stock_entry.fg_completed_qty, 0.0)
|
||||
stock_entry.submit()
|
||||
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 0.0)
|
||||
self.assertEqual(work_order.status, "In Process")
|
||||
|
||||
def test_backflushed_batch_raw_materials_based_on_transferred(self):
|
||||
frappe.db.set_single_value(
|
||||
"Manufacturing Settings",
|
||||
|
||||
@@ -157,6 +157,7 @@ class WorkOrder(Document):
|
||||
self.check_wip_warehouse_skip()
|
||||
self.calculate_operating_cost()
|
||||
self.validate_qty()
|
||||
self.validate_dates()
|
||||
self.validate_transfer_against()
|
||||
self.validate_operations()
|
||||
self.status = self.get_status()
|
||||
@@ -175,6 +176,11 @@ class WorkOrder(Document):
|
||||
|
||||
self.validate_operations_sequence()
|
||||
|
||||
def validate_dates(self):
|
||||
if self.planned_start_date and self.planned_end_date:
|
||||
if get_datetime(self.planned_end_date) < get_datetime(self.planned_start_date):
|
||||
frappe.throw(_("Planned End Date cannot be before Planned Start Date"))
|
||||
|
||||
def validate_operations_sequence(self):
|
||||
if all([not op.sequence_id for op in self.operations]):
|
||||
for op in self.operations:
|
||||
@@ -406,7 +412,11 @@ class WorkOrder(Document):
|
||||
elif self.docstatus == 1:
|
||||
if status not in ["Closed", "Stopped"]:
|
||||
status = "Not Started"
|
||||
if flt(self.material_transferred_for_manufacturing) > 0 or self.skip_transfer:
|
||||
if (
|
||||
flt(self.material_transferred_for_manufacturing) > 0
|
||||
or self.skip_transfer
|
||||
or self.has_transferred_material()
|
||||
):
|
||||
status = "In Process"
|
||||
|
||||
precision = frappe.get_precision("Work Order", "produced_qty")
|
||||
@@ -425,6 +435,26 @@ class WorkOrder(Document):
|
||||
|
||||
return status
|
||||
|
||||
def has_transferred_material(self):
|
||||
"""True if any raw material was transferred against this work order via a pick list
|
||||
(these leave material_transferred_for_manufacturing at 0 via the min-fraction rule)."""
|
||||
ste = frappe.qb.DocType("Stock Entry")
|
||||
ste_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
qty = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(
|
||||
(ste.work_order == self.name)
|
||||
& (ste.docstatus == 1)
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull())
|
||||
)
|
||||
).run()[0][0]
|
||||
return flt(qty) > 0
|
||||
|
||||
def update_work_order_qty(self):
|
||||
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
|
||||
based on Stock Entry"""
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder import Criterion
|
||||
|
||||
from erpnext import get_company_currency
|
||||
|
||||
@@ -155,60 +155,50 @@ def get_columns(filters):
|
||||
|
||||
|
||||
def get_entries(filters):
|
||||
doc_type = filters["doc_type"]
|
||||
date_field = filters["doc_type"] == "Sales Order" and "transaction_date" or "posting_date"
|
||||
if filters["doc_type"] == "Sales Order":
|
||||
qty_field = "delivered_qty"
|
||||
else:
|
||||
qty_field = "qty"
|
||||
conditions, values = get_conditions(filters, date_field)
|
||||
|
||||
date_field = "transaction_date" if doc_type == "Sales Order" else "posting_date"
|
||||
qty_field = "delivered_qty" if doc_type == "Sales Order" else "qty"
|
||||
|
||||
dt = frappe.qb.DocType(doc_type)
|
||||
dt_item = frappe.qb.DocType(f"{doc_type} Item")
|
||||
st = frappe.qb.DocType("Sales Team")
|
||||
|
||||
calc_qty = dt_item[qty_field] * dt_item.conversion_factor
|
||||
calc_net_amount = dt_item.base_net_rate * calc_qty
|
||||
|
||||
stock_qty_case = Case().when(dt.status == "Closed", calc_qty).else_(dt_item.stock_qty).as_("stock_qty")
|
||||
|
||||
base_net_amount_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", calc_net_amount)
|
||||
.else_(dt_item.base_net_amount)
|
||||
.as_("base_net_amount")
|
||||
entries = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
dt.name, dt.customer, dt.territory, dt.{} as posting_date, dt_item.item_code,
|
||||
st.sales_person, st.allocated_percentage, dt_item.warehouse,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN dt_item.{} * dt_item.conversion_factor
|
||||
ELSE dt_item.stock_qty
|
||||
END as stock_qty,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN (dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor)
|
||||
ELSE dt_item.base_net_amount
|
||||
END as base_net_amount,
|
||||
CASE
|
||||
WHEN dt.status = "Closed" THEN ((dt_item.base_net_rate * dt_item.{} * dt_item.conversion_factor) * st.allocated_percentage/100)
|
||||
ELSE dt_item.base_net_amount * st.allocated_percentage/100
|
||||
END as contribution_amt
|
||||
FROM
|
||||
`tab{}` dt, `tab{} Item` dt_item, `tabSales Team` st
|
||||
WHERE
|
||||
st.parent = dt.name and dt.name = dt_item.parent and st.parenttype = {}
|
||||
and dt.docstatus = 1 {} order by st.sales_person, dt.name desc
|
||||
""".format(
|
||||
date_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
qty_field,
|
||||
filters["doc_type"],
|
||||
filters["doc_type"],
|
||||
"%s",
|
||||
conditions,
|
||||
),
|
||||
tuple([filters["doc_type"], *values]),
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
contribution_amt_case = (
|
||||
Case()
|
||||
.when(dt.status == "Closed", (calc_net_amount * st.allocated_percentage / 100))
|
||||
.else_(dt_item.base_net_amount * st.allocated_percentage / 100)
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
.on(dt.name == st.parent)
|
||||
.select(
|
||||
dt.name,
|
||||
dt.customer,
|
||||
dt.territory,
|
||||
dt[date_field].as_("posting_date"),
|
||||
dt_item.item_code,
|
||||
st.sales_person,
|
||||
st.allocated_percentage,
|
||||
dt_item.warehouse,
|
||||
stock_qty_case,
|
||||
base_net_amount_case,
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
return entries
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
|
||||
@@ -442,22 +442,34 @@ class DeliveryNote(SellingController):
|
||||
frappe.throw(_("Warehouse required for stock Item {0}").format(d["item_code"]))
|
||||
|
||||
def update_current_stock(self):
|
||||
if self.get("_action") and self._action != "update_after_submit":
|
||||
for d in self.get("items"):
|
||||
d.actual_qty = frappe.db.get_value(
|
||||
"Bin", {"item_code": d.item_code, "warehouse": d.warehouse}, "actual_qty"
|
||||
)
|
||||
if not (self.get("_action") and self._action != "update_after_submit"):
|
||||
return
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_qty = frappe.db.get_value(
|
||||
"Bin",
|
||||
{"item_code": d.item_code, "warehouse": d.warehouse},
|
||||
["actual_qty", "projected_qty"],
|
||||
as_dict=True,
|
||||
)
|
||||
if bin_qty:
|
||||
d.actual_qty = flt(bin_qty.actual_qty)
|
||||
d.projected_qty = flt(bin_qty.projected_qty)
|
||||
warehouse_item_codes = {}
|
||||
for d in self.get("items") + self.get("packed_items"):
|
||||
warehouse_item_codes.setdefault(d.warehouse, set()).add(d.item_code)
|
||||
|
||||
if not warehouse_item_codes:
|
||||
return
|
||||
|
||||
bin_map = {}
|
||||
for warehouse, item_codes in warehouse_item_codes.items():
|
||||
for b in frappe.get_all(
|
||||
"Bin",
|
||||
filters={"item_code": ["in", item_codes], "warehouse": warehouse},
|
||||
fields=["item_code", "actual_qty", "projected_qty"],
|
||||
):
|
||||
bin_map[(b.item_code, warehouse)] = b
|
||||
|
||||
for d in self.get("items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
d.actual_qty = bin_data.actual_qty if bin_data else None
|
||||
|
||||
for d in self.get("packed_items"):
|
||||
bin_data = bin_map.get((d.item_code, d.warehouse))
|
||||
if bin_data:
|
||||
d.actual_qty = flt(bin_data.actual_qty)
|
||||
d.projected_qty = flt(bin_data.projected_qty)
|
||||
|
||||
def on_submit(self):
|
||||
self.validate_packed_qty()
|
||||
|
||||
@@ -145,6 +145,7 @@
|
||||
"ignore_user_permissions": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Variant Of",
|
||||
"link_filters": "[[\"Item\",\"has_variants\",\"=\",1]]",
|
||||
"options": "Item",
|
||||
"read_only": 1,
|
||||
"search_index": 1,
|
||||
@@ -897,7 +898,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-03-17 20:39:05.218344",
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -443,6 +443,100 @@ class TestItem(FrappeTestCase):
|
||||
"Large",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_code(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1)
|
||||
|
||||
variant = create_variant("_Test Variant Item", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item-L"))
|
||||
self.assertTrue(frappe.db.exists("Item", "_Test Variant Item-LRG"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item-LRG", "item_name"),
|
||||
"_Test Variant Item-LRG",
|
||||
)
|
||||
|
||||
def test_rename_attribute_abbr_updates_variant_item_name_from_template_name(self):
|
||||
# item_name can be derived from the template's item_name, which may differ from its
|
||||
# item_code (e.g. a friendly display name vs. a SKU-style code). The variant's item_name
|
||||
# must follow the abbreviation rename the same way item_code does.
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-L", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1)
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1)
|
||||
|
||||
template = frappe.get_doc("Item", "_Test Variant Item").as_dict()
|
||||
template = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "_Test Variant Item Diff",
|
||||
"item_name": "Test Variant Friendly Name",
|
||||
"item_group": template.item_group,
|
||||
"stock_uom": template.stock_uom,
|
||||
"has_variants": 1,
|
||||
"attributes": [{"attribute": "Test Size"}],
|
||||
}
|
||||
)
|
||||
template.insert()
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff", force=1))
|
||||
|
||||
variant = create_variant("_Test Variant Item Diff", {"Test Size": "Large"})
|
||||
variant.save()
|
||||
self.assertEqual(variant.item_code, "_Test Variant Item Diff-L")
|
||||
self.assertEqual(variant.item_name, "Test Variant Friendly Name-L")
|
||||
|
||||
# even a manually customized item_name (unrelated to the auto-generated pattern) must be
|
||||
# rebuilt on abbreviation rename, since item_code and item_name are meant to stay in lockstep.
|
||||
frappe.db.set_value("Item", variant.name, "item_name", "Custom Friendly Large Shirt Name")
|
||||
|
||||
attribute = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in attribute.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "LRG"
|
||||
break
|
||||
|
||||
def restore_test_size_abbr():
|
||||
doc = frappe.get_doc("Item Attribute", "Test Size")
|
||||
for row in doc.item_attribute_values:
|
||||
if row.attribute_value == "Large":
|
||||
row.abbr = "L"
|
||||
break
|
||||
frappe.flags.attribute_values = None
|
||||
doc.save()
|
||||
|
||||
self.addCleanup(restore_test_size_abbr)
|
||||
self.addCleanup(lambda: frappe.delete_doc_if_exists("Item", "_Test Variant Item Diff-LRG", force=1))
|
||||
|
||||
frappe.flags.attribute_values = None
|
||||
attribute.save()
|
||||
|
||||
self.assertFalse(frappe.db.exists("Item", "_Test Variant Item Diff-L"))
|
||||
self.assertEqual(
|
||||
frappe.db.get_value("Item", "_Test Variant Item Diff-LRG", "item_name"),
|
||||
"Test Variant Friendly Name-LRG",
|
||||
)
|
||||
|
||||
def test_make_item_variant(self):
|
||||
frappe.delete_doc_if_exists("Item", "_Test Variant Item-L", force=1)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from frappe.utils import flt
|
||||
from erpnext.controllers.item_variant import (
|
||||
InvalidItemAttributeValueError,
|
||||
update_variant_attribute_values,
|
||||
update_variant_item_codes_for_abbr_renames,
|
||||
validate_is_incremental,
|
||||
validate_item_attribute_value,
|
||||
)
|
||||
@@ -49,6 +50,7 @@ class ItemAttribute(Document):
|
||||
|
||||
def on_update(self):
|
||||
update_variant_attribute_values(self)
|
||||
update_variant_item_codes_for_abbr_renames(self)
|
||||
self.validate_exising_items()
|
||||
self.set_enabled_disabled_in_items()
|
||||
|
||||
|
||||
@@ -246,11 +246,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
<<<<<<< HEAD
|
||||
"modified": "2025-10-03 18:36:52.282355",
|
||||
=======
|
||||
"modified": "2026-07-06 18:17:18.000000",
|
||||
>>>>>>> af495ed253 (feat(stock): support partial transfer from pick list)
|
||||
"modified": "2026-07-10 11:39:13.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
@@ -1445,6 +1445,9 @@ def map_pl_locations(pick_list, item_mapper, delivery_note, sales_order=None):
|
||||
if location.sales_order != sales_order or location.product_bundle_item:
|
||||
continue
|
||||
|
||||
if flt(location.picked_qty) - flt(location.delivered_qty) <= 0:
|
||||
continue
|
||||
|
||||
if location.sales_order_item:
|
||||
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
|
||||
else:
|
||||
|
||||
@@ -10,16 +10,11 @@ from erpnext.selling.doctype.sales_order.sales_order import create_pick_list
|
||||
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
|
||||
from erpnext.stock.doctype.item.test_item import create_item, make_item
|
||||
from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
|
||||
<<<<<<< HEAD
|
||||
from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note, create_dn_for_pick_lists
|
||||
=======
|
||||
from erpnext.stock.doctype.pick_list.pick_list import (
|
||||
create_delivery,
|
||||
create_delivery_note,
|
||||
create_dn_for_pick_lists,
|
||||
create_stock_entry,
|
||||
)
|
||||
>>>>>>> 6ecbe6fd4b (test(stock): add test for partial transfer status from pick list)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_batch_from_bundle,
|
||||
@@ -1083,6 +1078,45 @@ class TestPickList(FrappeTestCase):
|
||||
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
|
||||
self.assertEqual(pick_list.status, "Partially Transferred")
|
||||
|
||||
def test_create_second_delivery_note_with_fully_delivered_location(self):
|
||||
# When one pick list item is fully delivered by the first Delivery Note
|
||||
# and another item is still pending, creating a second Delivery Note from
|
||||
# the Pick List must not create a zero-qty row for the delivered item.
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item_a = make_item(properties={"is_stock_item": 1}).name
|
||||
item_b = make_item(properties={"is_stock_item": 1}).name
|
||||
make_stock_entry(item=item_a, to_warehouse=warehouse, qty=20)
|
||||
make_stock_entry(item=item_b, to_warehouse=warehouse, qty=20)
|
||||
|
||||
so = make_sales_order(
|
||||
item_list=[
|
||||
{"item_code": item_a, "warehouse": warehouse, "qty": 10, "rate": 100},
|
||||
{"item_code": item_b, "warehouse": warehouse, "qty": 5, "rate": 100},
|
||||
]
|
||||
)
|
||||
|
||||
pl = create_pick_list(so.name)
|
||||
pl.save().submit()
|
||||
|
||||
# First Delivery Note: fully deliver item_a, drop item_b.
|
||||
dn1 = create_delivery_note(pl.name)
|
||||
for row in list(dn1.items):
|
||||
if row.item_code == item_b:
|
||||
dn1.remove(row)
|
||||
dn1.save().submit()
|
||||
|
||||
pl.reload()
|
||||
delivered = {loc.item_code: loc.delivered_qty for loc in pl.locations}
|
||||
self.assertEqual(delivered[item_a], 10)
|
||||
self.assertEqual(delivered[item_b], 0)
|
||||
|
||||
# Second Delivery Note for the remaining item must succeed and must not
|
||||
# include a zero-qty row for the already delivered item_a.
|
||||
dn2 = create_delivery_note(pl.name)
|
||||
self.assertEqual(len(dn2.items), 1)
|
||||
self.assertEqual(dn2.items[0].item_code, item_b)
|
||||
self.assertEqual(dn2.items[0].qty, 5)
|
||||
|
||||
def test_pick_list_validation(self):
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
item = make_item("Test Non Serialized Pick List Item", properties={"is_stock_item": 1}).name
|
||||
|
||||
@@ -26,6 +26,7 @@ from frappe.utils import (
|
||||
)
|
||||
from frappe.utils.csvutils import build_csv_response
|
||||
|
||||
from erpnext.stock.doctype.purchase_receipt_item.purchase_receipt_item import PurchaseReceiptItem
|
||||
from erpnext.stock.serial_batch_bundle import (
|
||||
BatchNoValuation,
|
||||
SerialNoValuation,
|
||||
@@ -2092,9 +2093,14 @@ def get_reference_serial_and_batch_bundle(child_row):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_serial_batch_ledgers(entries, child_row, doc, warehouse, do_not_save=False) -> object:
|
||||
if isinstance(child_row, str):
|
||||
child_row = frappe._dict(parse_json(child_row))
|
||||
def add_serial_batch_ledgers(
|
||||
entries: list | str,
|
||||
child_row: PurchaseReceiptItem | dict | str,
|
||||
doc: Document | dict | str,
|
||||
warehouse: str | None = None,
|
||||
do_not_save: bool = False,
|
||||
):
|
||||
child_row = parse_json(child_row)
|
||||
|
||||
if isinstance(entries, str):
|
||||
entries = parse_json(entries)
|
||||
@@ -2126,7 +2132,9 @@ def create_serial_batch_no_ledgers(
|
||||
if parent_doc.get("doctype") == "Stock Entry":
|
||||
warehouse = warehouse or child_row.s_warehouse or child_row.t_warehouse
|
||||
|
||||
posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
|
||||
posting_datetime = combine_datetime(
|
||||
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
|
||||
)
|
||||
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
@@ -2243,7 +2251,9 @@ def update_serial_batch_no_ledgers(bundle, entries, child_row, parent_doc, wareh
|
||||
)
|
||||
|
||||
doc.voucher_detail_no = child_row.name
|
||||
doc.posting_datetime = combine_datetime(parent_doc.get("posting_date"), parent_doc.get("posting_time"))
|
||||
doc.posting_datetime = combine_datetime(
|
||||
parent_doc.get("posting_date") or today(), parent_doc.get("posting_time") or nowtime()
|
||||
)
|
||||
|
||||
doc.warehouse = warehouse or doc.warehouse
|
||||
doc.set("entries", [])
|
||||
|
||||
@@ -528,11 +528,7 @@ class StockEntry(StockController):
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.update_subcontract_order_supplied_items()
|
||||
self.update_subcontracting_order_status()
|
||||
<<<<<<< HEAD
|
||||
=======
|
||||
self.update_pick_list_status()
|
||||
self.cancel_stock_reserve_for_wip_and_fg()
|
||||
>>>>>>> af495ed253 (feat(stock): support partial transfer from pick list)
|
||||
|
||||
if self.work_order and self.purpose == "Material Consumption for Manufacture":
|
||||
self.validate_work_order_status()
|
||||
|
||||
@@ -186,6 +186,10 @@ def get_item_warehouse_projected_qty(items_to_consider):
|
||||
item_warehouse_projected_qty = {}
|
||||
items_to_consider = list(items_to_consider.keys())
|
||||
|
||||
warehouse_parent_map = frappe._dict(
|
||||
frappe.get_all("Warehouse", fields=["name", "parent_warehouse"], as_list=True)
|
||||
)
|
||||
|
||||
for item_code, warehouse, projected_qty in frappe.db.sql(
|
||||
"""select item_code, warehouse, projected_qty
|
||||
from tabBin where item_code in ({})
|
||||
@@ -200,16 +204,14 @@ def get_item_warehouse_projected_qty(items_to_consider):
|
||||
if warehouse not in item_warehouse_projected_qty.get(item_code):
|
||||
item_warehouse_projected_qty[item_code][warehouse] = flt(projected_qty)
|
||||
|
||||
warehouse_doc = frappe.get_doc("Warehouse", warehouse)
|
||||
parent_warehouse = warehouse_parent_map.get(warehouse)
|
||||
|
||||
while warehouse_doc.parent_warehouse:
|
||||
if not item_warehouse_projected_qty.get(item_code, {}).get(warehouse_doc.parent_warehouse):
|
||||
item_warehouse_projected_qty.setdefault(item_code, {})[warehouse_doc.parent_warehouse] = flt(
|
||||
projected_qty
|
||||
)
|
||||
while parent_warehouse:
|
||||
if not item_warehouse_projected_qty.get(item_code, {}).get(parent_warehouse):
|
||||
item_warehouse_projected_qty.setdefault(item_code, {})[parent_warehouse] = flt(projected_qty)
|
||||
else:
|
||||
item_warehouse_projected_qty[item_code][warehouse_doc.parent_warehouse] += flt(projected_qty)
|
||||
warehouse_doc = frappe.get_doc("Warehouse", warehouse_doc.parent_warehouse)
|
||||
item_warehouse_projected_qty[item_code][parent_warehouse] += flt(projected_qty)
|
||||
parent_warehouse = warehouse_parent_map.get(parent_warehouse)
|
||||
|
||||
return item_warehouse_projected_qty
|
||||
|
||||
|
||||
@@ -287,6 +287,7 @@ class FIFOSlots:
|
||||
self.serial_no_details = {}
|
||||
self.batch_no_details = {}
|
||||
self.batchwise_valuation_by_batch = {}
|
||||
self.valuation_method_by_item = {}
|
||||
self.filters = filters
|
||||
self.sle = sle
|
||||
|
||||
@@ -307,9 +308,10 @@ class FIFOSlots:
|
||||
self.prepare_stock_reco_voucher_wise_count()
|
||||
|
||||
if stock_ledger_entries is None:
|
||||
# nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags must be resolved beforehand
|
||||
# streaming path: nested queries invalidate the streaming cursor below,
|
||||
# so batchwise valuation flags and item valuation methods must be resolved beforehand
|
||||
self._prefetch_batchwise_valuations()
|
||||
self._prefetch_valuation_methods()
|
||||
|
||||
with frappe.db.unbuffered_cursor():
|
||||
if stock_ledger_entries is None:
|
||||
@@ -321,12 +323,28 @@ class FIFOSlots:
|
||||
# Note that stock_ledger_entries is an iterator, you can not reuse it like a list
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
self.item_details = self._aggregate_details_by_item(self.item_details)
|
||||
|
||||
return self.item_details
|
||||
|
||||
def _recompute_moving_average_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_serial_no") or item_dict.get("has_batch_no"):
|
||||
continue
|
||||
|
||||
details = item_dict["details"]
|
||||
if self._get_item_valuation_method(details.name) != "Moving Average":
|
||||
continue
|
||||
|
||||
rate = flt(details.valuation_rate)
|
||||
for slot in item_dict["fifo_queue"]:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
@@ -347,7 +365,10 @@ class FIFOSlots:
|
||||
if row.actual_qty > 0:
|
||||
self._compute_incoming_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
else:
|
||||
self._compute_outgoing_stock(row, fifo_queue, transferred_item_key, serial_nos, batch_nos)
|
||||
from_end = self._get_item_valuation_method(row.name) == "LIFO"
|
||||
self._compute_outgoing_stock(
|
||||
row, fifo_queue, transferred_item_key, serial_nos, batch_nos, from_end
|
||||
)
|
||||
|
||||
self._update_balances(row, key)
|
||||
self._trim_serial_fifo_queue(row, key, fifo_queue)
|
||||
@@ -460,6 +481,43 @@ class FIFOSlots:
|
||||
for batch_no, use_batchwise_valuation in query.run():
|
||||
self.batchwise_valuation_by_batch[batch_no] = use_batchwise_valuation
|
||||
|
||||
def _get_item_valuation_method(self, item_code: str) -> str:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
if item_code not in self.valuation_method_by_item:
|
||||
# only reachable when stock ledger entries are passed in directly;
|
||||
# the streaming path prefetches all methods before iteration
|
||||
self.valuation_method_by_item[item_code] = get_valuation_method(item_code)
|
||||
|
||||
return self.valuation_method_by_item[item_code]
|
||||
|
||||
def _prefetch_valuation_methods(self) -> None:
|
||||
from erpnext.stock.utils import get_valuation_method
|
||||
|
||||
company = self.filters.get("company")
|
||||
sle = frappe.qb.DocType("Stock Ledger Entry")
|
||||
item = frappe.qb.DocType("Item")
|
||||
to_date = get_datetime(self.filters.get("to_date") + " 23:59:59")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(sle)
|
||||
.inner_join(item)
|
||||
.on(sle.item_code == item.name)
|
||||
.select(item.name, item.valuation_method)
|
||||
.distinct()
|
||||
.where((sle.company == company) & (sle.posting_datetime <= to_date) & (sle.is_cancelled != 1))
|
||||
)
|
||||
query = self._apply_filter(query, sle, "item_code")
|
||||
|
||||
# items with no item-level method share the company/settings default; resolve it once
|
||||
default_method = None
|
||||
for item_code, valuation_method in query.run():
|
||||
if not valuation_method:
|
||||
if default_method is None:
|
||||
default_method = get_valuation_method(item_code)
|
||||
valuation_method = default_method
|
||||
self.valuation_method_by_item[item_code] = valuation_method
|
||||
|
||||
def _init_key_stores(self, row: dict) -> tuple:
|
||||
"Initialise keys and FIFO Queue."
|
||||
|
||||
@@ -576,7 +634,13 @@ class FIFOSlots:
|
||||
fifo_queue[0][FIFO_VALUE_INDEX] += flt(row.stock_value_difference)
|
||||
|
||||
def _compute_outgoing_stock(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, serial_nos: list, batch_nos: list
|
||||
self,
|
||||
row: dict,
|
||||
fifo_queue: list,
|
||||
transfer_key: tuple,
|
||||
serial_nos: list,
|
||||
batch_nos: list,
|
||||
from_end: bool = False,
|
||||
):
|
||||
"Update FIFO Queue on outward stock."
|
||||
if serial_nos:
|
||||
@@ -584,7 +648,7 @@ class FIFOSlots:
|
||||
elif batch_nos:
|
||||
self._consume_batch_fifo_slots(row, fifo_queue, transfer_key, batch_nos)
|
||||
else:
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key)
|
||||
self._consume_fifo_slots(row, fifo_queue, transfer_key, from_end)
|
||||
|
||||
def _consume_serial_fifo_slots(self, fifo_queue: list, serial_nos: list) -> None:
|
||||
fifo_queue[:] = [slot for slot in fifo_queue if slot[FIFO_QTY_INDEX] not in serial_nos]
|
||||
@@ -661,19 +725,23 @@ class FIFOSlots:
|
||||
)
|
||||
self.transferred_item_details[transfer_key].append([qty, row.posting_date, stock_value_difference])
|
||||
|
||||
def _consume_fifo_slots(self, row: dict, fifo_queue: list, transfer_key: tuple) -> None:
|
||||
def _consume_fifo_slots(
|
||||
self, row: dict, fifo_queue: list, transfer_key: tuple, from_end: bool = False
|
||||
) -> None:
|
||||
# LIFO consumes the most recent inward first, so pop from the tail instead of the head.
|
||||
index = -1 if from_end else 0
|
||||
qty_to_pop = abs(row.actual_qty)
|
||||
stock_value = abs(row.stock_value_difference)
|
||||
|
||||
while qty_to_pop:
|
||||
slot = fifo_queue[0] if fifo_queue else [0, None, 0]
|
||||
slot = fifo_queue[index] if fifo_queue else [0, None, 0]
|
||||
slot_qty = flt(slot[FIFO_QTY_INDEX])
|
||||
slot_value = flt(slot[FIFO_VALUE_INDEX])
|
||||
|
||||
if 0 < slot_qty <= qty_to_pop:
|
||||
qty_to_pop -= slot_qty
|
||||
stock_value -= slot_value
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(0))
|
||||
self.transferred_item_details[transfer_key].append(fifo_queue.pop(index))
|
||||
elif not fifo_queue:
|
||||
fifo_queue.append([-(qty_to_pop), row.posting_date, -(stock_value)])
|
||||
self.transferred_item_details[transfer_key].append(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
|
||||
@@ -67,6 +69,131 @@ class TestStockAgeing(FrappeTestCase):
|
||||
data = format_report_data(self.filters, slots, self.filters["to_date"])
|
||||
self.assertEqual(data[0][8], 40.0) # valuating for stock value between age 0-30
|
||||
|
||||
def test_moving_average_value_ties_to_stock_balance(self):
|
||||
"""For Moving Average items the queue value is re-derived as qty * rate so the
|
||||
report's stock value ties to Stock Balance, instead of stranding a residual
|
||||
from FIFO-by-qty consumption vs blended outgoing value."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=1000,
|
||||
valuation_rate=100,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=10,
|
||||
qty_after_transaction=20,
|
||||
stock_value_difference=2000,
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=10,
|
||||
stock_value_difference=(-1500),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="MA Item",
|
||||
actual_qty=(-5),
|
||||
qty_after_transaction=5,
|
||||
stock_value_difference=(-750),
|
||||
valuation_rate=150,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-04",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="004",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="Moving Average"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["MA Item"]["fifo_queue"]
|
||||
total_value = sum(slot[2] for slot in queue)
|
||||
|
||||
# Stock Balance bal_val = qty_after_transaction * valuation_rate = 5 * 150
|
||||
self.assertEqual(total_value, 750.0)
|
||||
|
||||
def test_lifo_consumes_newest_first(self):
|
||||
"""LIFO items consume the most recent inward first, so the oldest lot stays on
|
||||
hand. The remaining queue, stock value and average age must reflect the older
|
||||
stock, unlike the default FIFO which retains the newest lots."""
|
||||
sle = [
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=30,
|
||||
qty_after_transaction=30,
|
||||
stock_value_difference=30,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-01",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="001",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=20,
|
||||
qty_after_transaction=50,
|
||||
stock_value_difference=20,
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-02",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="002",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
frappe._dict(
|
||||
name="LIFO Item",
|
||||
actual_qty=(-10),
|
||||
qty_after_transaction=40,
|
||||
stock_value_difference=(-10),
|
||||
warehouse="WH 1",
|
||||
posting_date="2021-12-03",
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no="003",
|
||||
has_serial_no=False,
|
||||
serial_no=None,
|
||||
),
|
||||
]
|
||||
|
||||
with patch("erpnext.stock.utils.get_valuation_method", return_value="LIFO"):
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
|
||||
queue = slots["LIFO Item"]["fifo_queue"]
|
||||
|
||||
# newest lot (day 2) is consumed first: oldest 30 stays, newest drops 20 -> 10
|
||||
self.assertEqual(queue[0][0], 30.0)
|
||||
self.assertEqual(queue[-1][0], 10.0)
|
||||
self.assertEqual(sum(slot[0] for slot in queue), 40.0)
|
||||
self.assertEqual(sum(slot[2] for slot in queue), 40.0)
|
||||
|
||||
# average age skews older than the FIFO result (8.5) because the old lot is retained
|
||||
self.assertEqual(get_average_age(queue, self.filters["to_date"]), 8.75)
|
||||
|
||||
def test_insufficient_balance(self):
|
||||
"Reference: Case 3 in stock_ageing_fifo_logic.md (same wh)"
|
||||
sle = [
|
||||
|
||||
@@ -171,14 +171,20 @@ def get_columns(filters):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_reposting_entries(rows, company):
|
||||
def create_reposting_entries(rows: str | list, company: str):
|
||||
if isinstance(rows, str):
|
||||
rows = parse_json(rows)
|
||||
|
||||
entries = []
|
||||
|
||||
item_wh = frappe._dict()
|
||||
vouchers = [row.get("voucher_no") for row in rows]
|
||||
vouchers = [
|
||||
row.get("voucher_no")
|
||||
for row in rows
|
||||
if row.get("voucher_type") not in ["Purchase Receipt", "Purchase Invoice"]
|
||||
]
|
||||
repost_based_on_transaction(rows, company, entries)
|
||||
|
||||
sles = get_stock_ledgers(vouchers)
|
||||
for sle in sles:
|
||||
key = (sle.item_code, sle.warehouse)
|
||||
@@ -211,3 +217,39 @@ def create_reposting_entries(rows, company):
|
||||
if entries:
|
||||
entries = ", ".join(entries)
|
||||
frappe.msgprint(_("Reposting entries created: {0}").format(entries))
|
||||
|
||||
|
||||
def repost_based_on_transaction(rows, company=None, entries=None):
|
||||
if entries is None:
|
||||
entries = []
|
||||
|
||||
duplicate_vouchers = set()
|
||||
for row in rows:
|
||||
if (
|
||||
row.get("voucher_type") == "Purchase Invoice"
|
||||
and frappe.get_cached_value("Purchase Invoice", row.get("voucher_no"), "update_stock") == 0
|
||||
):
|
||||
continue
|
||||
|
||||
if row.get("voucher_type") in ["Purchase Receipt", "Purchase Invoice"]:
|
||||
voucher_key = (row.get("voucher_type"), row.get("voucher_no"))
|
||||
if voucher_key in duplicate_vouchers:
|
||||
continue
|
||||
|
||||
duplicate_vouchers.add(voucher_key)
|
||||
doc = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Transaction",
|
||||
"status": "Queued",
|
||||
"voucher_type": row.get("voucher_type"),
|
||||
"voucher_no": row.get("voucher_no"),
|
||||
"posting_date": row.get("posting_date"),
|
||||
"posting_time": row.get("posting_time"),
|
||||
"company": company,
|
||||
"allow_nagative_stock": 1,
|
||||
"recalculate_valuation_rate": 1,
|
||||
}
|
||||
).submit()
|
||||
|
||||
entries.append(get_link_to_form("Repost Item Valuation", doc.name))
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
from frappe.tests.utils import FrappeTestCase
|
||||
from frappe.utils import today
|
||||
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.stock.report.stock_and_account_value_comparison.stock_and_account_value_comparison import (
|
||||
create_reposting_entries,
|
||||
execute,
|
||||
)
|
||||
|
||||
PI_COMPANY = "_Test Company with perpetual inventory"
|
||||
PI_STORES = "Stores - TCP1"
|
||||
|
||||
|
||||
class TestStockAndAccountValueComparison(FrappeTestCase):
|
||||
def test_purchase_voucher_reposted_transaction_based(self):
|
||||
# A Purchase Receipt whose GL entries are missing must surface in the report and, when reposted
|
||||
# from it, be reposted Transaction-based (so its own GL is regenerated) rather than the slower
|
||||
# Item-and-Warehouse based reposting.
|
||||
item = make_item(properties={"is_stock_item": 1, "valuation_method": "FIFO"}).name
|
||||
|
||||
pr = make_purchase_receipt(item_code=item, company=PI_COMPANY, warehouse=PI_STORES, qty=5, rate=100)
|
||||
|
||||
# Simulate the out-of-sync state: stock ledger exists but the accounting ledger does not.
|
||||
frappe.db.delete("GL Entry", {"voucher_type": "Purchase Receipt", "voucher_no": pr.name})
|
||||
|
||||
# The receipt now shows up in the comparison report (stock value 500 vs account value 0).
|
||||
filters = frappe._dict(company=PI_COMPANY, as_on_date=today())
|
||||
_columns, data = execute(filters)
|
||||
|
||||
row = next((d for d in data if d.get("voucher_no") == pr.name), None)
|
||||
self.assertIsNotNone(row, "Out-of-sync Purchase Receipt should appear in the report")
|
||||
self.assertEqual(row.get("voucher_type"), "Purchase Receipt")
|
||||
|
||||
# Repost from the report.
|
||||
create_reposting_entries([row], PI_COMPANY)
|
||||
|
||||
# A Transaction-based Repost Item Valuation must have been created for this voucher...
|
||||
transaction_rivs = frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"voucher_no": pr.name, "voucher_type": "Purchase Receipt"},
|
||||
fields=["name", "based_on"],
|
||||
)
|
||||
|
||||
self.assertTrue(transaction_rivs, "Expected a Repost Item Valuation for the Purchase Receipt")
|
||||
self.assertTrue(all(riv.based_on == "Transaction" for riv in transaction_rivs))
|
||||
|
||||
# ...and no Item-and-Warehouse based reposting should have been created for this item.
|
||||
item_wh_rivs = frappe.get_all(
|
||||
"Repost Item Valuation",
|
||||
filters={"based_on": "Item and Warehouse", "item_code": item},
|
||||
)
|
||||
self.assertFalse(item_wh_rivs, "Purchase vouchers must not be reposted Item-and-Warehouse based")
|
||||
Reference in New Issue
Block a user