mirror of
https://github.com/frappe/erpnext.git
synced 2026-07-24 21:25:04 +00:00
Compare commits
16 Commits
version-16
...
mergify/bp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47ee3aff1b | ||
|
|
ef21a9ba9b | ||
|
|
88f9039d1a | ||
|
|
2e6b4b5838 | ||
|
|
85d793b690 | ||
|
|
8a50572786 | ||
|
|
bbd942c600 | ||
|
|
84813d7f46 | ||
|
|
1132eb1a0f | ||
|
|
2323f2c978 | ||
|
|
e277647f7b | ||
|
|
c587f4934a | ||
|
|
e0020b478a | ||
|
|
95de2374ed | ||
|
|
d70e005412 | ||
|
|
32baf6a47c |
@@ -17,7 +17,7 @@ class ERPNextAddress(Address):
|
||||
|
||||
def link_address(self):
|
||||
"""Link address based on owner"""
|
||||
if self.is_your_company_address:
|
||||
if self.get("is_your_company_address"):
|
||||
return
|
||||
|
||||
return super().link_address()
|
||||
@@ -28,7 +28,9 @@ class ERPNextAddress(Address):
|
||||
self.is_your_company_address = 1
|
||||
|
||||
def validate_reference(self):
|
||||
if self.is_your_company_address and not [row for row in self.links if row.link_doctype == "Company"]:
|
||||
if self.get("is_your_company_address") and not [
|
||||
row for row in self.links if row.link_doctype == "Company"
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Address needs to be linked to a Company. Please add a row for Company in the Links table."
|
||||
|
||||
@@ -82,8 +82,7 @@
|
||||
"fieldname": "cost_center",
|
||||
"fieldtype": "Link",
|
||||
"label": "Cost Center",
|
||||
"options": "Cost Center",
|
||||
"reqd": 1
|
||||
"options": "Cost Center"
|
||||
},
|
||||
{
|
||||
"fieldname": "shipping_amount_section",
|
||||
@@ -141,19 +140,20 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
"fieldname": "project",
|
||||
"fieldtype": "Link",
|
||||
"label": "Project",
|
||||
"options": "Project"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-truck",
|
||||
"idx": 1,
|
||||
"links": [],
|
||||
"modified": "2024-03-27 13:10:41.653314",
|
||||
"modified": "2026-07-22 14:53:27.315435",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Shipping Rule",
|
||||
"naming_rule": "By fieldname",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
@@ -197,7 +197,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,18 +36,17 @@ class ShippingRule(Document):
|
||||
from erpnext.accounts.doctype.shipping_rule_condition.shipping_rule_condition import (
|
||||
ShippingRuleCondition,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import (
|
||||
ShippingRuleCountry,
|
||||
)
|
||||
from erpnext.accounts.doctype.shipping_rule_country.shipping_rule_country import ShippingRuleCountry
|
||||
|
||||
account: DF.Link
|
||||
calculate_based_on: DF.Literal["Fixed", "Net Total", "Net Weight"]
|
||||
company: DF.Link
|
||||
conditions: DF.Table[ShippingRuleCondition]
|
||||
cost_center: DF.Link
|
||||
cost_center: DF.Link | None
|
||||
countries: DF.Table[ShippingRuleCountry]
|
||||
disabled: DF.Check
|
||||
label: DF.Data
|
||||
project: DF.Link | None
|
||||
shipping_amount: DF.Currency
|
||||
shipping_rule_type: DF.Literal["Selling", "Buying"]
|
||||
# end: auto-generated types
|
||||
|
||||
@@ -1371,8 +1371,9 @@ class StockController(AccountsController):
|
||||
if outstanding > 0:
|
||||
reservations[key].append(row)
|
||||
|
||||
precision = frappe.get_precision("Serial and Batch Entry", "qty")
|
||||
for (batch_no, warehouse), reserved_qty in outstanding_qty.items():
|
||||
if flt(reserved_qty, 6) <= 0:
|
||||
if flt(reserved_qty, precision) <= 0:
|
||||
continue
|
||||
|
||||
batch_qty = get_batch_qty(
|
||||
@@ -1383,7 +1384,7 @@ class StockController(AccountsController):
|
||||
consider_negative_batches=True,
|
||||
)
|
||||
|
||||
if flt(batch_qty, 6) >= flt(reserved_qty, 6):
|
||||
if flt(batch_qty, precision) >= flt(reserved_qty, precision):
|
||||
continue
|
||||
|
||||
vouchers = ", ".join(
|
||||
|
||||
@@ -2554,7 +2554,13 @@ def get_item_details(item, project=None, skip_bom_info=False, throw=True):
|
||||
|
||||
@frappe.whitelist()
|
||||
def make_work_order(
|
||||
bom_no, item, qty=0, company=None, project=None, variant_items=None, use_multi_level_bom=None
|
||||
bom_no: str,
|
||||
item: str,
|
||||
qty: float = 0,
|
||||
company: str | None = None,
|
||||
project: str | None = None,
|
||||
variant_items: str | list | None = None,
|
||||
use_multi_level_bom: bool | None = None,
|
||||
):
|
||||
from erpnext import get_default_company
|
||||
|
||||
@@ -2563,7 +2569,8 @@ def make_work_order(
|
||||
|
||||
item_details = get_item_details(item, project)
|
||||
|
||||
if frappe.db.get_value("Item", item, "variant_of"):
|
||||
# selected BOM already belongs to this variant — keep it
|
||||
if frappe.db.get_value("Item", item, "variant_of") and frappe.db.get_value("BOM", bom_no, "item") != item:
|
||||
if variant_bom := frappe.db.get_value(
|
||||
"BOM",
|
||||
{"item": item, "is_default": 1, "docstatus": 1},
|
||||
|
||||
@@ -11,6 +11,36 @@ erpnext.stock.StockController = class StockController extends frappe.ui.form.Con
|
||||
}
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
this.set_route_options_for_new_doc();
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating a Batch or Serial and Batch Bundle from the link
|
||||
// field, copy details from the line item to the new form
|
||||
if (!this.frm.fields_dict.items) return;
|
||||
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse || row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
barcode(doc, cdt, cdn) {
|
||||
let row = locals[cdt][cdn];
|
||||
if (row.barcode) {
|
||||
|
||||
@@ -649,34 +649,6 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
|
||||
erpnext.toggle_serial_batch_fields(this.frm);
|
||||
}
|
||||
|
||||
set_route_options_for_new_doc() {
|
||||
// While creating the batch from the link field, copy item from line item to batch form
|
||||
|
||||
if (this.frm.fields_dict["items"].grid.get_field("batch_no")) {
|
||||
let batch_no_field = this.frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// While creating the SABB from the link field, copy item, doctype from line item to SABB form
|
||||
if (this.frm.fields_dict["items"].grid.get_field("serial_and_batch_bundle")) {
|
||||
let sbb_field = this.frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: this.frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scan_barcode() {
|
||||
frappe.flags.dialog_set = false;
|
||||
this.barcode_scanner.process_scan();
|
||||
|
||||
496
erpnext/stock/doctype/pick_list/mapper.py
Normal file
496
erpnext/stock/doctype/pick_list/mapper.py
Normal file
@@ -0,0 +1,496 @@
|
||||
# Copyright (c) 2019, Frappe Technologies Pvt. Ltd. and contributors
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
from itertools import groupby
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.model.mapper import map_child_doc
|
||||
from frappe.utils import flt, get_link_to_form
|
||||
|
||||
from erpnext.selling.doctype.sales_order.mapper import (
|
||||
make_delivery_note as create_delivery_note_from_sales_order,
|
||||
)
|
||||
from erpnext.selling.doctype.sales_order.mapper import (
|
||||
make_sales_invoice as create_sales_invoice_from_sales_order,
|
||||
)
|
||||
|
||||
|
||||
def validate_item_locations(pick_list):
|
||||
if not pick_list.locations:
|
||||
frappe.throw(_("Add items in the Item Locations table"))
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_delivery_note(source_name: str, target_doc: str | Document | None = None):
|
||||
return create_delivery(source_name, target_doc, "Delivery Note")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_delivery(source_name: str, target_doc: str | Document | None = None, target: str | None = None):
|
||||
pick_list = frappe.get_doc("Pick List", source_name)
|
||||
target = target or (frappe.flags.args or {}).get("target") or "Delivery Note"
|
||||
validate_item_locations(pick_list)
|
||||
sales_dict = dict()
|
||||
sales_orders = []
|
||||
documents = []
|
||||
for location in pick_list.locations:
|
||||
if location.sales_order:
|
||||
sales_orders.append(
|
||||
frappe.db.get_value(
|
||||
"Sales Order",
|
||||
location.sales_order,
|
||||
[
|
||||
"customer",
|
||||
"name as sales_order",
|
||||
"company_address",
|
||||
"dispatch_address_name",
|
||||
"shipping_address_name",
|
||||
"customer_address",
|
||||
],
|
||||
as_dict=True,
|
||||
)
|
||||
)
|
||||
|
||||
group_key = lambda so: ( # noqa
|
||||
so["customer"],
|
||||
so["company_address"] or "",
|
||||
so["dispatch_address_name"] or "",
|
||||
so["shipping_address_name"] or "",
|
||||
so["customer_address"] or "",
|
||||
)
|
||||
for key, rows in groupby(sorted(sales_orders, key=group_key), key=group_key):
|
||||
sales_dict[key] = {row.sales_order for row in rows}
|
||||
|
||||
if sales_dict:
|
||||
documents.extend(create_delivery_with_so(sales_dict, pick_list, target))
|
||||
|
||||
if not all(item.sales_order for item in pick_list.locations):
|
||||
documents.append(create_delivery_wo_so(pick_list, target, target_doc))
|
||||
|
||||
if len(documents) == 1:
|
||||
return documents[0]
|
||||
else:
|
||||
from frappe.utils import comma_and
|
||||
|
||||
doc_list = [get_link_to_form(target, p.name) for p in documents]
|
||||
frappe.msgprint(_("{0} created").format(comma_and(doc_list)))
|
||||
|
||||
|
||||
def create_dn_wo_so(pick_list, delivery_note=None):
|
||||
return create_delivery_wo_so(pick_list, "Delivery Note", delivery_note)
|
||||
|
||||
|
||||
def create_delivery_wo_so(pick_list, target, target_doc=None):
|
||||
if not target_doc:
|
||||
target_doc = frappe.new_doc(target)
|
||||
|
||||
target_doc.company = pick_list.company
|
||||
|
||||
if not target_doc.customer:
|
||||
target_doc.customer = pick_list.customer
|
||||
|
||||
item_table_mapper_without_so = {
|
||||
"doctype": f"{target} Item",
|
||||
"field_map": {
|
||||
"rate": "rate",
|
||||
"name": "name",
|
||||
"parent": "",
|
||||
},
|
||||
}
|
||||
map_pl_locations(pick_list, item_table_mapper_without_so, target_doc)
|
||||
target_doc.flags.ignore_mandatory = True
|
||||
if target == "Sales Invoice":
|
||||
target_doc.update_stock = 1
|
||||
target_doc.save()
|
||||
|
||||
return target_doc
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_dn_for_pick_lists(
|
||||
source_name: str, target_doc: str | Document | None = None, kwargs: dict | str | None = None
|
||||
):
|
||||
"""Get Items from Multiple Pick Lists and create a Delivery Note for filtered customer"""
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
kwargs = frappe.parse_json(kwargs)
|
||||
|
||||
pick_list = frappe.get_doc("Pick List", source_name)
|
||||
validate_item_locations(pick_list)
|
||||
|
||||
sales_order_arg = kwargs.get("sales_order")
|
||||
customer_arg = kwargs.get("customer")
|
||||
|
||||
if sales_order_arg:
|
||||
sales_orders = {sales_order_arg}
|
||||
else:
|
||||
sales_orders = {row.sales_order for row in pick_list.locations if row.sales_order}
|
||||
|
||||
if customer_arg:
|
||||
sales_orders = frappe.get_all(
|
||||
"Sales Order",
|
||||
filters={"customer": customer_arg, "name": ["in", list(sales_orders)]},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
delivery_note = create_delivery_from_so(
|
||||
pick_list, sales_orders, "Delivery Note", target_doc=target_doc, kwargs=kwargs
|
||||
)
|
||||
|
||||
if not sales_order_arg and not all(item.sales_order for item in pick_list.locations):
|
||||
if isinstance(delivery_note, str):
|
||||
delivery_note = frappe.get_doc(frappe.parse_json(delivery_note))
|
||||
|
||||
delivery_note = create_delivery_wo_so(pick_list, "Delivery Note", delivery_note)
|
||||
|
||||
return delivery_note
|
||||
|
||||
|
||||
def create_dn_with_so(sales_dict, pick_list):
|
||||
return create_delivery_with_so(sales_dict, pick_list, "Delivery Note")
|
||||
|
||||
|
||||
def create_delivery_with_so(sales_dict, pick_list, target):
|
||||
"""Create target document for each customer (based on SO) in a Pick List."""
|
||||
documents = []
|
||||
|
||||
for key in sales_dict:
|
||||
document = create_delivery_from_so(pick_list, sales_dict[key], target)
|
||||
if document:
|
||||
document.flags.ignore_mandatory = True
|
||||
# updates packed_items on save
|
||||
# save as multiple customers are possible
|
||||
if target == "Sales Invoice":
|
||||
document.update_stock = 1
|
||||
document.save()
|
||||
documents.append(document)
|
||||
|
||||
return documents
|
||||
|
||||
|
||||
def create_dn_from_so(pick_list, sales_order_list, delivery_note=None, kwargs=None):
|
||||
return create_delivery_from_so(
|
||||
pick_list, sales_order_list, "Delivery Note", target_doc=delivery_note, kwargs=kwargs
|
||||
)
|
||||
|
||||
|
||||
def create_delivery_from_so(pick_list, sales_order_list, target, target_doc=None, kwargs=None):
|
||||
if not sales_order_list:
|
||||
return target_doc
|
||||
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
|
||||
def select_item(d):
|
||||
filtered_items = kwargs.get("filtered_children", [])
|
||||
child_filter = d.name in filtered_items if filtered_items else True
|
||||
return child_filter
|
||||
|
||||
item_table_mapper = {
|
||||
"doctype": f"{target} Item",
|
||||
"field_map": {
|
||||
"rate": "rate",
|
||||
"name": "so_detail",
|
||||
"parent": "against_sales_order" if target == "Delivery Note" else "sales_order",
|
||||
},
|
||||
"condition": lambda doc: abs(doc.delivered_qty) < abs(doc.qty)
|
||||
and doc.delivered_by_supplier != 1
|
||||
and select_item(doc),
|
||||
}
|
||||
|
||||
kwargs = {"skip_item_mapping": True, "ignore_pricing_rule": pick_list.ignore_pricing_rule}
|
||||
|
||||
target_doc = (
|
||||
create_delivery_note_from_sales_order(next(iter(sales_order_list)), target_doc, kwargs=kwargs)
|
||||
if target == "Delivery Note"
|
||||
else create_sales_invoice_from_sales_order(next(iter(sales_order_list)), target_doc, args=kwargs)
|
||||
)
|
||||
|
||||
if not target_doc:
|
||||
return
|
||||
|
||||
for so in sales_order_list:
|
||||
map_pl_locations(pick_list, item_table_mapper, target_doc, so)
|
||||
|
||||
return target_doc
|
||||
|
||||
|
||||
def map_pl_locations(pick_list, item_mapper, target_doc, sales_order=None):
|
||||
for location in pick_list.locations:
|
||||
if location.sales_order != sales_order or location.product_bundle_item:
|
||||
continue
|
||||
|
||||
if location.sales_order_item:
|
||||
sales_order_item = frappe.get_doc("Sales Order Item", location.sales_order_item)
|
||||
else:
|
||||
sales_order_item = None
|
||||
|
||||
source_doc = sales_order_item or location
|
||||
|
||||
child_item = map_child_doc(source_doc, target_doc, item_mapper)
|
||||
|
||||
if child_item:
|
||||
child_item.against_pick_list = pick_list.name
|
||||
child_item.pick_list_item = location.name
|
||||
child_item.warehouse = location.warehouse
|
||||
child_item.qty = flt(location.picked_qty - location.delivered_qty) / (
|
||||
flt(child_item.conversion_factor) or 1
|
||||
)
|
||||
child_item.batch_no = location.batch_no
|
||||
child_item.serial_no = location.serial_no
|
||||
child_item.use_serial_batch_fields = location.use_serial_batch_fields
|
||||
|
||||
if not child_item.qty:
|
||||
target_doc.items.remove(child_item)
|
||||
continue
|
||||
|
||||
update_child_item(source_doc, child_item, target_doc)
|
||||
|
||||
add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order)
|
||||
set_target_missing_values(target_doc)
|
||||
|
||||
target_doc.company = pick_list.company
|
||||
if sales_order:
|
||||
target_doc.customer = frappe.get_value("Sales Order", sales_order, "customer")
|
||||
|
||||
|
||||
def add_product_bundles_to_delivery_note(pick_list, delivery_note, item_mapper, sales_order=None) -> None:
|
||||
return add_product_bundles_to_target(pick_list, delivery_note, item_mapper, sales_order)
|
||||
|
||||
|
||||
def add_product_bundles_to_target(pick_list, target_doc, item_mapper, sales_order=None) -> None:
|
||||
"""Add product bundles found in pick list to target document.
|
||||
|
||||
When mapping pick list items, the bundle item itself isn't part of the
|
||||
locations. Dynamically fetch and add parent bundle item into target document."""
|
||||
product_bundles = pick_list._get_product_bundles()
|
||||
product_bundle_qty_map = pick_list._get_product_bundle_qty_map(product_bundles.values())
|
||||
|
||||
for so_row, value in product_bundles.items():
|
||||
sales_order_item = frappe.get_doc("Sales Order Item", so_row)
|
||||
if sales_order and sales_order_item.parent != sales_order:
|
||||
continue
|
||||
|
||||
target_bundle_item = map_child_doc(sales_order_item, target_doc, item_mapper)
|
||||
target_bundle_item.qty = pick_list._compute_picked_qty_for_bundle(
|
||||
so_row, product_bundle_qty_map[value.item_code]
|
||||
)
|
||||
target_bundle_item.pick_list_item = value.pick_list_item
|
||||
target_bundle_item.against_pick_list = pick_list.name
|
||||
update_child_item(sales_order_item, target_bundle_item, target_doc)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_stock_entry(pick_list: str | dict):
|
||||
pick_list = frappe.get_doc(frappe.parse_json(pick_list))
|
||||
validate_item_locations(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")
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def update_delivery_note_item(source, target, delivery_note):
|
||||
return update_child_item(source, target, delivery_note)
|
||||
|
||||
|
||||
def update_child_item(source, target, target_doc):
|
||||
cost_center = frappe.db.get_value("Project", target_doc.project, "cost_center")
|
||||
if not cost_center:
|
||||
cost_center = get_cost_center(source.item_code, "Item", target_doc.company)
|
||||
|
||||
if not cost_center:
|
||||
cost_center = get_cost_center(source.item_group, "Item Group", target_doc.company)
|
||||
|
||||
target.cost_center = cost_center
|
||||
|
||||
|
||||
def get_cost_center(for_item, from_doctype, company):
|
||||
"""Returns Cost Center for Item or Item Group"""
|
||||
return frappe.db.get_value(
|
||||
"Item Default",
|
||||
fieldname=["buying_cost_center"],
|
||||
filters={"parent": for_item, "parenttype": from_doctype, "company": company},
|
||||
)
|
||||
|
||||
|
||||
def set_delivery_note_missing_values(target):
|
||||
return set_target_missing_values(target)
|
||||
|
||||
|
||||
def set_target_missing_values(target):
|
||||
target.run_method("set_missing_values")
|
||||
target.run_method("set_po_nos")
|
||||
target.run_method("calculate_taxes_and_totals")
|
||||
|
||||
|
||||
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
|
||||
stock_entry.bom_no = work_order.bom_no
|
||||
stock_entry.use_multi_level_bom = work_order.use_multi_level_bom
|
||||
stock_entry.fg_completed_qty = 0
|
||||
if work_order.bom_no:
|
||||
stock_entry.inspection_required = frappe.db.get_value("BOM", work_order.bom_no, "inspection_required")
|
||||
|
||||
is_wip_warehouse_group = frappe.db.get_value("Warehouse", work_order.wip_warehouse, "is_group")
|
||||
if not (is_wip_warehouse_group and work_order.skip_transfer):
|
||||
wip_warehouse = work_order.wip_warehouse
|
||||
else:
|
||||
wip_warehouse = None
|
||||
stock_entry.to_warehouse = wip_warehouse
|
||||
|
||||
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
|
||||
|
||||
stock_entry.append("items", item)
|
||||
|
||||
return 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(
|
||||
"Material Request Item", location.material_request_item, "warehouse"
|
||||
)
|
||||
item = frappe._dict()
|
||||
update_common_item_properties(item, location)
|
||||
item.t_warehouse = target_warehouse
|
||||
stock_entry.append("items", item)
|
||||
|
||||
return 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)
|
||||
|
||||
stock_entry.append("items", item)
|
||||
|
||||
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 = 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
|
||||
item.material_request = location.material_request
|
||||
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
|
||||
@@ -242,7 +242,7 @@ class RepostItemValuation(Document):
|
||||
def clear_attachment(self):
|
||||
if attachments := get_attachments(self.doctype, self.name):
|
||||
attachment = attachments[0]
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True)
|
||||
frappe.delete_doc("File", attachment.name, ignore_permissions=True, force=True)
|
||||
|
||||
if self.reposting_data_file:
|
||||
self.db_set("reposting_data_file", None)
|
||||
|
||||
@@ -515,6 +515,34 @@ class TestRepostItemValuation(ERPNextTestSuite, StockTestMixin):
|
||||
)
|
||||
)
|
||||
|
||||
def test_clear_attachment_skips_referenced_data_file(self):
|
||||
riv = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Repost Item Valuation",
|
||||
"based_on": "Item and Warehouse",
|
||||
"company": "_Test Company",
|
||||
"item_code": "_Test Item",
|
||||
"warehouse": "_Test Warehouse - _TC",
|
||||
"posting_date": today(),
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
|
||||
attached = frappe.get_doc(
|
||||
{
|
||||
"doctype": "File",
|
||||
"file_name": "repost_data.json.gz",
|
||||
"content": "test",
|
||||
"attached_to_doctype": riv.doctype,
|
||||
"attached_to_name": riv.name,
|
||||
}
|
||||
).insert(ignore_permissions=True)
|
||||
riv.db_set("reposting_data_file", attached.file_url)
|
||||
|
||||
riv.clear_attachment()
|
||||
|
||||
self.assertFalse(frappe.db.exists("File", attached.name))
|
||||
self.assertIsNone(frappe.db.get_value("Repost Item Valuation", riv.name, "reposting_data_file"))
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Reposting Settings",
|
||||
{"item_based_reposting": 1, "enable_parallel_reposting": 1, "no_of_parallel_reposting": 2},
|
||||
|
||||
@@ -569,8 +569,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
erpnext.accounts.dimensions.update_dimension(frm, frm.doctype);
|
||||
}
|
||||
|
||||
frm.events.set_route_options_for_new_doc(frm);
|
||||
|
||||
frm.set_df_property(
|
||||
"items",
|
||||
"cannot_add_rows",
|
||||
@@ -583,28 +581,6 @@ frappe.ui.form.on("Stock Entry", {
|
||||
);
|
||||
},
|
||||
|
||||
set_route_options_for_new_doc(frm) {
|
||||
let batch_no_field = frm.get_docfield("items", "batch_no");
|
||||
if (batch_no_field) {
|
||||
batch_no_field.get_route_options_for_new_doc = function (row) {
|
||||
return {
|
||||
item: row.doc.item_code,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
voucher_type: frm.doc.doctype,
|
||||
warehouse: row.doc.s_warehouse || row.doc.t_warehouse,
|
||||
};
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
get_items_from_transit_entry: function (frm) {
|
||||
if (frm.doc.docstatus === 0 && !frm.doc.subcontracting_inward_order) {
|
||||
frm.add_custom_button(
|
||||
@@ -1312,6 +1288,7 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
|
||||
}
|
||||
|
||||
onload_post_render() {
|
||||
super.onload_post_render();
|
||||
var me = this;
|
||||
if (me.frm.doc.__islocal && me.frm.doc.company && !me.frm.doc.amended_from) {
|
||||
me.company();
|
||||
|
||||
@@ -1344,6 +1344,47 @@ class TestStockLedgerEntry(ERPNextTestSuite, StockTestMixin):
|
||||
# receipt2 now sits on a zero base -> 10 (not 0 from a double shift, nor a negative-stock error).
|
||||
self.assertEqual(qty_after(receipt2), 10)
|
||||
|
||||
def test_cancel_shifts_same_timestamp_delivery_notes(self):
|
||||
item = make_item().name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
posting_date = today()
|
||||
posting_time = "10:00:00"
|
||||
|
||||
make_stock_entry(
|
||||
item_code=item,
|
||||
to_warehouse=warehouse,
|
||||
qty=100,
|
||||
rate=10,
|
||||
posting_date=posting_date,
|
||||
posting_time="09:00:00",
|
||||
)
|
||||
|
||||
dns = []
|
||||
for i in range(5):
|
||||
dns.append(
|
||||
create_delivery_note(
|
||||
item_code=item,
|
||||
warehouse=warehouse,
|
||||
qty=20,
|
||||
rate=10 * i,
|
||||
posting_date=posting_date,
|
||||
posting_time=posting_time,
|
||||
)
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
dn = dns[2]
|
||||
dn.cancel()
|
||||
|
||||
expected_qty_after_transaction_of_dns3 = 40
|
||||
qty_after_transaction_of_dns3 = frappe.db.get_value(
|
||||
"Stock Ledger Entry",
|
||||
{"voucher_no": dns[3].name, "is_cancelled": 0},
|
||||
"qty_after_transaction",
|
||||
)
|
||||
|
||||
self.assertEqual(expected_qty_after_transaction_of_dns3, qty_after_transaction_of_dns3)
|
||||
|
||||
def test_get_next_stock_reco_respects_creation_order(self):
|
||||
# A stock reco sharing the exact posting timestamp of the current entry must only count as the
|
||||
# "next" reco when it was created after that entry. A reco created before it actually precedes
|
||||
|
||||
@@ -46,17 +46,6 @@ frappe.ui.form.on("Stock Reconciliation", {
|
||||
};
|
||||
});
|
||||
|
||||
let sbb_field = frm.get_docfield("items", "serial_and_batch_bundle");
|
||||
if (sbb_field) {
|
||||
sbb_field.get_route_options_for_new_doc = (row) => {
|
||||
return {
|
||||
item_code: row.doc.item_code,
|
||||
warehouse: row.doc.warehouse,
|
||||
voucher_type: frm.doc.doctype,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
if (frm.doc.company) {
|
||||
erpnext.queries.setup_queries(frm, "Warehouse", function () {
|
||||
return erpnext.queries.warehouse(frm.doc);
|
||||
|
||||
@@ -325,6 +325,7 @@ class FIFOSlots:
|
||||
del stock_ledger_entries
|
||||
|
||||
self._recompute_moving_average_slots()
|
||||
self._rebalance_negative_batch_slots()
|
||||
|
||||
if not self.filters.get("show_warehouse_wise_stock"):
|
||||
# (Item 1, WH 1), (Item 1, WH 2) => (Item 1)
|
||||
@@ -346,6 +347,33 @@ class FIFOSlots:
|
||||
if is_qty_slot(slot):
|
||||
slot[FIFO_VALUE_INDEX] = flt(slot[FIFO_QTY_INDEX] * rate)
|
||||
|
||||
def _rebalance_negative_batch_slots(self) -> None:
|
||||
for item_dict in self.item_details.values():
|
||||
if item_dict.get("has_batch_no"):
|
||||
self._rebalance_negative_batch_slot_values(item_dict["fifo_queue"])
|
||||
|
||||
def _rebalance_negative_batch_slot_values(self, fifo_queue: list) -> None:
|
||||
"""A batch is one valuation pool, so a slot driven negative by consumption
|
||||
at the pooled rate is stale detail: spread the pool value over its slots."""
|
||||
groups = {}
|
||||
for slot in fifo_queue:
|
||||
if is_batch_slot(slot):
|
||||
key = slot[BATCH_SLOT_BATCH_INDEX] if slot[BATCH_SLOT_VALUATION_INDEX] else None
|
||||
groups.setdefault(key, []).append(slot)
|
||||
|
||||
for slots in groups.values():
|
||||
has_negative_slot = any(
|
||||
flt(slot[BATCH_SLOT_VALUE_INDEX]) < 0 and flt(slot[BATCH_SLOT_QTY_INDEX]) > 0
|
||||
for slot in slots
|
||||
)
|
||||
total_qty = sum(flt(slot[BATCH_SLOT_QTY_INDEX]) for slot in slots)
|
||||
if not has_negative_slot or total_qty <= 0:
|
||||
continue
|
||||
|
||||
rate = sum(flt(slot[BATCH_SLOT_VALUE_INDEX]) for slot in slots) / total_qty
|
||||
for slot in slots:
|
||||
slot[BATCH_SLOT_VALUE_INDEX] = flt(slot[BATCH_SLOT_QTY_INDEX] * rate)
|
||||
|
||||
def _get_bundle_wise_details(self, stock_ledger_entries: list | None) -> tuple[dict, dict]:
|
||||
if stock_ledger_entries is not None:
|
||||
return frappe._dict({}), frappe._dict({})
|
||||
|
||||
@@ -616,6 +616,58 @@ class TestStockAgeing(ERPNextTestSuite):
|
||||
],
|
||||
)
|
||||
|
||||
def test_batch_issue_at_pooled_rate_keeps_slot_values_positive(self):
|
||||
"""Ledger (same wh, batch B): [+10 @ 0, +10 @ 10, -4 @ pooled 5]
|
||||
Consuming the zero-valued head slot at the pooled rate drives it
|
||||
negative; slot values are then rebalanced to the batch pool rate."""
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
item_code = make_item(
|
||||
"Test Stock Ageing Batch Pool Rebalance",
|
||||
{"is_stock_item": 1, "has_batch_no": 1, "valuation_method": "FIFO"},
|
||||
).name
|
||||
|
||||
batch_no = "SA-POOL-REBALANCE-BATCH"
|
||||
if not frappe.db.exists("Batch", batch_no):
|
||||
frappe.get_doc({"doctype": "Batch", "batch_id": batch_no, "item": item_code}).insert(
|
||||
ignore_permissions=True
|
||||
)
|
||||
frappe.db.set_value("Batch", batch_no, "use_batchwise_valuation", 1)
|
||||
|
||||
def make_sle(posting_date, voucher_no, actual_qty, qty_after, stock_value_difference):
|
||||
return frappe._dict(
|
||||
name=item_code,
|
||||
actual_qty=actual_qty,
|
||||
qty_after_transaction=qty_after,
|
||||
stock_value_difference=stock_value_difference,
|
||||
valuation_rate=abs(stock_value_difference / actual_qty) if actual_qty else 0,
|
||||
warehouse="WH 1",
|
||||
posting_date=posting_date,
|
||||
voucher_type="Stock Entry",
|
||||
voucher_no=voucher_no,
|
||||
has_serial_no=False,
|
||||
has_batch_no=True,
|
||||
serial_no=None,
|
||||
batch_no=batch_no,
|
||||
)
|
||||
|
||||
sle = [
|
||||
make_sle("2021-12-01", "001", 10, 10, 0),
|
||||
make_sle("2021-12-02", "002", 10, 20, 100),
|
||||
make_sle("2021-12-03", "003", -4, 16, -20),
|
||||
]
|
||||
|
||||
slots = FIFOSlots(self.filters, sle).generate()
|
||||
queue = slots[item_code]["fifo_queue"]
|
||||
|
||||
self.assertEqual(
|
||||
queue,
|
||||
[
|
||||
[batch_no, 1, 6.0, "2021-12-01", 30.0],
|
||||
[batch_no, 1, 10.0, "2021-12-01", 50.0],
|
||||
],
|
||||
)
|
||||
|
||||
def test_sequential_stock_reco_same_warehouse(self):
|
||||
"""
|
||||
Test back to back stock recos (same warehouse).
|
||||
|
||||
@@ -497,7 +497,9 @@ class update_entries_after:
|
||||
|
||||
self.data = frappe._dict()
|
||||
|
||||
if not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle"):
|
||||
if (not self.repost_doc or not self.args.get("item_wh_wise_last_posted_sle")) and not self.args.get(
|
||||
"cancelled"
|
||||
):
|
||||
self.initialize_previous_data(self.args)
|
||||
|
||||
self.build()
|
||||
@@ -805,10 +807,23 @@ class update_entries_after:
|
||||
|
||||
def process_sle_against_current_timestamp(self):
|
||||
sl_entries = get_sle_against_current_voucher(self.args)
|
||||
if self.args.get("cancelled") and sl_entries:
|
||||
self.seed_previous_sle_for_cancellation(sl_entries[0])
|
||||
for sle in sl_entries:
|
||||
sle["timestamp"] = sle.posting_datetime
|
||||
self.process_sle(sle)
|
||||
|
||||
def seed_previous_sle_for_cancellation(self, anchor_sle):
|
||||
key = (anchor_sle.item_code, anchor_sle.warehouse)
|
||||
if key in self.prev_sle_dict:
|
||||
return
|
||||
|
||||
args = frappe._dict(anchor_sle)
|
||||
args["sle_id"] = args.name
|
||||
prev_sle = get_previous_sle_of_current_voucher(args)
|
||||
if prev_sle:
|
||||
self.prev_sle_dict[key] = prev_sle
|
||||
|
||||
def get_future_entries_to_fix(self):
|
||||
# includes current entry!
|
||||
args = self.data[self.args.warehouse].previous_sle or frappe._dict(
|
||||
|
||||
Reference in New Issue
Block a user