mirror of
https://github.com/frappe/erpnext.git
synced 2026-04-13 03:45:08 +00:00
Merge pull request #53700 from frappe/version-15-hotfix
This commit is contained in:
@@ -243,8 +243,10 @@ def get_other_conditions(conditions, values, args):
|
||||
if group_condition:
|
||||
conditions += " and " + group_condition
|
||||
|
||||
date = args.get("transaction_date") or frappe.get_value(
|
||||
args.get("doctype"), args.get("name"), "posting_date", ignore=True
|
||||
date = (
|
||||
args.get("transaction_date")
|
||||
or args.get("posting_date")
|
||||
or frappe.get_value(args.get("doctype"), args.get("name"), "posting_date", ignore=True)
|
||||
)
|
||||
if date:
|
||||
conditions += """ and %(transaction_date)s between ifnull(`tabPricing Rule`.valid_from, '2000-01-01')
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "bill_no",
|
||||
"collapsible_depends_on": "posting_date",
|
||||
"fieldname": "supplier_invoice_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Supplier Invoice"
|
||||
@@ -1660,7 +1660,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-02-05 20:45:16.964500",
|
||||
"modified": "2026-03-17 20:44:00.221219",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -2516,7 +2516,7 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
|
||||
"doctype": target_doctype,
|
||||
"postprocess": update_details,
|
||||
"set_target_warehouse": "set_from_warehouse",
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address"],
|
||||
"field_no_map": ["taxes_and_charges", "set_warehouse", "shipping_address", "cost_center"],
|
||||
},
|
||||
doctype + " Item": item_field_map,
|
||||
},
|
||||
|
||||
@@ -4835,6 +4835,33 @@ class TestSalesInvoice(FrappeTestCase):
|
||||
|
||||
self.assertEqual(stock_ledger_entry.incoming_rate, 0.0)
|
||||
|
||||
def test_inter_company_transaction_cost_center(self):
|
||||
si = create_sales_invoice(
|
||||
company="Wind Power LLC",
|
||||
customer="_Test Internal Customer",
|
||||
debit_to="Debtors - WP",
|
||||
warehouse="Stores - WP",
|
||||
income_account="Sales - WP",
|
||||
expense_account="Cost of Goods Sold - WP",
|
||||
parent_cost_center="Main - WP",
|
||||
cost_center="Main - WP",
|
||||
currency="USD",
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
si.selling_price_list = "_Test Price List Rest of the World"
|
||||
si.submit()
|
||||
|
||||
cost_center = frappe.db.get_value("Company", "_Test Company 1", "cost_center")
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", None)
|
||||
|
||||
target_doc = make_inter_company_transaction("Sales Invoice", si.name)
|
||||
|
||||
self.assertEqual(target_doc.cost_center, None)
|
||||
self.assertEqual(target_doc.items[0].cost_center, None)
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -152,7 +152,9 @@ class ShippingRule(Document):
|
||||
frappe.throw(_("Shipping rule only applicable for Buying"))
|
||||
|
||||
shipping_charge["doctype"] = "Purchase Taxes and Charges"
|
||||
shipping_charge["category"] = "Valuation and Total"
|
||||
shipping_charge["category"] = (
|
||||
"Valuation and Total" if doc.get_stock_items() or doc.get_asset_items() else "Total"
|
||||
)
|
||||
shipping_charge["add_deduct_tax"] = "Add"
|
||||
|
||||
existing_shipping_charge = doc.get("taxes", filters=shipping_charge)
|
||||
|
||||
@@ -8,6 +8,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import flt, formatdate
|
||||
|
||||
from erpnext.accounts.doctype.accounting_dimension.accounting_dimension import get_dimensions
|
||||
from erpnext.controllers.trends import get_period_date_ranges, get_period_month_ranges
|
||||
|
||||
|
||||
@@ -15,6 +16,8 @@ def execute(filters=None):
|
||||
if not filters:
|
||||
filters = {}
|
||||
|
||||
validate_filters(filters)
|
||||
|
||||
columns = get_columns(filters)
|
||||
if filters.get("budget_against_filter"):
|
||||
dimensions = filters.get("budget_against_filter")
|
||||
@@ -35,6 +38,21 @@ def execute(filters=None):
|
||||
return columns, data, None, chart
|
||||
|
||||
|
||||
def validate_filters(filters):
|
||||
validate_budget_dimensions(filters)
|
||||
|
||||
|
||||
def validate_budget_dimensions(filters):
|
||||
dimensions = [d.get("document_type") for d in get_dimensions(with_cost_center_and_project=True)[0]]
|
||||
if filters.get("budget_against") and filters.get("budget_against") not in dimensions:
|
||||
frappe.throw(
|
||||
title=_("Invalid Accounting Dimension"),
|
||||
msg=_("{0} is not a valid Accounting Dimension.").format(
|
||||
frappe.bold(filters.get("budget_against"))
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_final_data(dimension, dimension_items, filters, period_month_ranges, data, DCC_allocation):
|
||||
for account, monthwise_data in dimension_items.items():
|
||||
row = [dimension, account]
|
||||
|
||||
@@ -31,6 +31,7 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
|
||||
item_list = get_items(filters, additional_table_columns)
|
||||
aii_account_map = get_aii_accounts()
|
||||
default_taxes = {}
|
||||
if item_list:
|
||||
itemised_tax, tax_columns = get_tax_accounts(
|
||||
item_list,
|
||||
@@ -39,6 +40,9 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
doctype="Purchase Invoice",
|
||||
tax_doctype="Purchase Taxes and Charges",
|
||||
)
|
||||
for tax in tax_columns:
|
||||
default_taxes[f"{tax}_rate"] = 0
|
||||
default_taxes[f"{tax}_amount"] = 0
|
||||
|
||||
po_pr_map = get_purchase_receipts_against_purchase_order(item_list)
|
||||
|
||||
@@ -85,6 +89,8 @@ def _execute(filters=None, additional_table_columns=None):
|
||||
}
|
||||
|
||||
total_tax = 0
|
||||
row.update(default_taxes.copy())
|
||||
|
||||
for tax in tax_columns:
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
|
||||
@@ -29,8 +29,12 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
company_currency = frappe.get_cached_value("Company", filters.get("company"), "default_currency")
|
||||
|
||||
item_list = get_items(filters, additional_table_columns, additional_conditions)
|
||||
default_taxes = {}
|
||||
if item_list:
|
||||
itemised_tax, tax_columns = get_tax_accounts(item_list, columns, company_currency)
|
||||
for tax in tax_columns:
|
||||
default_taxes[f"{tax}_rate"] = 0
|
||||
default_taxes[f"{tax}_amount"] = 0
|
||||
|
||||
mode_of_payments = get_mode_of_payments(set(d.parent for d in item_list))
|
||||
so_dn_map = get_delivery_notes_against_sales_order(item_list)
|
||||
@@ -88,6 +92,8 @@ def _execute(filters=None, additional_table_columns=None, additional_conditions=
|
||||
|
||||
total_tax = 0
|
||||
total_other_charges = 0
|
||||
row.update(default_taxes.copy())
|
||||
|
||||
for tax in tax_columns:
|
||||
item_tax = itemised_tax.get(d.name, {}).get(tax, {})
|
||||
row.update(
|
||||
|
||||
@@ -17,9 +17,11 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
def tearDown(self):
|
||||
frappe.db.rollback()
|
||||
|
||||
def create_sales_invoice(self, do_not_submit=False):
|
||||
def create_sales_invoice(self, item=None, taxes=None, do_not_submit=False):
|
||||
si = create_sales_invoice(
|
||||
item=self.item,
|
||||
item=item or self.item,
|
||||
item_name=item or self.item,
|
||||
description=item or self.item,
|
||||
company=self.company,
|
||||
customer=self.customer,
|
||||
debit_to=self.debit_to,
|
||||
@@ -30,6 +32,19 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
price_list_rate=100,
|
||||
do_not_save=1,
|
||||
)
|
||||
|
||||
for tax in taxes or []:
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": tax["account_head"],
|
||||
"cost_center": self.cost_center,
|
||||
"description": tax["description"],
|
||||
"rate": tax["rate"],
|
||||
},
|
||||
)
|
||||
|
||||
si = si.save()
|
||||
if not do_not_submit:
|
||||
si = si.submit()
|
||||
@@ -63,3 +78,50 @@ class TestItemWiseSalesRegister(AccountsTestMixin, FrappeTestCase):
|
||||
|
||||
report_output = {k: v for k, v in report[1][0].items() if k in expected_result}
|
||||
self.assertDictEqual(report_output, expected_result)
|
||||
|
||||
def test_grouped_report_handles_different_tax_descriptions(self):
|
||||
self.create_item(item_name="_Test Item Tax Description A")
|
||||
first_item = self.item
|
||||
self.create_item(item_name="_Test Item Tax Description B")
|
||||
second_item = self.item
|
||||
|
||||
first_tax_description = "Tax Description A"
|
||||
second_tax_description = "Tax Description B"
|
||||
first_tax_amount_field = f"{frappe.scrub(first_tax_description)}_amount"
|
||||
second_tax_amount_field = f"{frappe.scrub(second_tax_description)}_amount"
|
||||
|
||||
self.create_sales_invoice(
|
||||
item=first_item,
|
||||
taxes=[
|
||||
{
|
||||
"account_head": "_Test Account VAT - _TC",
|
||||
"description": first_tax_description,
|
||||
"rate": 5,
|
||||
}
|
||||
],
|
||||
)
|
||||
self.create_sales_invoice(
|
||||
item=second_item,
|
||||
taxes=[
|
||||
{
|
||||
"account_head": "_Test Account Service Tax - _TC",
|
||||
"description": second_tax_description,
|
||||
"rate": 2,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
filters = frappe._dict(
|
||||
{
|
||||
"from_date": today(),
|
||||
"to_date": today(),
|
||||
"company": self.company,
|
||||
"group_by": "Customer",
|
||||
}
|
||||
)
|
||||
_, data, _, _, _, _ = execute(filters)
|
||||
|
||||
grand_total_row = next(row for row in data if row.get("bold") and row.get("item_code") == "Total")
|
||||
|
||||
self.assertEqual(grand_total_row[first_tax_amount_field], 5.0)
|
||||
self.assertEqual(grand_total_row[second_tax_amount_field], 2.0)
|
||||
|
||||
@@ -912,6 +912,8 @@ def get_list_context(context=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_status(status, name):
|
||||
frappe.has_permission("Purchase Order", "submit", name, throw=True)
|
||||
|
||||
po = frappe.get_doc("Purchase Order", name)
|
||||
po.update_status(status)
|
||||
po.update_delivered_qty_in_sales_order()
|
||||
|
||||
@@ -41,6 +41,7 @@ def get_columns(filters):
|
||||
"fieldname": "transferred_qty",
|
||||
"width": 200,
|
||||
},
|
||||
{"label": _("Returned Quantity"), "fieldtype": "Float", "fieldname": "returned_qty", "width": 150},
|
||||
{"label": _("Pending Quantity"), "fieldtype": "Float", "fieldname": "p_qty", "width": 150},
|
||||
]
|
||||
|
||||
@@ -50,7 +51,7 @@ def get_data(filters):
|
||||
|
||||
data = []
|
||||
for row in order_rm_item_details:
|
||||
transferred_qty = row.get("transferred_qty") or 0
|
||||
transferred_qty = (row.get("transferred_qty") or 0) - (row.get("returned_qty") or 0)
|
||||
if transferred_qty < row.get("reqd_qty", 0):
|
||||
pending_qty = frappe.utils.flt(row.get("reqd_qty", 0) - transferred_qty)
|
||||
row.p_qty = pending_qty if pending_qty > 0 else 0
|
||||
@@ -86,6 +87,7 @@ def get_order_items_to_supply(filters):
|
||||
f"`tab{supplied_items_table}`.rm_item_code as rm_item_code",
|
||||
f"`tab{supplied_items_table}`.required_qty as reqd_qty",
|
||||
f"`tab{supplied_items_table}`.supplied_qty as transferred_qty",
|
||||
f"`tab{supplied_items_table}`.returned_qty as returned_qty",
|
||||
],
|
||||
filters=record_filters,
|
||||
)
|
||||
|
||||
@@ -2297,6 +2297,16 @@ class AccountsController(TransactionBase):
|
||||
|
||||
return stock_items
|
||||
|
||||
def get_asset_items(self):
|
||||
asset_items = []
|
||||
item_codes = list(set(item.item_code for item in self.get("items")))
|
||||
if item_codes:
|
||||
asset_items = frappe.db.get_values(
|
||||
"Item", {"name": ["in", item_codes], "is_fixed_asset": 1}, pluck="name", cache=True
|
||||
)
|
||||
|
||||
return asset_items
|
||||
|
||||
def calculate_total_advance_from_ledger(self):
|
||||
adv = frappe.qb.DocType("Advance Payment Ledger Entry")
|
||||
return (
|
||||
|
||||
@@ -234,8 +234,8 @@ class StatusUpdater(Document):
|
||||
self.global_amount_allowance = None
|
||||
|
||||
for args in self.status_updater:
|
||||
if "target_ref_field" not in args:
|
||||
# if target_ref_field is not specified, the programmer does not want to validate qty / amount
|
||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
continue
|
||||
|
||||
# get unique transactions to update
|
||||
|
||||
@@ -989,6 +989,12 @@ class SubcontractingController(StockController):
|
||||
if self.doctype not in ["Purchase Invoice", "Purchase Receipt", "Subcontracting Receipt"]:
|
||||
return
|
||||
|
||||
if (
|
||||
frappe.db.get_single_value("Buying Settings", "backflush_raw_materials_of_subcontract_based_on")
|
||||
== "BOM"
|
||||
):
|
||||
return
|
||||
|
||||
for row in self.get(self.raw_material_table):
|
||||
key = (row.rm_item_code, row.main_item_code, row.get(self.subcontract_data.order_field))
|
||||
if not self.__transferred_items or not self.__transferred_items.get(key):
|
||||
|
||||
@@ -55,6 +55,14 @@ def validate_filters(filters):
|
||||
if filters.get("based_on") == filters.get("group_by"):
|
||||
frappe.throw(_("'Based On' and 'Group By' can not be same"))
|
||||
|
||||
if filters.get("period_based_on") and filters.period_based_on not in ["bill_date", "posting_date"]:
|
||||
frappe.throw(
|
||||
msg=_("{0} can be either {1} or {2}.").format(
|
||||
frappe.bold("Period based On"), frappe.bold("Posting Date"), frappe.bold("Billing Date")
|
||||
),
|
||||
title=_("Invalid Filter"),
|
||||
)
|
||||
|
||||
|
||||
def get_data(filters, conditions):
|
||||
data = []
|
||||
|
||||
@@ -192,6 +192,7 @@ frappe.ui.form.on("BOM", {
|
||||
bom_no: frm.doc.name,
|
||||
item: item,
|
||||
qty: data.qty || 0.0,
|
||||
company: frm.doc.company,
|
||||
project: frm.doc.project,
|
||||
variant_items: variant_items,
|
||||
use_multi_level_bom: use_multi_level_bom,
|
||||
|
||||
@@ -1019,6 +1019,12 @@ class BOM(WebsiteGenerator):
|
||||
"Row {0}: Workstation or Workstation Type is mandatory for an operation {1}"
|
||||
).format(d.idx, d.operation)
|
||||
)
|
||||
if not d.time_in_mins or d.time_in_mins <= 0:
|
||||
frappe.throw(
|
||||
_("Row {0}: Operation time should be greater than 0 for operation {1}").format(
|
||||
d.idx, d.operation
|
||||
)
|
||||
)
|
||||
|
||||
def get_tree_representation(self) -> BOMTree:
|
||||
"""Get a complete tree representation preserving order of child items."""
|
||||
@@ -1329,9 +1335,10 @@ def add_non_stock_items_cost(stock_entry, work_order, expense_account):
|
||||
bom = frappe.get_doc("BOM", work_order.bom_no)
|
||||
table = "exploded_items" if work_order.get("use_multi_level_bom") else "items"
|
||||
|
||||
items = {}
|
||||
items = frappe._dict()
|
||||
for d in bom.get(table):
|
||||
items.setdefault(d.item_code, d.amount)
|
||||
items.setdefault(d.item_code, 0)
|
||||
items[d.item_code] += flt(d.amount)
|
||||
|
||||
non_stock_items = frappe.get_all(
|
||||
"Item",
|
||||
|
||||
@@ -132,6 +132,15 @@ class TestBOM(FrappeTestCase):
|
||||
self.assertAlmostEqual(bom.base_raw_material_cost, base_raw_material_cost)
|
||||
self.assertAlmostEqual(bom.base_total_cost, base_raw_material_cost + base_op_cost)
|
||||
|
||||
@timeout
|
||||
def test_bom_no_operation_time_validation(self):
|
||||
bom = frappe.copy_doc(test_records[2])
|
||||
bom.docstatus = 0
|
||||
for op_row in bom.operations:
|
||||
op_row.time_in_mins = 0
|
||||
|
||||
self.assertRaises(frappe.ValidationError, bom.save)
|
||||
|
||||
@timeout
|
||||
def test_bom_cost_with_batch_size(self):
|
||||
bom = frappe.copy_doc(test_records[2])
|
||||
|
||||
@@ -1372,7 +1372,11 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_multi_level_bom=None):
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
if not frappe.has_permission("Work Order", "write"):
|
||||
frappe.throw(_("Not permitted"), frappe.PermissionError)
|
||||
|
||||
@@ -1387,6 +1391,7 @@ def make_work_order(bom_no, item, qty=0, project=None, variant_items=None, use_m
|
||||
|
||||
wo_doc = frappe.new_doc("Work Order")
|
||||
wo_doc.production_item = item
|
||||
wo_doc.company = company or get_default_company()
|
||||
wo_doc.update(item_details)
|
||||
wo_doc.bom_no = bom_no
|
||||
wo_doc.use_multi_level_bom = cint(use_multi_level_bom)
|
||||
|
||||
@@ -47,21 +47,8 @@ def create_customer_or_supplier():
|
||||
if party_exists(doctype, user):
|
||||
return
|
||||
|
||||
party = frappe.new_doc(doctype)
|
||||
fullname = frappe.utils.get_fullname(user)
|
||||
|
||||
if not doctype == "Customer":
|
||||
party.update(
|
||||
{
|
||||
"supplier_name": fullname,
|
||||
"supplier_group": "All Supplier Groups",
|
||||
"supplier_type": "Individual",
|
||||
}
|
||||
)
|
||||
|
||||
party.flags.ignore_mandatory = True
|
||||
party.insert(ignore_permissions=True)
|
||||
|
||||
party = create_party(doctype, fullname)
|
||||
alternate_doctype = "Customer" if doctype == "Supplier" else "Supplier"
|
||||
|
||||
if party_exists(alternate_doctype, user):
|
||||
@@ -69,6 +56,22 @@ def create_customer_or_supplier():
|
||||
fullname += "-" + doctype
|
||||
|
||||
create_party_contact(doctype, fullname, user, party.name)
|
||||
return party
|
||||
|
||||
|
||||
def create_party(doctype, fullname):
|
||||
party = frappe.new_doc(doctype)
|
||||
# Can't set parent party as group
|
||||
|
||||
party.update(
|
||||
{
|
||||
f"{doctype.lower()}_name": fullname,
|
||||
f"{doctype.lower()}_type": "Individual",
|
||||
}
|
||||
)
|
||||
|
||||
party.flags.ignore_mandatory = True
|
||||
party.insert(ignore_permissions=True)
|
||||
|
||||
return party
|
||||
|
||||
|
||||
@@ -1681,6 +1681,8 @@ def make_work_orders(items, sales_order, company, project=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_status(status, name):
|
||||
frappe.has_permission("Sales Order", "submit", name, throw=True)
|
||||
|
||||
so = frappe.get_doc("Sales Order", name)
|
||||
so.update_status(status)
|
||||
|
||||
|
||||
@@ -29,6 +29,15 @@ from erpnext.stock.serial_batch_bundle import (
|
||||
)
|
||||
from erpnext.utilities.transaction_base import TransactionBase
|
||||
|
||||
|
||||
class MissingWarehouseValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class IncorrectWarehouseValidationError(frappe.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
# TODO: Prioritize SO or WO group warehouse
|
||||
|
||||
|
||||
@@ -94,6 +103,7 @@ class PickList(TransactionBase):
|
||||
|
||||
if self.get("locations"):
|
||||
self.validate_sales_order_percentage()
|
||||
self.validate_warehouses()
|
||||
|
||||
def validate_stock_qty(self):
|
||||
from erpnext.stock.doctype.batch.batch import get_batch_qty
|
||||
@@ -138,6 +148,31 @@ class PickList(TransactionBase):
|
||||
title=_("Insufficient Stock"),
|
||||
)
|
||||
|
||||
def validate_warehouses(self):
|
||||
for location in self.locations:
|
||||
if not location.warehouse:
|
||||
frappe.throw(
|
||||
_("Row {0}: Warehouse is required").format(location.idx),
|
||||
title=_("Missing Warehouse"),
|
||||
exc=MissingWarehouseValidationError,
|
||||
)
|
||||
|
||||
company = frappe.get_cached_value("Warehouse", location.warehouse, "company")
|
||||
|
||||
if company != self.company:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {0}: Warehouse {1} is linked to company {2}. Please select a warehouse belonging to company {3}."
|
||||
).format(
|
||||
location.idx,
|
||||
frappe.bold(location.warehouse),
|
||||
frappe.bold(company),
|
||||
frappe.bold(self.company),
|
||||
),
|
||||
title=_("Incorrect Warehouse"),
|
||||
exc=IncorrectWarehouseValidationError,
|
||||
)
|
||||
|
||||
def check_serial_no_status(self):
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
|
||||
@@ -949,6 +984,7 @@ def get_available_item_locations(
|
||||
locations = get_available_item_locations_for_batched_item(
|
||||
item_code,
|
||||
from_warehouses,
|
||||
company,
|
||||
consider_rejected_warehouses=consider_rejected_warehouses,
|
||||
)
|
||||
else:
|
||||
@@ -1049,6 +1085,7 @@ def get_available_item_locations_for_serial_and_batched_item(
|
||||
locations = get_available_item_locations_for_batched_item(
|
||||
item_code,
|
||||
from_warehouses,
|
||||
company,
|
||||
consider_rejected_warehouses=consider_rejected_warehouses,
|
||||
)
|
||||
|
||||
@@ -1129,6 +1166,7 @@ def get_available_item_locations_for_serialized_item(
|
||||
def get_available_item_locations_for_batched_item(
|
||||
item_code,
|
||||
from_warehouses,
|
||||
company,
|
||||
consider_rejected_warehouses=False,
|
||||
):
|
||||
locations = []
|
||||
@@ -1138,6 +1176,7 @@ def get_available_item_locations_for_batched_item(
|
||||
"item_code": item_code,
|
||||
"warehouse": from_warehouses,
|
||||
"based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
|
||||
"company": company,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -211,6 +211,7 @@ class TestPickList(FrappeTestCase):
|
||||
"qty": 1000,
|
||||
"stock_qty": 1000,
|
||||
"conversion_factor": 1,
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"sales_order": so.name,
|
||||
"sales_order_item": so.items[0].name,
|
||||
}
|
||||
@@ -268,6 +269,119 @@ class TestPickList(FrappeTestCase):
|
||||
pr1.cancel()
|
||||
pr2.cancel()
|
||||
|
||||
def test_pick_list_warehouse_for_batched_item(self):
|
||||
"""
|
||||
Test that pick list respects company based warehouse assignment for batched items.
|
||||
|
||||
This test verifies that when creating a pick list for a batched item,
|
||||
the system correctly identifies and assigns the appropriate warehouse
|
||||
based on the company.
|
||||
"""
|
||||
from erpnext.stock.doctype.batch.test_batch import make_new_batch
|
||||
|
||||
batch_company = frappe.get_doc(
|
||||
{"doctype": "Company", "company_name": "Batch Company", "default_currency": "INR"}
|
||||
)
|
||||
batch_company.insert()
|
||||
|
||||
batch_warehouse = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Warehouse",
|
||||
"warehouse_name": "Batch Warehouse",
|
||||
"company": batch_company.name,
|
||||
}
|
||||
)
|
||||
batch_warehouse.insert()
|
||||
|
||||
batch_item = frappe.db.exists("Item", "Batch Warehouse Item")
|
||||
if not batch_item:
|
||||
batch_item = create_item("Batch Warehouse Item")
|
||||
batch_item.has_batch_no = 1
|
||||
batch_item.create_new_batch = 1
|
||||
batch_item.save()
|
||||
else:
|
||||
batch_item = frappe.get_doc("Item", "Batch Warehouse Item")
|
||||
|
||||
batch_no = make_new_batch(item_code=batch_item.name, batch_id="B-WH-ITEM-001")
|
||||
|
||||
make_stock_entry(
|
||||
item_code=batch_item.name,
|
||||
qty=5,
|
||||
company=batch_company.name,
|
||||
to_warehouse=batch_warehouse.name,
|
||||
batch_no=batch_no.name,
|
||||
rate=100.0,
|
||||
)
|
||||
make_stock_entry(
|
||||
item_code=batch_item.name,
|
||||
qty=5,
|
||||
to_warehouse="_Test Warehouse - _TC",
|
||||
batch_no=batch_no.name,
|
||||
rate=100.0,
|
||||
)
|
||||
|
||||
pick_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pick List",
|
||||
"company": batch_company.name,
|
||||
"purpose": "Material Transfer",
|
||||
"locations": [
|
||||
{
|
||||
"item_code": batch_item.name,
|
||||
"qty": 10,
|
||||
"stock_qty": 10,
|
||||
"conversion_factor": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
pick_list.set_item_locations()
|
||||
self.assertEqual(len(pick_list.locations), 1)
|
||||
self.assertEqual(pick_list.locations[0].qty, 5)
|
||||
self.assertEqual(pick_list.locations[0].batch_no, batch_no.name)
|
||||
self.assertEqual(pick_list.locations[0].warehouse, batch_warehouse.name)
|
||||
|
||||
def test_pick_list_warehouse_validation(self):
|
||||
"""check if the warehouse validations are triggered"""
|
||||
from erpnext.stock.doctype.pick_list.pick_list import (
|
||||
IncorrectWarehouseValidationError,
|
||||
MissingWarehouseValidationError,
|
||||
)
|
||||
|
||||
warehouse_item = create_item("Warehouse Item")
|
||||
temp_company = frappe.get_doc(
|
||||
{"doctype": "Company", "company_name": "Temp Company", "default_currency": "INR"}
|
||||
).insert()
|
||||
temp_warehouse = frappe.get_doc(
|
||||
{"doctype": "Warehouse", "warehouse_name": "Temp Warehouse", "company": temp_company.name}
|
||||
).insert()
|
||||
|
||||
make_stock_entry(item_code=warehouse_item.name, qty=10, rate=100.0, to_warehouse=temp_warehouse.name)
|
||||
|
||||
pick_list = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Pick List",
|
||||
"company": temp_company.name,
|
||||
"purpose": "Material Transfer",
|
||||
"pick_manually": 1,
|
||||
"locations": [
|
||||
{
|
||||
"item_code": warehouse_item.name,
|
||||
"qty": 5,
|
||||
"stock_qty": 5,
|
||||
"conversion_factor": 1,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
self.assertRaises(MissingWarehouseValidationError, pick_list.insert)
|
||||
pick_list.locations[0].warehouse = "_Test Warehouse - _TC"
|
||||
self.assertRaises(IncorrectWarehouseValidationError, pick_list.insert)
|
||||
pick_list.locations[0].warehouse = temp_warehouse.name
|
||||
pick_list.insert()
|
||||
|
||||
def test_pick_list_for_batched_and_serialised_item(self):
|
||||
# check if oldest batch no and serial nos are picked
|
||||
item = frappe.db.exists("Item", {"item_name": "Batched and Serialised Item"})
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Warehouse",
|
||||
"mandatory_depends_on": "eval: parent.pick_manually",
|
||||
"options": "Warehouse",
|
||||
"read_only": 1
|
||||
},
|
||||
@@ -283,7 +284,7 @@
|
||||
],
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-23 00:02:57.817040",
|
||||
"modified": "2026-03-17 16:25:10.358013",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Pick List Item",
|
||||
|
||||
@@ -185,6 +185,7 @@ class PurchaseReceipt(BuyingController):
|
||||
"target_ref_field": "stock_qty",
|
||||
"source_field": "stock_qty",
|
||||
"percent_join_field": "material_request",
|
||||
"validate_qty": False,
|
||||
},
|
||||
{
|
||||
"source_dt": "Purchase Receipt Item",
|
||||
@@ -327,7 +328,10 @@ class PurchaseReceipt(BuyingController):
|
||||
)
|
||||
|
||||
def po_required(self):
|
||||
if frappe.db.get_value("Buying Settings", None, "po_required") == "Yes":
|
||||
if (
|
||||
frappe.db.get_single_value("Buying Settings", "po_required") == "Yes"
|
||||
and not self.is_internal_transfer()
|
||||
):
|
||||
for d in self.get("items"):
|
||||
if not d.purchase_order:
|
||||
frappe.throw(_("Purchase Order number required for Item {0}").format(d.item_code))
|
||||
@@ -1414,6 +1418,8 @@ def make_purchase_return(source_name, target_doc=None):
|
||||
|
||||
@frappe.whitelist()
|
||||
def update_purchase_receipt_status(docname, status):
|
||||
frappe.has_permission("Purchase Receipt", "submit", docname, throw=True)
|
||||
|
||||
pr = frappe.get_doc("Purchase Receipt", docname)
|
||||
pr.update_status(status)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from erpnext.controllers.accounts_controller import InvalidQtyError
|
||||
from erpnext.controllers.buying_controller import QtyMismatchError
|
||||
from erpnext.stock import get_warehouse_account_map
|
||||
from erpnext.stock.doctype.item.test_item import create_item, make_item
|
||||
from erpnext.stock.doctype.material_request.material_request import make_purchase_order
|
||||
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
|
||||
SerialNoDuplicateError,
|
||||
@@ -33,6 +34,40 @@ class TestPurchaseReceipt(FrappeTestCase):
|
||||
def setUp(self):
|
||||
frappe.db.set_single_value("Buying Settings", "allow_multiple_items", 1)
|
||||
|
||||
def test_purchase_receipt_skips_validation(self):
|
||||
"""
|
||||
Test that validation is skipped when over delivery receipt allowance is reduced after PO submission
|
||||
and PR can be submitted with higher qty than MR.
|
||||
"""
|
||||
item = create_item("Test item for validation")
|
||||
mr = frappe.new_doc("Material Request")
|
||||
mr.material_request_type = "Purchase"
|
||||
mr.company = "_Test Company"
|
||||
mr.price_list = "_Test Price List"
|
||||
mr.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item.name,
|
||||
"item_name": item.item_name,
|
||||
"item_group": item.item_group,
|
||||
"schedule_date": add_days(today(), 1),
|
||||
"qty": 100,
|
||||
"uom": item.stock_uom,
|
||||
},
|
||||
)
|
||||
mr.insert()
|
||||
mr.submit()
|
||||
frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 200)
|
||||
po = make_purchase_order(mr.name)
|
||||
po.supplier = "_Test Supplier"
|
||||
po.items[0].qty = 300
|
||||
po.save()
|
||||
po.submit()
|
||||
frappe.db.set_value("Item", item.name, "over_delivery_receipt_allowance", 20)
|
||||
pr = make_purchase_receipt(qty=300, item_code=item.name, do_not_save=True)
|
||||
pr.save()
|
||||
pr.submit()
|
||||
|
||||
def test_purchase_receipt_qty(self):
|
||||
pr = make_purchase_receipt(qty=0, rejected_qty=0, do_not_save=True)
|
||||
with self.assertRaises(InvalidQtyError):
|
||||
|
||||
@@ -748,11 +748,16 @@ class SerialandBatchBundle(Document):
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "incoming_rate")
|
||||
for d in self.entries:
|
||||
fifo_batch_wise_val = True
|
||||
if valuation_method == "FIFO" and d.batch_no in batches:
|
||||
fifo_batch_wise_val = False
|
||||
|
||||
if self.is_rejected and not set_valuation_rate_for_rejected_materials:
|
||||
rate = 0.0
|
||||
elif (
|
||||
(flt(d.incoming_rate, precision) == flt(rate, precision))
|
||||
and not stock_queue
|
||||
and fifo_batch_wise_val
|
||||
and d.qty
|
||||
and d.stock_value_difference
|
||||
):
|
||||
@@ -2524,26 +2529,38 @@ def get_reserved_batches_for_pos(kwargs) -> dict:
|
||||
"""Returns a dict of `Batch No` followed by the `Qty` reserved in POS Invoices."""
|
||||
|
||||
pos_batches = frappe._dict()
|
||||
pos_invoices = frappe.get_all(
|
||||
"POS Invoice",
|
||||
fields=[
|
||||
"`tabPOS Invoice Item`.batch_no",
|
||||
"`tabPOS Invoice Item`.qty",
|
||||
"`tabPOS Invoice`.is_return",
|
||||
"`tabPOS Invoice Item`.warehouse",
|
||||
"`tabPOS Invoice Item`.name as child_docname",
|
||||
"`tabPOS Invoice`.name as parent_docname",
|
||||
"`tabPOS Invoice Item`.use_serial_batch_fields",
|
||||
"`tabPOS Invoice Item`.serial_and_batch_bundle",
|
||||
],
|
||||
filters=[
|
||||
["POS Invoice", "consolidated_invoice", "is", "not set"],
|
||||
["POS Invoice", "docstatus", "=", 1],
|
||||
["POS Invoice Item", "item_code", "=", kwargs.item_code],
|
||||
["POS Invoice", "name", "not in", kwargs.ignore_voucher_nos],
|
||||
],
|
||||
POS_Invoice = frappe.qb.DocType("POS Invoice")
|
||||
POS_Invoice_Item = frappe.qb.DocType("POS Invoice Item")
|
||||
|
||||
pos_invoices = (
|
||||
frappe.qb.from_(POS_Invoice)
|
||||
.inner_join(POS_Invoice_Item)
|
||||
.on(POS_Invoice.name == POS_Invoice_Item.parent)
|
||||
.select(
|
||||
POS_Invoice_Item.batch_no,
|
||||
POS_Invoice_Item.qty,
|
||||
POS_Invoice.is_return,
|
||||
POS_Invoice_Item.warehouse,
|
||||
POS_Invoice_Item.name.as_("child_docname"),
|
||||
POS_Invoice.name.as_("parent_docname"),
|
||||
POS_Invoice_Item.use_serial_batch_fields,
|
||||
POS_Invoice_Item.serial_and_batch_bundle,
|
||||
)
|
||||
.where(
|
||||
(POS_Invoice.consolidated_invoice.isnull())
|
||||
& (POS_Invoice.docstatus == 1)
|
||||
& (POS_Invoice_Item.item_code == kwargs.item_code)
|
||||
)
|
||||
)
|
||||
|
||||
if kwargs.get("company"):
|
||||
pos_invoices = pos_invoices.where(POS_Invoice.company == kwargs.get("company"))
|
||||
|
||||
if kwargs.get("ignore_voucher_nos"):
|
||||
pos_invoices = pos_invoices.where(POS_Invoice.name.notin(kwargs.get("ignore_voucher_nos")))
|
||||
|
||||
pos_invoices = pos_invoices.run(as_dict=True)
|
||||
|
||||
ids = [
|
||||
pos_invoice.serial_and_batch_bundle
|
||||
for pos_invoice in pos_invoices
|
||||
@@ -2607,6 +2624,9 @@ def get_reserved_batches_for_sre(kwargs) -> dict:
|
||||
.groupby(sb_entry.batch_no, sre.warehouse)
|
||||
)
|
||||
|
||||
if kwargs.get("company"):
|
||||
query = query.where(sre.company == kwargs.get("company"))
|
||||
|
||||
if kwargs.batch_no:
|
||||
if isinstance(kwargs.batch_no, list):
|
||||
query = query.where(sb_entry.batch_no.isin(kwargs.batch_no))
|
||||
@@ -2769,6 +2789,9 @@ def get_available_batches(kwargs):
|
||||
.groupby(batch_ledger.batch_no, batch_ledger.warehouse)
|
||||
)
|
||||
|
||||
if kwargs.get("company"):
|
||||
query = query.where(stock_ledger_entry.company == kwargs.get("company"))
|
||||
|
||||
if not kwargs.get("for_stock_levels"):
|
||||
query = query.where((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull()))
|
||||
|
||||
@@ -2886,6 +2909,9 @@ def get_picked_batches(kwargs) -> dict[str, dict]:
|
||||
)
|
||||
)
|
||||
|
||||
if kwargs.get("company"):
|
||||
query = query.where(table.company == kwargs.get("company"))
|
||||
|
||||
if kwargs.get("item_code"):
|
||||
query = query.where(table.item_code == kwargs.get("item_code"))
|
||||
|
||||
@@ -3085,6 +3111,9 @@ def get_stock_ledgers_batches(kwargs):
|
||||
.groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse)
|
||||
)
|
||||
|
||||
if kwargs.get("company"):
|
||||
query = query.where(stock_ledger_entry.company == kwargs.get("company"))
|
||||
|
||||
for field in ["warehouse", "item_code", "batch_no"]:
|
||||
if not kwargs.get(field):
|
||||
continue
|
||||
|
||||
@@ -514,7 +514,9 @@ frappe.ui.form.on("Stock Entry", {
|
||||
frm.fields_dict.items.grid.refresh();
|
||||
frm.cscript.toggle_related_fields(frm.doc);
|
||||
},
|
||||
|
||||
cost_center(frm, cdt, cdn) {
|
||||
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "cost_center");
|
||||
},
|
||||
validate_purpose_consumption: function (frm) {
|
||||
frappe
|
||||
.call({
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
"address_display",
|
||||
"accounting_dimensions_section",
|
||||
"project",
|
||||
"column_break_wgvc",
|
||||
"cost_center",
|
||||
"other_info_tab",
|
||||
"printing_settings",
|
||||
"select_print_heading",
|
||||
@@ -699,6 +701,16 @@
|
||||
"fieldtype": "Data",
|
||||
"is_virtual": 1,
|
||||
"label": "Last Scanned Warehouse"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_wgvc",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-file-text",
|
||||
@@ -706,7 +718,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2025-08-04 19:21:03.338958",
|
||||
"modified": "2026-03-04 19:03:23.426082",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Stock",
|
||||
"name": "Stock Entry",
|
||||
|
||||
@@ -103,6 +103,7 @@ class StockEntry(StockController):
|
||||
asset_repair: DF.Link | None
|
||||
bom_no: DF.Link | None
|
||||
company: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
credit_note: DF.Link | None
|
||||
delivery_note_no: DF.Link | None
|
||||
fg_completed_qty: DF.Float
|
||||
@@ -589,9 +590,6 @@ class StockEntry(StockController):
|
||||
flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
|
||||
)
|
||||
|
||||
if self.purpose == "Manufacture":
|
||||
item.set("expense_account", item_details.get("expense_account"))
|
||||
|
||||
def validate_fg_completed_qty(self):
|
||||
item_wise_qty = {}
|
||||
if self.purpose == "Manufacture" and self.work_order:
|
||||
@@ -2136,7 +2134,7 @@ class StockEntry(StockController):
|
||||
self.to_warehouse if self.purpose == "Send to Subcontractor" else ""
|
||||
)
|
||||
|
||||
if original_item != item.get("item_code"):
|
||||
if isinstance(original_item, str) and original_item != item.get("item_code"):
|
||||
item["original_item"] = original_item
|
||||
|
||||
self.add_to_stock_entry_detail(item_dict)
|
||||
|
||||
@@ -7,7 +7,7 @@ from math import ceil
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.utils import add_days, cint, flt, nowdate
|
||||
from frappe.utils import add_days, cint, escape_html, flt, nowdate
|
||||
|
||||
import erpnext
|
||||
|
||||
@@ -219,15 +219,6 @@ def create_material_request(material_requests):
|
||||
mr_list = []
|
||||
exceptions_list = []
|
||||
|
||||
def _log_exception(mr):
|
||||
if frappe.local.message_log:
|
||||
exceptions_list.extend(frappe.local.message_log)
|
||||
frappe.local.message_log = []
|
||||
else:
|
||||
exceptions_list.append(frappe.get_traceback(with_context=True))
|
||||
|
||||
mr.log_error("Unable to create material request")
|
||||
|
||||
company_wise_mr = frappe._dict({})
|
||||
for request_type in material_requests:
|
||||
for company in material_requests[request_type]:
|
||||
@@ -297,8 +288,9 @@ def create_material_request(material_requests):
|
||||
|
||||
company_wise_mr.setdefault(company, []).append(mr)
|
||||
|
||||
except Exception:
|
||||
_log_exception(mr)
|
||||
except Exception as exception:
|
||||
exceptions_list.append(exception)
|
||||
mr.log_error("Unable to create material request")
|
||||
|
||||
if company_wise_mr:
|
||||
if getattr(frappe.local, "reorder_email_notify", None) is None:
|
||||
@@ -383,10 +375,7 @@ def notify_errors(exceptions_list):
|
||||
|
||||
for exception in exceptions_list:
|
||||
try:
|
||||
exception = json.loads(exception)
|
||||
error_message = """<div class='small text-muted'>{}</div><br>""".format(
|
||||
_(exception.get("message"))
|
||||
)
|
||||
error_message = f"<div class='small text-muted'>{escape_html(str(exception))}</div><br>"
|
||||
content += error_message
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -243,10 +243,7 @@ class FIFOSlots:
|
||||
consumed/updated and maintained via FIFO. **
|
||||
}
|
||||
"""
|
||||
|
||||
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
|
||||
get_serial_nos_from_bundle,
|
||||
)
|
||||
from erpnext.stock.serial_batch_bundle import get_serial_nos_from_bundle
|
||||
|
||||
stock_ledger_entries = self.sle
|
||||
|
||||
@@ -271,7 +268,7 @@ class FIFOSlots:
|
||||
if bundle_wise_serial_nos:
|
||||
serial_nos = bundle_wise_serial_nos.get(d.serial_and_batch_bundle) or []
|
||||
else:
|
||||
serial_nos = get_serial_nos_from_bundle(d.serial_and_batch_bundle) or []
|
||||
serial_nos = sorted(get_serial_nos_from_bundle(d.serial_and_batch_bundle)) or []
|
||||
|
||||
serial_nos = self.uppercase_serial_nos(serial_nos)
|
||||
if d.actual_qty > 0:
|
||||
|
||||
@@ -2058,7 +2058,6 @@ def update_qty_in_future_sle(args, allow_negative_stock=False):
|
||||
where
|
||||
item_code = %(item_code)s
|
||||
and warehouse = %(warehouse)s
|
||||
and voucher_no != %(voucher_no)s
|
||||
and is_cancelled = 0
|
||||
and (
|
||||
posting_datetime > %(posting_datetime)s
|
||||
|
||||
Reference in New Issue
Block a user