mirror of
https://github.com/frappe/erpnext.git
synced 2026-08-20 01:39:57 +00:00
Merge branch 'version-16-hotfix' into mergify/bp/version-16-hotfix/pr-56964
This commit is contained in:
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.25.0"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -406,8 +406,7 @@
|
||||
"Customer Deposits": {
|
||||
"account_number": "2500",
|
||||
"is_group": 0,
|
||||
"root_type": "Liability",
|
||||
"account_type": "Payable"
|
||||
"root_type": "Liability"
|
||||
}
|
||||
},
|
||||
"Non Current Liabilities": {
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Closing Balance",
|
||||
"non_negative": 1,
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
@@ -191,7 +190,7 @@
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-08 17:55:25.615942",
|
||||
"modified": "2026-07-09 17:55:25.615942",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import Log",
|
||||
|
||||
@@ -557,7 +557,7 @@ class BankStatementImportLog(Document):
|
||||
docname=self.name,
|
||||
)
|
||||
|
||||
if self.closing_balance and self.closing_balance > 0 and self.end_date:
|
||||
if self.closing_balance is not None and self.end_date:
|
||||
set_closing_balance_as_per_statement(
|
||||
self.bank_account, frappe.utils.getdate(self.end_date), self.closing_balance
|
||||
)
|
||||
|
||||
@@ -24,15 +24,22 @@ frappe.ui.form.on("Opening Invoice Creation Tool", {
|
||||
setTimeout(
|
||||
() => {
|
||||
frm.doc.import_in_progress = false;
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
frm.page.clear_indicator();
|
||||
frm.dashboard.hide_progress();
|
||||
|
||||
if (frm.doc.invoice_type == "Sales") {
|
||||
frappe.msgprint(__("Opening Sales Invoices have been created."));
|
||||
if (!data.errors) {
|
||||
frm.clear_table("invoices");
|
||||
frm.refresh_fields();
|
||||
const message =
|
||||
frm.doc.invoice_type == "Sales"
|
||||
? __("Opening Sales Invoice(s) have been created.")
|
||||
: __("Opening Purchase Invoice(s) have been created.");
|
||||
frappe.show_alert({
|
||||
message: message,
|
||||
indicator: "green",
|
||||
});
|
||||
} else {
|
||||
frappe.msgprint(__("Opening Purchase Invoices have been created."));
|
||||
frm.refresh_fields();
|
||||
}
|
||||
},
|
||||
1500,
|
||||
|
||||
@@ -281,12 +281,20 @@ class OpeningInvoiceCreationTool(Document):
|
||||
def start_import(invoices):
|
||||
errors = 0
|
||||
names = []
|
||||
total = len(invoices)
|
||||
for idx, d in enumerate(invoices):
|
||||
# Scope each invoice to a savepoint so a failure only undoes that invoice.
|
||||
# A plain rollback() would discard the whole transaction — including invoices
|
||||
# imported earlier in this batch and the error logs of earlier failures (the
|
||||
# latter only survive on mariadb because the Error Log table is MyISAM; on
|
||||
# postgres they would be lost). Rolling back to a savepoint keeps both.
|
||||
savepoint = f"opening_invoice_{frappe.generate_hash(length=8)}"
|
||||
frappe.db.savepoint(savepoint)
|
||||
is_last = idx == total - 1
|
||||
try:
|
||||
invoice_number = None
|
||||
if d.invoice_number:
|
||||
invoice_number = d.invoice_number
|
||||
publish(idx, len(invoices), d.doctype)
|
||||
doc = frappe.get_doc(d)
|
||||
doc.flags.ignore_mandatory = True
|
||||
doc.insert(set_name=invoice_number)
|
||||
@@ -294,10 +302,12 @@ def start_import(invoices):
|
||||
if not frappe.in_test:
|
||||
frappe.db.commit()
|
||||
names.append(doc.name)
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
except Exception:
|
||||
errors += 1
|
||||
frappe.db.rollback()
|
||||
doc.log_error("Opening invoice creation failed")
|
||||
publish(idx, total, d.doctype, errors=errors if is_last else None)
|
||||
if errors:
|
||||
frappe.msgprint(
|
||||
_("You had {} errors while creating opening invoices. Check {} for more details").format(
|
||||
@@ -309,7 +319,7 @@ def start_import(invoices):
|
||||
return names
|
||||
|
||||
|
||||
def publish(index, total, doctype):
|
||||
def publish(index, total, doctype, errors=None):
|
||||
frappe.publish_realtime(
|
||||
"opening_invoice_creation_progress",
|
||||
dict(
|
||||
@@ -317,6 +327,7 @@ def publish(index, total, doctype):
|
||||
message=_("Creating {} out of {} {}").format(index + 1, total, doctype),
|
||||
count=index + 1,
|
||||
total=total,
|
||||
errors=errors,
|
||||
),
|
||||
user=frappe.session.user,
|
||||
)
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"in_list_view": 1,
|
||||
"label": "Outstanding Amount",
|
||||
"options": "Company:company:default_currency",
|
||||
"reqd": 1
|
||||
},
|
||||
{
|
||||
@@ -136,7 +137,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-04-29 17:08:15.617047",
|
||||
"modified": "2026-07-02 15:17:11.938499",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool Item",
|
||||
|
||||
@@ -6,8 +6,10 @@ import frappe
|
||||
from frappe import _, msgprint, qb
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.meta import get_field_precision
|
||||
from frappe.permissions import get_allowed_docs_for_doctype, get_user_permissions
|
||||
from frappe.query_builder import Case, Criterion
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import IfNull
|
||||
from frappe.utils import flt, fmt_money, get_link_to_form, getdate, nowdate, today
|
||||
|
||||
import erpnext
|
||||
@@ -74,6 +76,10 @@ class PaymentReconciliation(Document):
|
||||
self.ple_posting_date_filter = []
|
||||
self.dimensions = get_dimensions(with_cost_center_and_project=True)[0]
|
||||
|
||||
@property
|
||||
def user_permissions(self):
|
||||
return get_user_permissions(frappe.session.user)
|
||||
|
||||
def load_from_db(self):
|
||||
# 'modified' attribute is required for `run_doc_method` to work properly.
|
||||
doc_dict = frappe._dict(
|
||||
@@ -153,6 +159,22 @@ class PaymentReconciliation(Document):
|
||||
|
||||
self.add_payment_entries(non_reconciled_payments)
|
||||
|
||||
def get_permitted_dimension_values(self, document_type, reference_doctype):
|
||||
return get_allowed_docs_for_doctype(self.user_permissions.get(document_type, []), reference_doctype)
|
||||
|
||||
def validate_permitted_dimension_value(self, document_type, value, allowed):
|
||||
if value and allowed and value not in allowed:
|
||||
frappe.throw(
|
||||
_("You do not have enough permission to access {0}: {1}").format(_(document_type), value),
|
||||
frappe.PermissionError,
|
||||
)
|
||||
|
||||
def get_user_permission_dimension_condition(self, field, allowed):
|
||||
value_condition = field.isin(allowed)
|
||||
if frappe.get_system_settings("apply_strict_user_permissions"):
|
||||
return value_condition
|
||||
return (IfNull(field, "") == "") | value_condition
|
||||
|
||||
def get_payment_entries(self):
|
||||
party_account = [self.receivable_payable_account]
|
||||
|
||||
@@ -176,8 +198,13 @@ class PaymentReconciliation(Document):
|
||||
dimensions = {}
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
dimensions.update({dimension: self.get(dimension)})
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
dimensions[dimension] = value
|
||||
elif allowed:
|
||||
dimensions[dimension] = allowed
|
||||
|
||||
condition.update({"accounting_dimensions": dimensions})
|
||||
|
||||
payment_entries = get_advance_payment_entries_for_regional(
|
||||
@@ -201,8 +228,12 @@ class PaymentReconciliation(Document):
|
||||
# Dimension filters
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension):
|
||||
conditions.append(jea[dimension] == self.get(dimension))
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Journal Entry Account")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
conditions.append(jea[dimension] == value)
|
||||
elif allowed:
|
||||
conditions.append(self.get_user_permission_dimension_condition(jea[dimension], allowed))
|
||||
|
||||
if self.payment_name:
|
||||
conditions.append(je.name.like(f"%%{self.payment_name}%%"))
|
||||
@@ -746,8 +777,15 @@ class PaymentReconciliation(Document):
|
||||
ple = qb.DocType("Payment Ledger Entry")
|
||||
for x in self.dimensions:
|
||||
dimension = x.fieldname
|
||||
if self.get(dimension) and frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == self.get(dimension))
|
||||
if frappe.db.has_column("Payment Ledger Entry", dimension):
|
||||
allowed = self.get_permitted_dimension_values(x.document_type, "Payment Ledger Entry")
|
||||
if value := self.get(dimension):
|
||||
self.validate_permitted_dimension_value(x.document_type, value, allowed)
|
||||
self.accounting_dimension_filter_conditions.append(ple[dimension] == value)
|
||||
elif allowed:
|
||||
self.accounting_dimension_filter_conditions.append(
|
||||
self.get_user_permission_dimension_condition(ple[dimension], allowed)
|
||||
)
|
||||
|
||||
def build_qb_filter_conditions(self, get_invoices=False, get_return_invoices=False):
|
||||
self.common_filter_conditions.clear()
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import qb
|
||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||
from frappe.utils import add_days, add_years, cint, flt, getdate, nowdate, today
|
||||
from frappe.utils.data import getdate as convert_to_date
|
||||
|
||||
from erpnext import get_default_cost_center
|
||||
@@ -1106,6 +1106,101 @@ class TestPaymentReconciliation(ERPNextTestSuite):
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertCountEqual(payment_vouchers, [je2.name, pe2.name])
|
||||
|
||||
def test_user_permission_on_accounting_dimension_filters_vouchers(self):
|
||||
test_user = "test@example.com"
|
||||
permitted_ccs = ["_Test Cost Center - _TC", "_Test Cost Center 2 - _TC"]
|
||||
restricted_cc = "_Test Write Off Cost Center - _TC"
|
||||
existing_apply_strict_user_permissions = cint(
|
||||
frappe.db.get_single_value("System Settings", "apply_strict_user_permissions")
|
||||
)
|
||||
self.addCleanup(
|
||||
frappe.db.set_single_value,
|
||||
"System Settings",
|
||||
"apply_strict_user_permissions",
|
||||
existing_apply_strict_user_permissions,
|
||||
)
|
||||
transaction_date = nowdate()
|
||||
rate = 100
|
||||
|
||||
def make_invoice(cost_center):
|
||||
si = self.create_sales_invoice(
|
||||
qty=1, rate=rate, posting_date=transaction_date, do_not_submit=True
|
||||
)
|
||||
si.cost_center = cost_center
|
||||
for row in si.items:
|
||||
row.cost_center = cost_center
|
||||
return si.submit()
|
||||
|
||||
def make_payment(cost_center):
|
||||
pe = self.create_payment_entry(posting_date=transaction_date, amount=rate)
|
||||
pe.cost_center = cost_center
|
||||
return pe.save().submit()
|
||||
|
||||
def make_journal(cost_center):
|
||||
je = self.create_journal_entry(
|
||||
self.bank, self.debit_to, 100, transaction_date, cost_center=cost_center
|
||||
)
|
||||
je.accounts[1].party_type = "Customer"
|
||||
je.accounts[1].party = self.customer
|
||||
return je.save().submit()
|
||||
|
||||
# Vouchers tagged with the two permitted cost centers
|
||||
si_allowed = make_invoice(permitted_ccs[0])
|
||||
pe_allowed = make_payment(permitted_ccs[1])
|
||||
je_allowed = make_journal(permitted_ccs[0])
|
||||
|
||||
# Vouchers tagged with the restricted cost center
|
||||
si_restricted = make_invoice(restricted_cc)
|
||||
pe_restricted = make_payment(restricted_cc)
|
||||
je_restricted = make_journal(restricted_cc)
|
||||
|
||||
# Payment entry with a BLANK cost center
|
||||
pe_blank = make_payment(None)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.add_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
# Without strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 0)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# With strict user permissions
|
||||
frappe.db.set_single_value("System Settings", "apply_strict_user_permissions", 1)
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.get_unreconciled_entries()
|
||||
|
||||
invoice_numbers = [x.get("invoice_number") for x in pr.get("invoices")]
|
||||
payment_vouchers = [x.get("reference_name") for x in pr.get("payments")]
|
||||
self.assertIn(si_allowed.name, invoice_numbers)
|
||||
self.assertIn(pe_allowed.name, payment_vouchers)
|
||||
self.assertIn(je_allowed.name, payment_vouchers)
|
||||
self.assertNotIn(pe_blank.name, payment_vouchers)
|
||||
self.assertNotIn(si_restricted.name, invoice_numbers)
|
||||
self.assertNotIn(pe_restricted.name, payment_vouchers)
|
||||
self.assertNotIn(je_restricted.name, payment_vouchers)
|
||||
|
||||
# with restricted dimension as a filter
|
||||
with self.set_user(test_user):
|
||||
pr = self.create_payment_reconciliation()
|
||||
pr.cost_center = restricted_cc
|
||||
self.assertRaises(frappe.PermissionError, pr.get_unreconciled_entries)
|
||||
|
||||
for cc in permitted_ccs:
|
||||
frappe.permissions.remove_user_permission("Cost Center", cc, test_user)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings",
|
||||
{
|
||||
|
||||
@@ -379,6 +379,7 @@ class PaymentRequest(Document):
|
||||
bank_amount=bank_amount,
|
||||
created_from_payment_request=True,
|
||||
)
|
||||
payment_entry.set_missing_ref_details(force=True)
|
||||
|
||||
payment_entry.update(
|
||||
{
|
||||
|
||||
@@ -775,6 +775,22 @@ class TestPaymentRequest(ERPNextTestSuite):
|
||||
pi.load_from_db()
|
||||
self.assertEqual(pr_2.grand_total, pi.outstanding_amount)
|
||||
|
||||
def test_payment_entry_reference_details_fetched_from_invoice(self):
|
||||
pi = make_purchase_invoice(currency="INR", qty=1, rate=94500)
|
||||
pi.submit()
|
||||
|
||||
pr = make_payment_request(dt="Purchase Invoice", dn=pi.name, mute_email=1, submit_doc=0, return_doc=1)
|
||||
pr.grand_total = 94000
|
||||
pr.submit()
|
||||
|
||||
pe = pr.create_payment_entry(submit=False)
|
||||
|
||||
self.assertEqual(pe.references[0].reference_name, pi.name)
|
||||
self.assertEqual(pe.references[0].total_amount, pi.grand_total)
|
||||
self.assertEqual(pe.references[0].outstanding_amount, pi.outstanding_amount)
|
||||
self.assertEqual(pe.references[0].allocated_amount, 94000)
|
||||
self.assertEqual(pe.paid_amount, 94000)
|
||||
|
||||
def test_consider_journal_entry_and_return_invoice(self):
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from frappe.contacts.doctype.address.address import get_address_display
|
||||
from frappe.model.workflow import get_workflow_name
|
||||
from frappe.query_builder import Criterion, DocType
|
||||
from frappe.query_builder.custom import ConstantColumn
|
||||
from frappe.query_builder.functions import Abs, Sum
|
||||
from frappe.query_builder.functions import Abs, IfNull, Sum
|
||||
from frappe.utils import (
|
||||
add_days,
|
||||
add_months,
|
||||
@@ -3511,8 +3511,18 @@ def get_common_query(
|
||||
common_filter_conditions.append(payment_entry.cost_center == condition["cost_center"])
|
||||
|
||||
if condition.get("accounting_dimensions"):
|
||||
apply_strict_user_permissions = frappe.get_system_settings("apply_strict_user_permissions")
|
||||
for field, val in condition.get("accounting_dimensions").items():
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
if isinstance(val, list | tuple | set):
|
||||
value_condition = payment_entry[field].isin(val)
|
||||
if apply_strict_user_permissions:
|
||||
common_filter_conditions.append(value_condition)
|
||||
else:
|
||||
common_filter_conditions.append(
|
||||
(IfNull(payment_entry[field], "") == "") | value_condition
|
||||
)
|
||||
else:
|
||||
common_filter_conditions.append(payment_entry[field] == val)
|
||||
|
||||
if condition.get("minimum_payment_amount"):
|
||||
common_filter_conditions.append(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -166,7 +166,8 @@ status_map = {
|
||||
"Pick List": [
|
||||
["Draft", None],
|
||||
["Open", "eval:self.docstatus == 1"],
|
||||
["Completed", "stock_entry_exists"],
|
||||
["Completed", "is_fully_transferred"],
|
||||
["Partially Transferred", "is_partially_transferred"],
|
||||
[
|
||||
"Partly Delivered",
|
||||
"eval:self.purpose == 'Delivery' and self.delivery_status == 'Partly Delivered'",
|
||||
|
||||
@@ -2060,6 +2060,7 @@ class StockController(AccountsController):
|
||||
def show_accounting_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company, include_dimensions=1)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_gl_preview")
|
||||
|
||||
gl_columns, gl_data = get_accounting_ledger_preview(doc, filters)
|
||||
@@ -2073,6 +2074,7 @@ def show_accounting_ledger_preview(company, doctype, docname):
|
||||
def show_stock_ledger_preview(company, doctype, docname):
|
||||
filters = frappe._dict(company=company)
|
||||
doc = frappe.get_lazy_doc(doctype, docname)
|
||||
doc.check_permission("read")
|
||||
doc.run_method("before_sl_preview")
|
||||
|
||||
sl_columns, sl_data = get_stock_ledger_preview(doc, filters)
|
||||
|
||||
77
erpnext/controllers/tests/test_stock_controller.py
Normal file
77
erpnext/controllers/tests/test_stock_controller.py
Normal file
@@ -0,0 +1,77 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||
from erpnext.controllers.stock_controller import (
|
||||
show_accounting_ledger_preview,
|
||||
show_stock_ledger_preview,
|
||||
)
|
||||
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestLedgerPreviewPermission(ERPNextTestSuite):
|
||||
def test_accounting_ledger_preview_requires_read_permission(self):
|
||||
company = "_Test Company"
|
||||
je = make_journal_entry("_Test Cash - _TC", "_Test Bank - _TC", 100, submit=True)
|
||||
|
||||
email = "ledger_preview_no_role@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "No Role",
|
||||
"user_type": "Website User",
|
||||
"send_welcome_email": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
try:
|
||||
frappe.set_user(email)
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
show_accounting_ledger_preview,
|
||||
company,
|
||||
"Journal Entry",
|
||||
je.name,
|
||||
)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
# a permitted user is still able to read the preview
|
||||
accounting_ledger_result = show_accounting_ledger_preview(company, "Journal Entry", je.name)
|
||||
self.assertTrue(accounting_ledger_result.get("gl_data"))
|
||||
|
||||
def test_stock_ledger_preview_requires_read_permission(self):
|
||||
company = "_Test Company"
|
||||
pr = make_purchase_receipt()
|
||||
|
||||
email = "ledger_preview_no_role@example.com"
|
||||
if not frappe.db.exists("User", email):
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"email": email,
|
||||
"first_name": "No Role",
|
||||
"user_type": "Website User",
|
||||
"send_welcome_email": 0,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
try:
|
||||
frappe.set_user(email)
|
||||
self.assertRaises(
|
||||
frappe.PermissionError,
|
||||
show_stock_ledger_preview,
|
||||
company,
|
||||
"Purchase Receipt",
|
||||
pr.name,
|
||||
)
|
||||
finally:
|
||||
frappe.set_user("Administrator")
|
||||
|
||||
stock_ledger_result = show_stock_ledger_preview(company, "Purchase Receipt", pr.name)
|
||||
self.assertTrue(stock_ledger_result.get("sl_data"))
|
||||
@@ -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 []
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
// For license information, please see license.txt
|
||||
|
||||
frappe.ui.form.on("CRM Settings", {
|
||||
// refresh: function(frm) {
|
||||
// }
|
||||
refresh: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
enable_frappe_crm_data_synchronization: function (frm) {
|
||||
const flag = frm.events.calculate_visiblity_flag(frm);
|
||||
|
||||
if (flag) {
|
||||
frappe.show_alert(
|
||||
__("Allowed Users is required for data synchronization from remote Frappe CRM site.")
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
make allowed_users field visible and mandatory if enable_frappe_crm_data_synchronization
|
||||
is set and crm app is not installed.
|
||||
*/
|
||||
|
||||
frm.set_df_property("allowed_users", "hidden", !flag);
|
||||
frm.set_df_property("allowed_users", "reqd", flag);
|
||||
},
|
||||
|
||||
calculate_visiblity_flag: function (frm) {
|
||||
const crm_sync_enabled = frm.doc.enable_frappe_crm_data_synchronization;
|
||||
const is_crm_installed = cint(frappe.utils.get_installed_apps().includes("crm"));
|
||||
|
||||
return crm_sync_enabled && !is_crm_installed;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -120,9 +120,9 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.enable_frappe_crm_data_synchronization === 1;",
|
||||
"fieldname": "allowed_users",
|
||||
"fieldtype": "Table MultiSelect",
|
||||
"hidden": 1,
|
||||
"label": "Allowed Users",
|
||||
"options": "Frappe CRM Allowed User",
|
||||
"permlevel": 1
|
||||
@@ -140,7 +140,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-22 01:26:13.474915",
|
||||
"modified": "2026-07-01 01:09:16.461470",
|
||||
"modified_by": "Administrator",
|
||||
"module": "CRM",
|
||||
"name": "CRM Settings",
|
||||
|
||||
@@ -6,6 +6,8 @@ from frappe import _
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
from frappe.model.document import Document
|
||||
|
||||
from erpnext.crm.frappe_crm_api import is_crm_installed
|
||||
|
||||
|
||||
class CRMSettings(Document):
|
||||
# begin: auto-generated types
|
||||
@@ -46,13 +48,16 @@ class CRMSettings(Document):
|
||||
)
|
||||
|
||||
def validate_allowed_users(self):
|
||||
if self.enable_frappe_crm_data_synchronization and not self.allowed_users:
|
||||
if self.enable_frappe_crm_data_synchronization and not (is_crm_installed() or self.allowed_users):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Please add atleast one user on Allowed Users to allow Data Synchronization from Frappe CRM site."
|
||||
)
|
||||
)
|
||||
|
||||
if self.enable_frappe_crm_data_synchronization and is_crm_installed() and self.allowed_users:
|
||||
frappe.throw(_("Allowed Users is not required as Frappe CRM is already installed on the site."))
|
||||
|
||||
def before_save(self):
|
||||
self.clear_allowed_users()
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
|
||||
import click
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
@@ -150,7 +151,9 @@ def create_customer(customer_data=None):
|
||||
for field in CUSTOMER_ALLOWED_FIELDS:
|
||||
if customer_data.get(field) is not None:
|
||||
customer.set(field, customer_data.get(field))
|
||||
customer.insert(ignore_permissions=True)
|
||||
|
||||
# If CRM is installed on the site, User Permission cannot be ignored while saving Customer Records.
|
||||
customer.insert(ignore_permissions=not is_crm_installed())
|
||||
customer_name = customer.name
|
||||
|
||||
contacts = json.loads(customer_data.get("contacts"))
|
||||
@@ -169,6 +172,10 @@ def validate_frappe_crm_sync():
|
||||
_("Frappe CRM data synchronization is not enabled on ERPNext. Contact System Manager of ERPNext.")
|
||||
)
|
||||
|
||||
# Skip allowed_users validation if CRM is installed on the site.
|
||||
if is_crm_installed():
|
||||
return
|
||||
|
||||
allowed_users = [d.user for d in CRMSettings.allowed_users]
|
||||
|
||||
if frappe.session.user not in allowed_users:
|
||||
@@ -178,3 +185,35 @@ def validate_frappe_crm_sync():
|
||||
),
|
||||
exc=frappe.PermissionError,
|
||||
)
|
||||
|
||||
|
||||
def is_crm_installed():
|
||||
return "crm" in frappe.get_installed_apps()
|
||||
|
||||
|
||||
def remove_allowed_users_on_crm_install():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.allowed_users = []
|
||||
CRMSettings.save()
|
||||
click.secho("Removed 'Allowed Users' from CRM Settings.")
|
||||
except Exception:
|
||||
click.secho("'Allowed Users' from CRM Settings couldn't be cleared.")
|
||||
|
||||
|
||||
def disable_frappe_crm_data_synchronization_on_crm_uninstall():
|
||||
try:
|
||||
CRMSettings = frappe.get_single("CRM Settings")
|
||||
|
||||
if not CRMSettings.enable_frappe_crm_data_synchronization:
|
||||
return
|
||||
|
||||
CRMSettings.enable_frappe_crm_data_synchronization = 0
|
||||
CRMSettings.save()
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings has been disabled.")
|
||||
except Exception:
|
||||
click.secho("'Enable Frappe CRM Data Synchronization' on CRM Settings could not be disabled.")
|
||||
|
||||
@@ -189,6 +189,7 @@ def get_filtered_todos(ref_doctype, ref_docname, status: str | tuple[str, str]):
|
||||
"allocated_to",
|
||||
"date",
|
||||
],
|
||||
order_by="date asc",
|
||||
)
|
||||
|
||||
|
||||
@@ -218,6 +219,7 @@ def get_filtered_events(ref_doctype, ref_docname, open: bool):
|
||||
& (event_link.reference_docname == ref_docname)
|
||||
& (event_status_filter)
|
||||
)
|
||||
.orderby(event.starts_on)
|
||||
)
|
||||
data = query.run(as_dict=True)
|
||||
|
||||
|
||||
@@ -65,6 +65,9 @@ setup_wizard_stages = "erpnext.setup.setup_wizard.setup_wizard.get_setup_stages"
|
||||
|
||||
after_install = "erpnext.setup.install.after_install"
|
||||
|
||||
after_app_install = "erpnext.setup.install.after_app_install"
|
||||
after_app_uninstall = "erpnext.setup.install.after_app_uninstall"
|
||||
|
||||
boot_session = "erpnext.startup.boot.boot_session"
|
||||
notification_config = "erpnext.startup.notifications.get_notification_config"
|
||||
get_help_messages = "erpnext.utilities.activation.get_help_messages"
|
||||
|
||||
1907
erpnext/locale/af.po
1907
erpnext/locale/af.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/ar.po
1911
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/bg.po
1907
erpnext/locale/bg.po
File diff suppressed because it is too large
Load Diff
2059
erpnext/locale/bs.po
2059
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/cs.po
1907
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/da.po
1907
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/de.po
1913
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
2039
erpnext/locale/eo.po
2039
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/es.po
1911
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
1935
erpnext/locale/fa.po
1935
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/fi.po
1907
erpnext/locale/fi.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/fr.po
1913
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
1915
erpnext/locale/hi.po
1915
erpnext/locale/hi.po
File diff suppressed because it is too large
Load Diff
2055
erpnext/locale/hr.po
2055
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/hu.po
1911
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/id.po
1913
erpnext/locale/id.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/it.po
1913
erpnext/locale/it.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/ko.po
1907
erpnext/locale/ko.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/my.po
1907
erpnext/locale/my.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/nb.po
1907
erpnext/locale/nb.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/nl.po
1911
erpnext/locale/nl.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/pl.po
1911
erpnext/locale/pl.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/pt.po
1913
erpnext/locale/pt.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/ru.po
1913
erpnext/locale/ru.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/sl.po
1907
erpnext/locale/sl.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/sr.po
1913
erpnext/locale/sr.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
2171
erpnext/locale/sv.po
2171
erpnext/locale/sv.po
File diff suppressed because it is too large
Load Diff
1907
erpnext/locale/ta.po
1907
erpnext/locale/ta.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/th.po
1913
erpnext/locale/th.po
File diff suppressed because it is too large
Load Diff
1911
erpnext/locale/tr.po
1911
erpnext/locale/tr.po
File diff suppressed because it is too large
Load Diff
20399
erpnext/locale/uz.po
20399
erpnext/locale/uz.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/vi.po
1913
erpnext/locale/vi.po
File diff suppressed because it is too large
Load Diff
1913
erpnext/locale/zh.po
1913
erpnext/locale/zh.po
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -586,7 +586,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",
|
||||
|
||||
@@ -662,6 +662,48 @@ class TestJobCard(ERPNextTestSuite):
|
||||
self.assertEqual(ste.from_bom, 1.0)
|
||||
self.assertEqual(ste.bom_no, work_order.bom_no)
|
||||
|
||||
def test_job_card_material_transfer_via_pick_list(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import create_pick_list
|
||||
from erpnext.stock.doctype.pick_list.pick_list import (
|
||||
create_stock_entry as create_stock_entry_from_pick_list,
|
||||
)
|
||||
|
||||
create_bom_with_multiple_operations()
|
||||
work_order = make_wo_with_transfer_against_jc()
|
||||
|
||||
for item in work_order.required_items:
|
||||
make_stock_entry(
|
||||
item_code=item.item_code,
|
||||
target=item.source_warehouse,
|
||||
qty=item.required_qty * 2,
|
||||
basic_rate=100,
|
||||
)
|
||||
|
||||
job_card_name = frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
|
||||
job_card = frappe.get_doc("Job Card", job_card_name)
|
||||
|
||||
mr = make_material_request(job_card_name)
|
||||
mr.schedule_date = today()
|
||||
mr.submit()
|
||||
|
||||
pick_list = create_pick_list(mr.name)
|
||||
pick_list.submit()
|
||||
|
||||
ste = frappe.get_doc(create_stock_entry_from_pick_list(pick_list.as_dict()))
|
||||
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
|
||||
self.assertEqual(ste.job_card, job_card_name)
|
||||
self.assertEqual(ste.work_order, work_order.name)
|
||||
self.assertEqual(ste.fg_completed_qty, job_card.for_quantity)
|
||||
for row in ste.items:
|
||||
self.assertEqual(row.t_warehouse, job_card.wip_warehouse)
|
||||
self.assertTrue(row.job_card_item)
|
||||
|
||||
ste.insert()
|
||||
ste.submit()
|
||||
|
||||
job_card.reload()
|
||||
self.assertEqual(job_card.transferred_qty, job_card.for_quantity)
|
||||
|
||||
def test_job_card_proccess_qty_and_completed_qty(self):
|
||||
from erpnext.manufacturing.doctype.routing.test_routing import (
|
||||
create_routing,
|
||||
|
||||
@@ -18,6 +18,7 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
|
||||
StockOverProductionError,
|
||||
close_work_order,
|
||||
make_job_card,
|
||||
make_material_request,
|
||||
make_stock_entry,
|
||||
make_stock_return_entry,
|
||||
stop_unstop,
|
||||
@@ -1547,6 +1548,97 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
work_order.reload()
|
||||
self.assertEqual(work_order.material_transferred_for_manufacturing, 2.0)
|
||||
|
||||
def test_work_order_material_request_and_bom_details(self):
|
||||
from erpnext.stock.doctype.material_request.material_request import (
|
||||
make_stock_entry as mr_to_stock_entry,
|
||||
)
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
planned_start_date=now(), qty=2, source_warehouse="Stores - _TC"
|
||||
)
|
||||
|
||||
mr = make_material_request(work_order.name)
|
||||
mr.schedule_date = today()
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.submit()
|
||||
self.assertEqual(mr.work_order, work_order.name)
|
||||
|
||||
ste = mr_to_stock_entry(mr.name)
|
||||
self.assertEqual(ste.purpose, "Material Transfer for Manufacture")
|
||||
self.assertEqual(ste.work_order, work_order.name)
|
||||
self.assertEqual(ste.from_bom, 1.0)
|
||||
self.assertEqual(ste.bom_no, work_order.bom_no)
|
||||
self.assertEqual(ste.fg_completed_qty, 0.0)
|
||||
|
||||
def test_status_in_process_when_only_one_required_item_transferred_via_material_request(self):
|
||||
"""Same bottleneck scenario as the Pick List flow, but the intermediate document is a
|
||||
Material Request created directly from the Work Order: 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.stock.doctype.material_request.material_request import (
|
||||
make_stock_entry as mr_to_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
|
||||
)
|
||||
|
||||
mr = make_material_request(work_order.name)
|
||||
mr.schedule_date = today()
|
||||
# request only _Test Item; the other required item is left off this material request
|
||||
mr.items = [item for item in mr.items if item.item_code == "_Test Item"]
|
||||
for item in mr.items:
|
||||
item.schedule_date = today()
|
||||
mr.submit()
|
||||
|
||||
stock_entry = frappe.get_doc(mr_to_stock_entry(mr.name))
|
||||
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_status_in_process_when_only_one_required_item_transferred_via_pick_list(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",
|
||||
|
||||
@@ -813,6 +813,10 @@ erpnext.work_order = {
|
||||
erpnext.work_order.create_pick_list(frm);
|
||||
});
|
||||
|
||||
frm.add_custom_button(__("Material Request"), function () {
|
||||
erpnext.work_order.make_material_request(frm);
|
||||
});
|
||||
|
||||
var start_btn = frm.add_custom_button(__("Start"), function () {
|
||||
erpnext.work_order.make_se(frm, "Material Transfer for Manufacture");
|
||||
});
|
||||
@@ -1151,6 +1155,13 @@ erpnext.work_order = {
|
||||
}
|
||||
},
|
||||
|
||||
make_material_request: function (frm) {
|
||||
frappe.model.open_mapped_doc({
|
||||
method: "erpnext.manufacturing.doctype.work_order.work_order.make_material_request",
|
||||
frm,
|
||||
});
|
||||
},
|
||||
|
||||
create_pick_list: function (frm, purpose = "Material Transfer for Manufacture") {
|
||||
const max = this.get_max_transferable_qty(frm, purpose);
|
||||
|
||||
|
||||
@@ -295,6 +295,10 @@ class WorkOrder(Document):
|
||||
self.validate_subcontracting_inward_order()
|
||||
|
||||
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"))
|
||||
|
||||
if self.actual_start_date and self.actual_end_date:
|
||||
if self.actual_end_date < self.actual_start_date:
|
||||
frappe.throw(_("Actual End Date cannot be before Actual Start Date"))
|
||||
@@ -677,7 +681,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")
|
||||
@@ -711,6 +719,57 @@ class WorkOrder(Document):
|
||||
|
||||
return status
|
||||
|
||||
def _has_transferred_material(self):
|
||||
"""True if any raw material transferred against this work order via a pick list or a
|
||||
material request is still, net of returns, in WIP (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")
|
||||
mr_ste = frappe.qb.DocType("Stock Entry")
|
||||
mr_child = frappe.qb.DocType("Stock Entry Detail")
|
||||
# Stock Entry only carries `material_request` at the child-row level, so a Stock
|
||||
# Entry is "MR-sourced" if *any* of its rows link back to a Material Request against
|
||||
# this work order; the join to mr_ste keeps this scoped to this work order's entries
|
||||
# instead of scanning every Material-Request-linked row in the system.
|
||||
mr_sourced_stock_entries = (
|
||||
frappe.qb.from_(mr_child)
|
||||
.inner_join(mr_ste)
|
||||
.on(mr_ste.name == mr_child.parent)
|
||||
.select(mr_child.parent)
|
||||
.where(
|
||||
(mr_child.material_request.isnotnull())
|
||||
& (mr_ste.work_order == self.name)
|
||||
& (mr_ste.docstatus == 1)
|
||||
& (mr_ste.purpose == "Material Transfer for Manufacture")
|
||||
)
|
||||
)
|
||||
common_filters = (
|
||||
(ste.work_order == self.name)
|
||||
& (ste.docstatus == 1)
|
||||
& (ste.purpose == "Material Transfer for Manufacture")
|
||||
)
|
||||
transferred_qty = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(
|
||||
common_filters
|
||||
& (ste.is_return == 0)
|
||||
& (ste.pick_list.isnotnull() | ste.name.isin(mr_sourced_stock_entries))
|
||||
)
|
||||
).run()[0][0]
|
||||
# Returns don't carry their own pick_list/material_request reference, so net every
|
||||
# return against this work order to correctly clear WIP after a full return.
|
||||
returned_qty = (
|
||||
frappe.qb.from_(ste)
|
||||
.inner_join(ste_child)
|
||||
.on(ste_child.parent == ste.name)
|
||||
.select(Sum(ste_child.transfer_qty))
|
||||
.where(common_filters & (ste.is_return == 1))
|
||||
).run()[0][0]
|
||||
return flt(transferred_qty) - flt(returned_qty) > 0
|
||||
|
||||
def update_work_order_qty(self):
|
||||
"""Update **Manufactured Qty** and **Material Transferred for Qty** in Work Order
|
||||
based on Stock Entry"""
|
||||
@@ -3014,6 +3073,40 @@ def get_reserved_qty_for_production(
|
||||
return query.run()[0][0] or 0.0
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_material_request(source_name: str, target_doc: str | dict | None = None):
|
||||
frappe.has_permission("Material Request", "create", throw=True)
|
||||
|
||||
doc = get_mapped_doc("Work Order", source_name, _material_request_mapping(), target_doc)
|
||||
doc.material_request_type = "Material Transfer"
|
||||
return doc
|
||||
|
||||
|
||||
def _material_request_mapping():
|
||||
return {
|
||||
"Work Order": {
|
||||
"doctype": "Material Request",
|
||||
"validation": {"docstatus": ["=", 1]},
|
||||
"field_map": {"name": "work_order"},
|
||||
},
|
||||
"Work Order Item": {
|
||||
"doctype": "Material Request Item",
|
||||
"field_map": [
|
||||
("stock_uom", "uom"),
|
||||
("source_warehouse", "from_warehouse"),
|
||||
],
|
||||
"postprocess": _set_material_request_item,
|
||||
"condition": lambda doc: abs(doc.transferred_qty) < abs(doc.required_qty),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _set_material_request_item(source, target, source_parent):
|
||||
target.warehouse = source_parent.wip_warehouse
|
||||
target.qty = flt(source.required_qty) - flt(source.transferred_qty)
|
||||
target.schedule_date = nowdate()
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_return_entry(work_order):
|
||||
from erpnext.stock.doctype.stock_entry.stock_entry import get_available_materials
|
||||
|
||||
@@ -260,7 +260,6 @@ execute:frappe.rename_doc("Report", "TDS Payable Monthly", "Tax Withholding Deta
|
||||
erpnext.patches.v14_0.update_proprietorship_to_individual
|
||||
erpnext.patches.v15_0.rename_subcontracting_fields
|
||||
erpnext.patches.v15_0.unset_incorrect_additional_discount_percentage
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
|
||||
[post_model_sync]
|
||||
erpnext.patches.v15_0.rename_gross_purchase_amount_to_net_purchase_amount
|
||||
@@ -439,6 +438,7 @@ erpnext.patches.v16_0.set_reporting_currency
|
||||
erpnext.patches.v16_0.set_posting_datetime_for_sabb_and_drop_indexes
|
||||
erpnext.patches.v16_0.update_serial_no_reference_name
|
||||
erpnext.patches.v16_0.update_account_categories_for_existing_accounts
|
||||
erpnext.patches.v16_0.create_company_custom_fields
|
||||
erpnext.patches.v16_0.rename_subcontracted_quantity
|
||||
erpnext.patches.v16_0.add_new_stock_entry_types
|
||||
erpnext.patches.v15_0.set_asset_status_if_not_already_set
|
||||
@@ -488,3 +488,5 @@ execute:frappe.db.set_single_value("Accounts Settings", "pcv_job_timeout", 3600)
|
||||
erpnext.patches.v16_0.remove_mandatory_from_inv_dimension_fields
|
||||
erpnext.patches.v15_0.backfill_sla_link_filters_on_custom_field
|
||||
erpnext.patches.v15_0.backfill_sla_link_filters_on_docfield
|
||||
erpnext.patches.v16_0.crm_settings_handle_allowed_users_for_frappe_crm
|
||||
erpnext.patches.v16_0.backfill_pick_list_transferred_qty
|
||||
|
||||
58
erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py
Normal file
58
erpnext/patches/v16_0/backfill_pick_list_transferred_qty.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import frappe
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import flt
|
||||
|
||||
|
||||
def execute():
|
||||
StockEntry = frappe.qb.DocType("Stock Entry")
|
||||
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
|
||||
|
||||
pick_lists = (
|
||||
frappe.qb.from_(StockEntry)
|
||||
.select(StockEntry.pick_list)
|
||||
.distinct()
|
||||
.where((StockEntry.pick_list.isnotnull()) & (StockEntry.docstatus == 1))
|
||||
).run(pluck=True)
|
||||
|
||||
if not pick_lists:
|
||||
return
|
||||
|
||||
rows = (
|
||||
frappe.qb.from_(StockEntryDetail)
|
||||
.join(StockEntry)
|
||||
.on(StockEntryDetail.parent == StockEntry.name)
|
||||
.select(
|
||||
StockEntry.pick_list,
|
||||
StockEntryDetail.item_code,
|
||||
StockEntryDetail.s_warehouse,
|
||||
Sum(StockEntryDetail.transfer_qty).as_("qty"),
|
||||
)
|
||||
.where((StockEntry.pick_list.isin(pick_lists)) & (StockEntry.docstatus == 1))
|
||||
.groupby(StockEntry.pick_list, StockEntryDetail.item_code, StockEntryDetail.s_warehouse)
|
||||
).run(as_dict=True)
|
||||
|
||||
transferred = {(r.pick_list, r.item_code, r.s_warehouse): flt(r.qty) for r in rows}
|
||||
|
||||
items = frappe.get_all(
|
||||
"Pick List Item",
|
||||
filters={"parent": ("in", pick_lists), "picked_qty": (">", 0)},
|
||||
fields=["name", "parent", "item_code", "warehouse", "picked_qty"],
|
||||
order_by="idx",
|
||||
)
|
||||
|
||||
updates = {}
|
||||
for row in items:
|
||||
key = (row.parent, row.item_code, row.warehouse)
|
||||
available = transferred.get(key, 0)
|
||||
if available <= 0:
|
||||
continue
|
||||
qty = min(flt(row.picked_qty), available)
|
||||
transferred[key] = available - qty
|
||||
updates[row.name] = {"transferred_qty": qty}
|
||||
|
||||
if not updates:
|
||||
return
|
||||
|
||||
frappe.db.auto_commit_on_many_writes = True
|
||||
frappe.db.bulk_update("Pick List Item", updates)
|
||||
frappe.db.auto_commit_on_many_writes = False
|
||||
@@ -0,0 +1,10 @@
|
||||
import frappe
|
||||
|
||||
|
||||
def execute():
|
||||
from erpnext.crm.frappe_crm_api import is_crm_installed, remove_allowed_users_on_crm_install
|
||||
|
||||
if not is_crm_installed():
|
||||
return
|
||||
|
||||
remove_allowed_users_on_crm_install()
|
||||
@@ -28,8 +28,8 @@ erpnext.financial_statements = {
|
||||
},
|
||||
|
||||
is_blank_row: function (data) {
|
||||
if (!data || data.segment_values) return false;
|
||||
return (
|
||||
data &&
|
||||
!data.account &&
|
||||
!data.accounts &&
|
||||
!data.child_accounts &&
|
||||
|
||||
@@ -893,13 +893,15 @@
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"description": "Applying a Discount Amount? When this Sales Order is partially fulfilled through multiple Delivery Notes and Sales Invoices, the Discount Amount is allocated on a FIFO basis. The earlier transactions receive a larger share of the discount. To spread the discount proportionally across item prices, use Additional Discount Percentage instead.",
|
||||
"fieldname": "discount_amount",
|
||||
"fieldtype": "Currency",
|
||||
"hide_days": 1,
|
||||
"hide_seconds": 1,
|
||||
"label": "Additional Discount Amount",
|
||||
"options": "currency",
|
||||
"print_hide": 1
|
||||
"print_hide": 1,
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "base_grand_total",
|
||||
@@ -1760,7 +1762,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 11:41:11.823034",
|
||||
"modified": "2026-06-24 12:00:00.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Selling",
|
||||
"name": "Sales Order",
|
||||
|
||||
@@ -183,8 +183,22 @@ def get_entries(filters):
|
||||
.as_("contribution_amt")
|
||||
)
|
||||
|
||||
# Only pass valid document-field filters to get_query; report-specific keys such as
|
||||
# doc_type / sales_person / item_group are handled separately below.
|
||||
doc_filters = {"docstatus": 1}
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
doc_filters[field] = filters.get(field)
|
||||
|
||||
if filters.get("from_date") and filters.get("to_date"):
|
||||
doc_filters[date_field] = ["between", [filters.get("from_date"), filters.get("to_date")]]
|
||||
elif filters.get("from_date"):
|
||||
doc_filters[date_field] = [">=", filters.get("from_date")]
|
||||
elif filters.get("to_date"):
|
||||
doc_filters[date_field] = ["<=", filters.get("to_date")]
|
||||
|
||||
query = (
|
||||
frappe.get_query(dt, filters=filters, ignore_permissions=False)
|
||||
frappe.get_query(dt, filters=doc_filters, ignore_permissions=False)
|
||||
.join(dt_item)
|
||||
.on(dt.name == dt_item.parent)
|
||||
.join(st)
|
||||
@@ -203,48 +217,29 @@ def get_entries(filters):
|
||||
contribution_amt_case,
|
||||
)
|
||||
.where(st.parenttype == doc_type)
|
||||
.where(dt.docstatus == 1)
|
||||
)
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.db.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
sp = frappe.qb.DocType("Sales Person")
|
||||
query = query.where(
|
||||
st.sales_person.isin(frappe.qb.from_(sp).select(sp.name).where((sp.lft >= lft) & (sp.rgt <= rgt)))
|
||||
)
|
||||
|
||||
# only resolve items when an item_group/brand filter is set; otherwise get_items
|
||||
# would return every item in the system and add a huge IN() clause on each run
|
||||
if filters.get("item_group") or filters.get("brand"):
|
||||
items = get_items(filters)
|
||||
if not items:
|
||||
# the item_group/brand filter matched nothing -> no rows
|
||||
return []
|
||||
query = query.where(dt_item.item_code.isin([d[0] for d in items]))
|
||||
|
||||
query = query.orderby(st.sales_person).orderby(dt.name, order=frappe.qb.desc)
|
||||
|
||||
return query.run(as_dict=True)
|
||||
|
||||
|
||||
def get_conditions(filters, date_field):
|
||||
conditions = [""]
|
||||
values = []
|
||||
|
||||
for field in ["company", "customer", "territory"]:
|
||||
if filters.get(field):
|
||||
conditions.append(f"dt.{field}=%s")
|
||||
values.append(filters[field])
|
||||
|
||||
if filters.get("sales_person"):
|
||||
lft, rgt = frappe.get_value("Sales Person", filters.get("sales_person"), ["lft", "rgt"])
|
||||
conditions.append(
|
||||
f"exists(select name from `tabSales Person` where lft >= {lft} and rgt <= {rgt} and name=st.sales_person)"
|
||||
)
|
||||
|
||||
if filters.get("from_date"):
|
||||
conditions.append(f"dt.{date_field}>=%s")
|
||||
values.append(filters["from_date"])
|
||||
|
||||
if filters.get("to_date"):
|
||||
conditions.append(f"dt.{date_field}<=%s")
|
||||
values.append(filters["to_date"])
|
||||
|
||||
items = get_items(filters)
|
||||
if items:
|
||||
conditions.append("dt_item.item_code in (%s)" % ", ".join(["%s"] * len(items)))
|
||||
values += items
|
||||
else:
|
||||
# return empty result, if no items are fetched after filtering on 'item group' and 'brand'
|
||||
conditions.append("dt_item.item_code = Null")
|
||||
|
||||
return " and ".join(conditions), values
|
||||
|
||||
|
||||
def get_items(filters):
|
||||
item = qb.DocType("Item")
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Copyright (c) 2026, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||
from erpnext.selling.report.sales_person_wise_transaction_summary.sales_person_wise_transaction_summary import (
|
||||
execute,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestSalesPersonWiseTransactionSummary(ERPNextTestSuite):
|
||||
"""Item-level summary joining a sales document with its Sales Team rows, showing
|
||||
each sales person's contributed qty and amount per item line."""
|
||||
|
||||
def setUp(self):
|
||||
self.sales_person = "_Test Sales Person"
|
||||
|
||||
def make_invoice_with_commission(self, qty=5, rate=200, percentage=100):
|
||||
si = create_sales_invoice(
|
||||
item="_Test Item", qty=qty, rate=rate, do_not_save=True, posting_date="2026-06-01"
|
||||
)
|
||||
si.append("sales_team", {"sales_person": self.sales_person, "allocated_percentage": percentage})
|
||||
si.insert()
|
||||
si.submit()
|
||||
return si
|
||||
|
||||
def run_report(self, **extra):
|
||||
filters = frappe._dict(
|
||||
{"company": "_Test Company", "doc_type": "Sales Invoice", "sales_person": self.sales_person}
|
||||
)
|
||||
filters.update(extra)
|
||||
return execute(filters)[1]
|
||||
|
||||
def test_doc_type_is_mandatory(self):
|
||||
self.assertRaises(frappe.ValidationError, execute, frappe._dict({"company": "_Test Company"}))
|
||||
|
||||
def test_invalid_doc_type_throws(self):
|
||||
self.assertRaises(
|
||||
frappe.ValidationError,
|
||||
execute,
|
||||
frappe._dict({"company": "_Test Company", "doc_type": "Purchase Invoice"}),
|
||||
)
|
||||
|
||||
def test_item_line_contribution(self):
|
||||
si = self.make_invoice_with_commission(qty=5, rate=200, percentage=100)
|
||||
item = si.items[0]
|
||||
|
||||
rows = self.run_report()
|
||||
row = next((r for r in rows if r[0] == si.name and r[5] == "_Test Item"), None)
|
||||
self.assertIsNotNone(row, "Invoice item line missing from report")
|
||||
|
||||
# row: name, customer, territory, warehouse, posting_date, item_code, item_group,
|
||||
# brand, stock_qty, base_net_amount, sales_person, allocated_percentage,
|
||||
# contributed_qty, contribution_amt, currency
|
||||
self.assertEqual(row[1], si.customer)
|
||||
self.assertEqual(row[8], item.stock_qty)
|
||||
self.assertEqual(row[9], item.base_net_amount)
|
||||
self.assertEqual(row[10], self.sales_person)
|
||||
self.assertEqual(row[11], 100)
|
||||
self.assertEqual(row[12], item.stock_qty * 100 / 100) # contributed qty
|
||||
self.assertEqual(row[13], item.base_net_amount * 100 / 100) # contribution amount
|
||||
|
||||
def test_appends_total_row(self):
|
||||
self.make_invoice_with_commission()
|
||||
rows = self.run_report()
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(rows[-1], [""] * len(rows[0]))
|
||||
@@ -432,3 +432,19 @@ DEFAULT_ROLE_PROFILES = {
|
||||
"Purchase Manager",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def after_app_install(app_name=None):
|
||||
if app_name == "crm":
|
||||
from erpnext.crm.frappe_crm_api import remove_allowed_users_on_crm_install
|
||||
|
||||
remove_allowed_users_on_crm_install()
|
||||
|
||||
|
||||
def after_app_uninstall(app_name=None):
|
||||
if app_name == "crm":
|
||||
from erpnext.crm.frappe_crm_api import disable_frappe_crm_data_synchronization_on_crm_uninstall
|
||||
|
||||
disable_frappe_crm_data_synchronization_on_crm_uninstall()
|
||||
|
||||
frappe.db.commit() # nosemgrep
|
||||
|
||||
@@ -415,22 +415,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()
|
||||
|
||||
@@ -170,6 +170,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,
|
||||
@@ -1092,7 +1093,7 @@
|
||||
"image_field": "image",
|
||||
"links": [],
|
||||
"make_attachments_public": 1,
|
||||
"modified": "2026-05-27 10:18:46.862670",
|
||||
"modified": "2026-07-05 23:24:45.734144",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Item",
|
||||
|
||||
@@ -493,6 +493,100 @@ class TestItem(ERPNextTestSuite):
|
||||
"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,
|
||||
)
|
||||
@@ -46,6 +47,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()
|
||||
|
||||
|
||||
@@ -717,7 +717,7 @@ def make_supplier_quotation(source_name, target_doc=None):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_stock_entry(source_name, target_doc=None):
|
||||
def make_stock_entry(source_name: str, target_doc: str | dict | None = None):
|
||||
def update_item(obj, target, source_parent):
|
||||
qty = (
|
||||
flt(flt(obj.stock_qty) - flt(obj.ordered_qty)) / target.conversion_factor
|
||||
@@ -753,6 +753,9 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
if source.job_card:
|
||||
target.purpose = "Material Transfer for Manufacture"
|
||||
|
||||
if source.work_order:
|
||||
target.purpose = "Material Transfer for Manufacture"
|
||||
|
||||
if source.material_request_type == "Customer Provided":
|
||||
target.purpose = "Material Receipt"
|
||||
|
||||
@@ -772,6 +775,18 @@ def make_stock_entry(source_name, target_doc=None):
|
||||
target.fg_completed_qty = job_card_details[0].for_quantity
|
||||
target.from_bom = 1
|
||||
|
||||
if source.work_order:
|
||||
work_order_details = frappe.db.get_value(
|
||||
"Work Order", source.work_order, ["bom_no", "use_multi_level_bom"], as_dict=True
|
||||
)
|
||||
|
||||
if work_order_details:
|
||||
target.bom_no = work_order_details.bom_no
|
||||
target.use_multi_level_bom = work_order_details.use_multi_level_bom
|
||||
target.from_bom = 1
|
||||
# not fg-qty-driven, mirrors the Pick List -> Stock Entry transfer for this Work Order
|
||||
target.fg_completed_qty = 0
|
||||
|
||||
doclist = get_mapped_doc(
|
||||
"Material Request",
|
||||
source_name,
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
"in_standard_filter": 1,
|
||||
"label": "Status",
|
||||
"no_copy": 1,
|
||||
"options": "Draft\nOpen\nPartly Delivered\nCompleted\nCancelled",
|
||||
"options": "Draft\nOpen\nPartly Delivered\nPartially Transferred\nCompleted\nCancelled",
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1,
|
||||
@@ -278,7 +278,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-06 18:14:18.361039",
|
||||
"modified": "2026-07-06 18:17:18.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List",
|
||||
|
||||
@@ -73,7 +73,9 @@ class PickList(TransactionBase):
|
||||
purpose: DF.Literal["Material Transfer for Manufacture", "Material Transfer", "Delivery"]
|
||||
scan_barcode: DF.Data | None
|
||||
scan_mode: DF.Check
|
||||
status: DF.Literal["Draft", "Open", "Partly Delivered", "Completed", "Cancelled"]
|
||||
status: DF.Literal[
|
||||
"Draft", "Open", "Partly Delivered", "Partially Transferred", "Completed", "Cancelled"
|
||||
]
|
||||
work_order: DF.Link | None
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -419,6 +421,34 @@ class PickList(TransactionBase):
|
||||
|
||||
return stock_entry_exists(self.name)
|
||||
|
||||
def get_transfer_status(self):
|
||||
"""Return the pick list's transfer progress based on how much of the picked qty has been
|
||||
moved into submitted Stock Entries (tracked on Pick List Item.transferred_qty).
|
||||
|
||||
Only applies to purposes that move stock via Stock Entry; the Delivery purpose is tracked
|
||||
via delivery_status instead. Returns "Completed", "Partially Transferred" or None."""
|
||||
if self.purpose == "Delivery":
|
||||
return None
|
||||
|
||||
total_picked = sum(flt(row.picked_qty) for row in self.locations)
|
||||
if not total_picked:
|
||||
return None
|
||||
|
||||
total_transferred = sum(flt(row.transferred_qty) for row in self.locations)
|
||||
if total_transferred <= 0:
|
||||
return None
|
||||
|
||||
if total_transferred >= total_picked:
|
||||
return "Completed"
|
||||
|
||||
return "Partially Transferred"
|
||||
|
||||
def is_fully_transferred(self):
|
||||
return self.get_transfer_status() == "Completed"
|
||||
|
||||
def is_partially_transferred(self):
|
||||
return self.get_transfer_status() == "Partially Transferred"
|
||||
|
||||
def update_reference_qty(self):
|
||||
packed_items = []
|
||||
so_items = []
|
||||
@@ -1481,6 +1511,9 @@ def map_pl_locations(pick_list, item_mapper, target_doc, 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:
|
||||
@@ -1544,26 +1577,33 @@ def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_orde
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_entry(pick_list):
|
||||
pick_list = frappe.get_doc(json.loads(pick_list))
|
||||
def create_stock_entry(pick_list: str | dict):
|
||||
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
|
||||
validate_item_locations(pick_list)
|
||||
|
||||
if stock_entry_exists(pick_list.get("name")):
|
||||
return frappe.msgprint(_("Stock Entry has been already created against this Pick List"))
|
||||
|
||||
stock_entry = frappe.new_doc("Stock Entry")
|
||||
stock_entry.pick_list = pick_list.get("name")
|
||||
stock_entry.purpose = pick_list.get("purpose")
|
||||
stock_entry.company = pick_list.get("company")
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
if pick_list.get("work_order"):
|
||||
job_card = pick_list.get("material_request") and frappe.db.get_value(
|
||||
"Material Request", pick_list.get("material_request"), "job_card"
|
||||
)
|
||||
|
||||
if job_card:
|
||||
stock_entry = update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card)
|
||||
elif pick_list.get("work_order"):
|
||||
stock_entry = update_stock_entry_based_on_work_order(pick_list, stock_entry)
|
||||
elif pick_list.get("material_request"):
|
||||
stock_entry = update_stock_entry_based_on_material_request(pick_list, stock_entry)
|
||||
else:
|
||||
stock_entry = update_stock_entry_items_with_no_reference(pick_list, stock_entry)
|
||||
|
||||
stock_entry.set_stock_entry_type()
|
||||
|
||||
if not stock_entry.get("items"):
|
||||
return frappe.msgprint(_("All picked items have already been transferred against this Pick List"))
|
||||
|
||||
stock_entry.set_missing_values()
|
||||
|
||||
return stock_entry.as_dict()
|
||||
@@ -1651,9 +1691,67 @@ def stock_entry_exists(pick_list_name):
|
||||
return frappe.db.exists("Stock Entry", {"pick_list": pick_list_name})
|
||||
|
||||
|
||||
def update_stock_entry_based_on_job_card(pick_list, stock_entry, job_card):
|
||||
job_card = frappe.db.get_value(
|
||||
"Job Card",
|
||||
job_card,
|
||||
[
|
||||
"name",
|
||||
"work_order",
|
||||
"bom_no",
|
||||
"semi_fg_bom",
|
||||
"for_quantity",
|
||||
"transferred_qty",
|
||||
"wip_warehouse",
|
||||
"project",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
|
||||
stock_entry.purpose = "Material Transfer for Manufacture"
|
||||
stock_entry.job_card = job_card.name
|
||||
stock_entry.work_order = job_card.work_order
|
||||
stock_entry.from_bom = 1
|
||||
stock_entry.bom_no = job_card.semi_fg_bom or job_card.bom_no
|
||||
stock_entry.fg_completed_qty = max(flt(job_card.for_quantity) - flt(job_card.transferred_qty), 0)
|
||||
stock_entry.to_warehouse = job_card.wip_warehouse
|
||||
stock_entry.project = job_card.project
|
||||
|
||||
job_card_items = get_job_card_items_by_material_request_item(pick_list)
|
||||
|
||||
for location in pick_list.locations:
|
||||
if get_pending_transfer_stock_qty(location) <= 0:
|
||||
continue
|
||||
item = frappe._dict()
|
||||
update_common_item_properties(item, location)
|
||||
item.t_warehouse = job_card.wip_warehouse
|
||||
item.job_card_item = job_card_items.get(location.material_request_item)
|
||||
stock_entry.append("items", item)
|
||||
|
||||
return stock_entry
|
||||
|
||||
|
||||
def get_job_card_items_by_material_request_item(pick_list):
|
||||
material_request_items = [
|
||||
location.material_request_item for location in pick_list.locations if location.material_request_item
|
||||
]
|
||||
if not material_request_items:
|
||||
return {}
|
||||
|
||||
return dict(
|
||||
frappe.get_all(
|
||||
"Material Request Item",
|
||||
filters={"name": ["in", material_request_items]},
|
||||
fields=["name", "job_card_item"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
||||
work_order = frappe.get_doc("Work Order", pick_list.get("work_order"))
|
||||
|
||||
stock_entry.purpose = "Material Transfer for Manufacture"
|
||||
stock_entry.work_order = work_order.name
|
||||
stock_entry.company = work_order.company
|
||||
stock_entry.from_bom = 1
|
||||
@@ -1673,6 +1771,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
||||
stock_entry.project = work_order.project
|
||||
|
||||
for location in pick_list.locations:
|
||||
if get_pending_transfer_stock_qty(location) <= 0:
|
||||
continue
|
||||
item = frappe._dict()
|
||||
update_common_item_properties(item, location)
|
||||
item.t_warehouse = wip_warehouse
|
||||
@@ -1684,6 +1784,8 @@ def update_stock_entry_based_on_work_order(pick_list, stock_entry):
|
||||
|
||||
def update_stock_entry_based_on_material_request(pick_list, stock_entry):
|
||||
for location in pick_list.locations:
|
||||
if get_pending_transfer_stock_qty(location) <= 0:
|
||||
continue
|
||||
target_warehouse = None
|
||||
if location.material_request_item:
|
||||
target_warehouse = frappe.get_value(
|
||||
@@ -1699,6 +1801,8 @@ def update_stock_entry_based_on_material_request(pick_list, stock_entry):
|
||||
|
||||
def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
|
||||
for location in pick_list.locations:
|
||||
if get_pending_transfer_stock_qty(location) <= 0:
|
||||
continue
|
||||
item = frappe._dict()
|
||||
update_common_item_properties(item, location)
|
||||
|
||||
@@ -1707,11 +1811,18 @@ def update_stock_entry_items_with_no_reference(pick_list, stock_entry):
|
||||
return stock_entry
|
||||
|
||||
|
||||
def get_pending_transfer_stock_qty(location):
|
||||
"""Stock qty of this pick list row still to be moved into a Stock Entry."""
|
||||
return flt(location.picked_qty) - flt(location.transferred_qty)
|
||||
|
||||
|
||||
def update_common_item_properties(item, location):
|
||||
pending_stock_qty = get_pending_transfer_stock_qty(location)
|
||||
item.item_code = location.item_code
|
||||
item.item_name = location.item_name
|
||||
item.s_warehouse = location.warehouse
|
||||
item.transfer_qty = location.picked_qty
|
||||
item.qty = flt(location.picked_qty / (location.conversion_factor or 1), location.precision("qty"))
|
||||
item.transfer_qty = pending_stock_qty
|
||||
item.qty = flt(pending_stock_qty / (location.conversion_factor or 1), location.precision("qty"))
|
||||
item.uom = location.uom
|
||||
item.conversion_factor = location.conversion_factor
|
||||
item.stock_uom = location.stock_uom
|
||||
@@ -1719,6 +1830,7 @@ def update_common_item_properties(item, location):
|
||||
item.serial_no = location.serial_no
|
||||
item.batch_no = location.batch_no
|
||||
item.material_request_item = location.material_request_item
|
||||
item.pick_list_item = location.name
|
||||
|
||||
|
||||
def get_rejected_warehouses():
|
||||
|
||||
@@ -7,6 +7,7 @@ frappe.listview_settings["Pick List"] = {
|
||||
Draft: "red",
|
||||
Open: "orange",
|
||||
"Partly Delivered": "orange",
|
||||
"Partially Transferred": "yellow",
|
||||
Completed: "green",
|
||||
Cancelled: "red",
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ from erpnext.stock.doctype.pick_list.pick_list import (
|
||||
create_delivery,
|
||||
create_delivery_note,
|
||||
create_dn_for_pick_lists,
|
||||
create_stock_entry,
|
||||
)
|
||||
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 (
|
||||
@@ -1221,6 +1222,103 @@ class TestPickList(ERPNextTestSuite):
|
||||
pl.reload()
|
||||
self.assertEqual(pl.status, "Cancelled")
|
||||
|
||||
def test_pick_list_partial_transfer_status(self):
|
||||
"""Partial Stock Entries from a Pick List should track transferred_qty and drive the
|
||||
Partially Transferred / Completed status, and allow further transfers for the remainder."""
|
||||
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
|
||||
|
||||
item = make_item(properties={"is_stock_item": 1}).name
|
||||
source_warehouse = "_Test Warehouse - _TC"
|
||||
target_warehouse = create_warehouse("_Test Transfer Target Warehouse")
|
||||
make_stock_entry(item=item, to_warehouse=source_warehouse, qty=10)
|
||||
|
||||
pick_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pick List",
|
||||
"company": "_Test Company",
|
||||
"purpose": "Material Transfer",
|
||||
"pick_manually": 1,
|
||||
"locations": [
|
||||
{
|
||||
"item_code": item,
|
||||
"qty": 10,
|
||||
"stock_qty": 10,
|
||||
"conversion_factor": 1,
|
||||
"warehouse": source_warehouse,
|
||||
"picked_qty": 10,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
pick_list.submit()
|
||||
self.assertEqual(pick_list.status, "Open")
|
||||
|
||||
# Transfer 4 of the 10 picked units.
|
||||
se1 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
|
||||
self.assertEqual(se1.items[0].qty, 10)
|
||||
se1.items[0].qty = 4
|
||||
se1.items[0].t_warehouse = target_warehouse
|
||||
se1.submit()
|
||||
|
||||
pick_list.reload()
|
||||
self.assertEqual(pick_list.locations[0].transferred_qty, 4)
|
||||
self.assertEqual(pick_list.status, "Partially Transferred")
|
||||
|
||||
# The next Stock Entry should only offer the remaining 6 units.
|
||||
se2 = frappe.get_doc(create_stock_entry(pick_list.as_dict()))
|
||||
self.assertEqual(se2.items[0].qty, 6)
|
||||
se2.items[0].t_warehouse = target_warehouse
|
||||
se2.submit()
|
||||
|
||||
pick_list.reload()
|
||||
self.assertEqual(pick_list.locations[0].transferred_qty, 10)
|
||||
self.assertEqual(pick_list.status, "Completed")
|
||||
|
||||
# Cancelling the last entry rolls transferred_qty and status back.
|
||||
se2.cancel()
|
||||
pick_list.reload()
|
||||
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
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"conversion_factor",
|
||||
"stock_uom",
|
||||
"delivered_qty",
|
||||
"transferred_qty",
|
||||
"available_quantity_section",
|
||||
"actual_qty",
|
||||
"column_break_kyek",
|
||||
@@ -255,6 +256,16 @@
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "transferred_qty",
|
||||
"fieldtype": "Float",
|
||||
"label": "Transferred Qty (in Stock UOM)",
|
||||
"no_copy": 1,
|
||||
"print_hide": 1,
|
||||
"read_only": 1,
|
||||
"report_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "available_quantity_section",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -285,7 +296,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-03-17 16:25:10.358013",
|
||||
"modified": "2026-07-06 18:17:18.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List Item",
|
||||
|
||||
@@ -39,6 +39,7 @@ class PickListItem(Document):
|
||||
stock_qty: DF.Float
|
||||
stock_reserved_qty: DF.Float
|
||||
stock_uom: DF.Link | None
|
||||
transferred_qty: DF.Float
|
||||
uom: DF.Link | None
|
||||
use_serial_batch_fields: DF.Check
|
||||
warehouse: DF.Link | None
|
||||
|
||||
@@ -27,6 +27,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,
|
||||
@@ -2152,9 +2153,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)
|
||||
@@ -2186,7 +2192,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(
|
||||
{
|
||||
@@ -2303,7 +2311,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", [])
|
||||
|
||||
@@ -178,6 +178,15 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.status_updater = [
|
||||
{
|
||||
"source_dt": "Stock Entry Detail",
|
||||
"target_dt": "Pick List Item",
|
||||
"join_field": "pick_list_item",
|
||||
"target_field": "transferred_qty",
|
||||
"source_field": "transfer_qty",
|
||||
}
|
||||
]
|
||||
if self.purchase_order:
|
||||
self.subcontract_data = frappe._dict(
|
||||
{
|
||||
@@ -571,6 +580,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
self.validate_closed_subcontracting_order()
|
||||
self.update_subcontract_order_supplied_items()
|
||||
self.update_subcontracting_order_status()
|
||||
self.update_pick_list_status()
|
||||
self.cancel_stock_reserve_for_wip_and_fg()
|
||||
|
||||
if self.work_order and self.purpose == "Material Consumption for Manufacture":
|
||||
@@ -4054,6 +4064,9 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
def update_pick_list_status(self):
|
||||
from erpnext.stock.doctype.pick_list.pick_list import update_pick_list_status
|
||||
|
||||
if self.pick_list:
|
||||
self.update_qty()
|
||||
|
||||
update_pick_list_status(self.pick_list)
|
||||
|
||||
def set_missing_values(self):
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
"col_break6",
|
||||
"material_request",
|
||||
"material_request_item",
|
||||
"pick_list_item",
|
||||
"original_item",
|
||||
"reference_section",
|
||||
"against_stock_entry",
|
||||
@@ -423,6 +424,16 @@
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "pick_list_item",
|
||||
"fieldtype": "Link",
|
||||
"hidden": 1,
|
||||
"label": "Pick List Item",
|
||||
"no_copy": 1,
|
||||
"options": "Pick List Item",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "original_item",
|
||||
"fieldtype": "Link",
|
||||
@@ -678,7 +689,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-07-03 12:11:53.714931",
|
||||
"modified": "2026-07-06 18:17:18.000000",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry Detail",
|
||||
|
||||
@@ -47,6 +47,7 @@ class StockEntryDetail(Document):
|
||||
parent: DF.Data
|
||||
parentfield: DF.Data
|
||||
parenttype: DF.Data
|
||||
pick_list_item: DF.Link | None
|
||||
po_detail: DF.Data | None
|
||||
project: DF.Link | None
|
||||
putaway_rule: DF.Link | None
|
||||
|
||||
@@ -1615,6 +1615,12 @@ def apply_price_list(ctx, as_doc=False, doc=None):
|
||||
def apply_price_list_on_item(ctx, doc=None):
|
||||
item_doc = frappe.get_cached_doc("Item", ctx.item_code)
|
||||
item_details = get_price_list_rate(ctx, item_doc)
|
||||
|
||||
ctx.conversion_factor = flt(ctx.conversion_factor) or get_conversion_factor(ctx.item_code, ctx.uom).get(
|
||||
"conversion_factor", 1
|
||||
)
|
||||
ctx.stock_qty = flt(ctx.qty) * flt(ctx.conversion_factor)
|
||||
|
||||
item_details.update(get_pricing_rule_for_item(ctx, doc=doc))
|
||||
|
||||
return item_details
|
||||
|
||||
@@ -190,6 +190,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 ({})
|
||||
@@ -204,16 +208,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
|
||||
|
||||
|
||||
@@ -174,14 +174,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)
|
||||
@@ -214,3 +220,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.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,
|
||||
)
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
PI_COMPANY = "_Test Company with perpetual inventory"
|
||||
PI_STORES = "Stores - TCP1"
|
||||
|
||||
|
||||
class TestStockAndAccountValueComparison(ERPNextTestSuite):
|
||||
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")
|
||||
@@ -1093,7 +1093,11 @@ class update_entries_after:
|
||||
self.wh_data.stock_queue = json.loads(stock_queue[0]) if stock_queue else []
|
||||
|
||||
self.wh_data.stock_value = round_off_if_near_zero(self.wh_data.stock_value + doc.total_amount)
|
||||
self.wh_data.qty_after_transaction += flt(doc.total_qty, self.flt_precision)
|
||||
# Replay the immutable qty recorded on the SLE at submission, not the bundle's recomputed
|
||||
# total_qty. A valuation repost must never rewrite physical quantities; if the bundle's child
|
||||
# rows were edited after submission, doc.total_qty would silently corrupt qty_after_transaction
|
||||
# (and every downstream balance). sle.actual_qty is the frozen movement for this entry.
|
||||
self.wh_data.qty_after_transaction += flt(sle.actual_qty, self.flt_precision)
|
||||
if flt(self.wh_data.qty_after_transaction, self.flt_precision):
|
||||
self.wh_data.valuation_rate = flt(self.wh_data.stock_value, self.flt_precision) / flt(
|
||||
self.wh_data.qty_after_transaction, self.flt_precision
|
||||
|
||||
Reference in New Issue
Block a user