Merge upstream/develop into erpnext-refactoring

Resolve 4 conflicts from Phase 7 service/mapper extraction vs upstream:
- asset.py: take extraction; repoint dangling make_asset_movement JS to mapper
- job_card: port upstream field_no_map(naming_series) into mapper.make_subcontracting_po
- sales_order: port upstream rows-index fix into mapper.make_delivery_note
- sales_invoice (Phase 7): take service delegations; port upstream SQL->QB/ORM
  changes for get_warehouse, get_all_mode_of_payments, get_discounting_status,
  clear_unallocated_mode_of_payments, and set_pos_fields(POS DN skip) into services
This commit is contained in:
Nabin Hait
2026-06-03 11:05:01 +05:30
247 changed files with 113261 additions and 46190 deletions

View File

@@ -9,11 +9,10 @@ import frappe
from frappe import qb, scrub
from frappe.desk.reportview import get_filters_cond, get_match_cond
from frappe.permissions import has_permission
from frappe.query_builder import Case, Criterion, DocType, Field
from frappe.query_builder import Case, Criterion, DocType
from frappe.query_builder.functions import Concat, CustomFunction, Length, Locate, Substring, Sum
from frappe.utils import nowdate, today, unique
from pypika import Order
from pypika.terms import LiteralValue
import erpnext
from erpnext.accounts.utils import build_qb_match_conditions
@@ -433,10 +432,15 @@ def get_delivery_notes_to_be_billed(
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
)
query = frappe.qb.get_query(
"Delivery Note",
fields=fields,
filters=filters,
ignore_permissions=False,
)
query = (
frappe.qb.from_(DeliveryNote)
.select(*[DeliveryNote[f] for f in fields])
.where(
query.where(
(DeliveryNote.docstatus == 1)
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
& (DeliveryNote[searchfield].like(f"%{txt}%"))
@@ -450,12 +454,11 @@ def get_delivery_notes_to_be_billed(
)
)
)
.orderby(DeliveryNote[searchfield], order=Order.asc)
.limit(page_len)
.offset(start)
)
if filters and isinstance(filters, dict):
for key, value in filters.items():
query = query.where(DeliveryNote[key] == value)
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
return query.run(as_dict=as_dict)

View File

@@ -262,15 +262,17 @@ class StatusUpdater(Document):
def validate_qty(self):
"""Validates qty at row level"""
self.item_allowance = {}
self.global_qty_allowance = None
self.global_amount_allowance = None
for args in self.status_updater:
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
# Reset per-args so each config block uses its own allowance source without
# leaking cached values from a previous config block.
self.item_allowance = {}
self.global_qty_allowance = None
self.global_amount_allowance = None
items_to_validate = []
selling_negative_rate_allowed = frappe.get_single_value(
"Selling Settings", "allow_negative_rates_for_items"
@@ -402,9 +404,12 @@ class StatusUpdater(Document):
def check_overflow_with_allowance(self, item, args):
"""
Checks if there is overflow condering a relaxation allowance
Checks if there is overflow considering a relaxation allowance.
"""
qty_or_amount = "qty" if "qty" in args["target_ref_field"] else "amount"
global_qty_allowance_field = args.get("global_allowance_field", "over_delivery_receipt_allowance")
global_qty_allowance_doctype = args.get("global_allowance_doctype", "Stock Settings")
item_qty_allowance_field = args.get("item_allowance_field", "over_delivery_receipt_allowance")
# check if overflow is within allowance
(
@@ -419,6 +424,9 @@ class StatusUpdater(Document):
self.global_qty_allowance,
self.global_amount_allowance,
qty_or_amount,
global_qty_allowance_field,
global_qty_allowance_doctype,
item_qty_allowance_field,
)
if args["source_dt"] != "Pick List Item"
else (0, {}, None, None)
@@ -463,7 +471,9 @@ class StatusUpdater(Document):
"Quotation Item",
"Packed Item",
]:
if qty_or_amount == "qty":
if args.get("target_dt") == "Material Request Item":
action_msg = _('To allow over ordering, update "Over Order Allowance" in Buying Settings.')
elif qty_or_amount == "qty":
action_msg = _(
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
)
@@ -724,16 +734,28 @@ class StatusUpdater(Document):
ref_doc.set_status(update=True)
@frappe.request_cache
def get_allowance_for(
item_code,
item_allowance=None,
global_qty_allowance=None,
global_amount_allowance=None,
qty_or_amount="qty",
global_qty_allowance_field="over_delivery_receipt_allowance",
global_qty_allowance_doctype="Stock Settings",
item_qty_allowance_field="over_delivery_receipt_allowance",
):
"""
Returns the allowance for the item, if not set, returns global allowance
Returns the allowance for the item, if not set, returns global allowance.
Args:
item_code: The item to get allowance for.
item_allowance: Cached per-item allowances from a previous call.
global_qty_allowance: Cached global qty allowance from a previous call.
global_amount_allowance: Cached global amount allowance from a previous call.
qty_or_amount: Whether to return qty or amount allowance.
global_qty_allowance_field: The field name on the settings doctype to use for the global qty allowance.
global_qty_allowance_doctype: The settings doctype to read the global qty allowance from.
item_qty_allowance_field: The field name on the Item doctype to use for the item-level qty allowance override.
"""
if item_allowance is None:
item_allowance = {}
@@ -755,13 +777,13 @@ def get_allowance_for(
)
qty_allowance, over_billing_allowance = frappe.get_cached_value(
"Item", item_code, ["over_delivery_receipt_allowance", "over_billing_allowance"]
"Item", item_code, [item_qty_allowance_field, "over_billing_allowance"]
)
if qty_or_amount == "qty" and not qty_allowance:
if global_qty_allowance is None:
global_qty_allowance = flt(
frappe.get_cached_value("Stock Settings", None, "over_delivery_receipt_allowance")
frappe.get_single_value(global_qty_allowance_doctype, global_qty_allowance_field)
)
qty_allowance = global_qty_allowance
elif qty_or_amount == "amount" and not over_billing_allowance:

View File

@@ -1311,7 +1311,7 @@ class StockController(AccountsController):
elif self.doctype == "Stock Entry" and row.t_warehouse:
qi_required = True # inward stock needs inspection
if row.get("type") or row.get("is_legacy_scrap_item"):
if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"):
continue
if qi_required: # validate row only if inspection is required on item level
@@ -1979,7 +1979,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
@frappe.whitelist()
def check_item_quality_inspection(doctype: str, items: str | list[dict]):
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
if isinstance(items, str):
items = json.loads(items)
@@ -1991,13 +1991,30 @@ def check_item_quality_inspection(doctype: str, items: str | list[dict]):
"Delivery Note": "inspection_required_before_delivery",
}
items_to_remove = []
for item in items:
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
items_to_remove.append(item)
items = [item for item in items if item not in items_to_remove]
inspection_fieldname = inspection_fieldname_map.get(doctype)
if inspection_fieldname is None:
return []
return items
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
)
if allow_after_transaction:
return items
item_codes = list({item.get("item_code") for item in items})
Item = frappe.qb.DocType("Item")
results = (
frappe.qb.from_(Item)
.select(Item.name)
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
.run(as_dict=True)
)
inspection_required_items = {row.name for row in results}
return [item for item in items if item.get("item_code") in inspection_required_items]
@frappe.whitelist()

View File

@@ -7,6 +7,7 @@ from collections import defaultdict
import frappe
from frappe import _
from frappe.model.document import Document
from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, flt, get_link_to_form
@@ -150,7 +151,7 @@ class SubcontractingController(StockController):
).format(item.idx, get_link_to_form("Item", item.item_code))
)
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
if not is_sub_contracted_item:
frappe.throw(
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
@@ -1243,10 +1244,10 @@ class SubcontractingController(StockController):
total_amt = sum(
flt(item.amount)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
)
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = (
(item.amount * self.total_additional_costs) / total_amt
) / item.qty
@@ -1254,15 +1255,15 @@ class SubcontractingController(StockController):
total_qty = sum(
flt(item.qty)
for item in self.get("items")
if not item.get("type") and not item.get("is_legacy_scrap_item")
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
)
additional_cost_per_qty = self.total_additional_costs / total_qty
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = additional_cost_per_qty
else:
for item in self.items:
if not item.get("type") and not item.get("is_legacy_scrap_item"):
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
item.additional_cost_per_qty = 0
@frappe.whitelist()
@@ -1529,9 +1530,13 @@ def make_return_stock_entry_for_subcontract(
@frappe.whitelist()
def get_materials_from_supplier(
subcontract_order: str, rm_details: str | list, order_doctype: str = "Subcontracting Order"
):
def get_materials_from_supplier(source_name: str, target_doc: Document | str | None = None):
args = frappe.flags.args or {}
subcontract_order = args.get("subcontract_order") or source_name
rm_details = args.get("rm_details")
order_doctype = args.get("order_doctype") or "Subcontracting Order"
if isinstance(rm_details, str):
rm_details = json.loads(rm_details)

View File

@@ -241,7 +241,7 @@ class SubcontractingInwardController:
item
for item in self.get("items")
if not item.is_finished_item
and not item.type
and not item.secondary_item_type
and not item.is_legacy_scrap_item
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
]
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
for item in self.items:
if (
item.is_finished_item or item.type or item.is_legacy_scrap_item
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
) and item.valuation_rate == 0:
item.allow_zero_valuation_rate = 1
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
self.validate_delivery_on_save()
else:
for item in self.items:
if not item.type and not item.is_legacy_scrap_item:
if not item.secondary_item_type and not item.is_legacy_scrap_item:
delivered_qty, returned_qty = frappe.get_value(
"Subcontracting Inward Order Item",
item.scio_detail,
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
bold(
frappe.get_cached_value(
"Subcontracting Inward Order Item"
if not item.type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item",
item.scio_detail,
"stock_uom",
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
)
for item in [item for item in self.items if not item.is_finished_item]:
if item.type or item.is_legacy_scrap_item:
if item.secondary_item_type or item.is_legacy_scrap_item:
scio_secondary_item = frappe.get_value(
"Subcontracting Inward Order Secondary Item",
{
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
for item in self.items:
doctype = (
"Subcontracting Inward Order Item"
if not item.type and not item.is_legacy_scrap_item
if not item.secondary_item_type and not item.is_legacy_scrap_item
else "Subcontracting Inward Order Secondary Item"
)
qty_map[doctype][item.scio_detail] += (
@@ -781,7 +781,7 @@ class SubcontractingInwardController:
items = [
item
for item in self.items
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
]
item_code_wh = frappe._dict(
{
@@ -884,7 +884,9 @@ class SubcontractingInwardController:
def update_inward_order_secondary_items(self):
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
secondary_items_list = [
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
]
secondary_items = defaultdict(float)
for item in secondary_items_list:
@@ -958,7 +960,7 @@ class SubcontractingInwardController:
stock_uom=secondary_item.stock_uom,
warehouse=secondary_item.t_warehouse,
produced_qty=secondary_item.transfer_qty,
type=secondary_item.type,
secondary_item_type=secondary_item.secondary_item_type,
delivered_qty=0,
reference_name=frappe.get_value(
"Work Order", self.work_order, "subcontracting_inward_order_item"

View File

@@ -347,7 +347,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items])
frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
doc.save()
self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
@@ -404,7 +409,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items])
frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")
@@ -1133,7 +1143,12 @@ class TestSubcontractingController(ERPNextTestSuite):
sco.load_from_db()
self.assertEqual(sco.supplied_items[0].consumed_qty, 5)
doc = get_materials_from_supplier(sco.name, [d.name for d in sco.supplied_items])
frappe.flags.args = frappe._dict(
subcontract_order=sco.name,
rm_details=[d.name for d in sco.supplied_items],
order_doctype=sco.doctype,
)
doc = get_materials_from_supplier(sco.name)
self.assertEqual(doc.items[0].qty, 1)
self.assertEqual(doc.items[0].s_warehouse, "_Test Warehouse 1 - _TC")
self.assertEqual(doc.items[0].t_warehouse, "_Test Warehouse - _TC")