mirror of
https://github.com/frappe/erpnext.git
synced 2026-09-25 14:27:05 +00:00
Compare commits
25 Commits
v16.36.0
...
version-16
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ff89ae15f | ||
|
|
3dd8af58fb | ||
|
|
813b3dd2f3 | ||
|
|
398603ff0b | ||
|
|
b569861136 | ||
|
|
c21ee5ddf7 | ||
|
|
f3c74f4639 | ||
|
|
6686da2cce | ||
|
|
4426228532 | ||
|
|
223adf655f | ||
|
|
544ca623bd | ||
|
|
30baa235e4 | ||
|
|
3a7a7dbd58 | ||
|
|
4fe41c925d | ||
|
|
032fcad61b | ||
|
|
80b26b2f28 | ||
|
|
ede9f11500 | ||
|
|
5306aaf53d | ||
|
|
3e851108f1 | ||
|
|
e55efadd0d | ||
|
|
2387c67952 | ||
|
|
0c02cbaf1e | ||
|
|
ad8f945cce | ||
|
|
f1cdeab601 | ||
|
|
a657adba37 |
13
.github/workflows/linters.yml
vendored
13
.github/workflows/linters.yml
vendored
@@ -23,6 +23,19 @@ jobs:
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
js-unit-tests:
|
||||
name: js unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run JS unit tests
|
||||
run: yarn test:js
|
||||
|
||||
semgrep:
|
||||
name: semgrep
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -6,7 +6,7 @@ import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils.user import is_website_user
|
||||
|
||||
__version__ = "16.36.0"
|
||||
__version__ = "16.26.2"
|
||||
|
||||
|
||||
def get_default_company(user=None):
|
||||
|
||||
@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
|
||||
return {
|
||||
filters: {
|
||||
company: doc.company,
|
||||
is_company_account: 1,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -424,6 +424,7 @@ def apply_pricing_rule(args, doc=None):
|
||||
for item in item_list:
|
||||
args_copy = copy.deepcopy(args)
|
||||
args_copy.update(item)
|
||||
set_transaction_type(args_copy)
|
||||
data = get_pricing_rule_for_item(args_copy, doc=doc)
|
||||
out.append(data)
|
||||
|
||||
|
||||
@@ -501,6 +501,7 @@ class SalesInvoice(SellingController):
|
||||
self.validate_standalone_serial_nos_customer()
|
||||
self.update_stock_reservation_entries()
|
||||
self.update_stock_ledger()
|
||||
self.validate_produced_serial_nos_against_reservation()
|
||||
|
||||
self.split_asset_based_on_sale_qty()
|
||||
|
||||
|
||||
@@ -5296,6 +5296,34 @@ class TestSalesInvoice(ERPNextTestSuite):
|
||||
|
||||
frappe.db.set_value("Company", "_Test Company 1", "cost_center", cost_center)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": 1})
|
||||
def test_update_stock_restricted_to_reserved_produced_serial_nos(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import (
|
||||
make_sales_invoice as make_si_from_so,
|
||||
)
|
||||
from erpnext.stock.doctype.delivery_note.test_delivery_note import (
|
||||
make_so_with_reserved_produced_serial_no,
|
||||
)
|
||||
|
||||
so, reserved, unreserved = make_so_with_reserved_produced_serial_no()
|
||||
|
||||
def make_si(serial_no):
|
||||
si = make_si_from_so(so.name)
|
||||
si.update_stock = 1
|
||||
si.items[0].warehouse = so.items[0].warehouse
|
||||
si.items[0].use_serial_batch_fields = 1
|
||||
si.items[0].serial_no = serial_no
|
||||
return si.save()
|
||||
|
||||
frappe.db.savepoint("unreserved_serial_no")
|
||||
si = make_si(unreserved[0])
|
||||
self.assertRaises(frappe.ValidationError, si.submit)
|
||||
frappe.db.rollback(save_point="unreserved_serial_no")
|
||||
|
||||
si = make_si(reserved[0])
|
||||
si.submit()
|
||||
self.assertEqual(get_serial_nos_from_bundle(si.items[0].serial_and_batch_bundle), reserved)
|
||||
|
||||
|
||||
def make_item_for_si(item_code, properties=None):
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -423,7 +423,15 @@ def get_invoices(filters, additional_query_columns):
|
||||
if filters.get("supplier"):
|
||||
query = query.where(pi.supplier == filters.supplier)
|
||||
if filters.get("supplier_group"):
|
||||
query = query.where(pi.supplier_group == filters.supplier_group)
|
||||
# read the group from the supplier master, to match the Supplier Group column
|
||||
supplier = frappe.qb.DocType("Supplier")
|
||||
query = query.where(
|
||||
pi.supplier.isin(
|
||||
frappe.qb.from_(supplier)
|
||||
.select(supplier.name)
|
||||
.where(supplier.supplier_group == filters.supplier_group)
|
||||
)
|
||||
)
|
||||
|
||||
query = get_conditions(filters, query, "Purchase Invoice")
|
||||
|
||||
|
||||
@@ -117,6 +117,23 @@ class TestPurchaseRegister(ERPNextTestSuite):
|
||||
self.assertEqual(first_row.credit, 600)
|
||||
self.assertEqual(first_row.balance, 500)
|
||||
|
||||
def test_supplier_group_filter_uses_supplier_master(self):
|
||||
# invoices created before the supplier_group field existed have it blank
|
||||
pi = make_purchase_invoice()
|
||||
pi.db_set("supplier_group", None, update_modified=False)
|
||||
supplier_group = frappe.db.get_value("Supplier", pi.supplier, "supplier_group")
|
||||
|
||||
filters = frappe._dict(
|
||||
company="_Test Company 6",
|
||||
from_date=add_months(today(), -1),
|
||||
to_date=today(),
|
||||
supplier_group=supplier_group,
|
||||
)
|
||||
rows = [frappe._dict(row) for row in execute(filters)[1] if row.get("voucher_no") == pi.name]
|
||||
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].supplier_group, supplier_group)
|
||||
|
||||
|
||||
def make_purchase_invoice():
|
||||
from erpnext.accounts.doctype.account.test_account import create_account
|
||||
|
||||
@@ -796,6 +796,25 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
po = create_purchase_order(qty=3.4, do_not_save=True)
|
||||
self.assertRaises(UOMMustBeIntegerError, po.insert)
|
||||
|
||||
def test_uom_integer_check_tolerates_conversion_dust(self):
|
||||
from erpnext.utilities.transaction_base import UOMMustBeIntegerError
|
||||
|
||||
item_doc = make_item(properties={"stock_uom": "Nos"})
|
||||
item_doc.append("uoms", {"uom": "Kg", "conversion_factor": 0.6})
|
||||
item_doc.save()
|
||||
item = item_doc.name
|
||||
|
||||
precision = frappe.get_precision("Purchase Order Item", "stock_qty")
|
||||
po = create_purchase_order(item_code=item, qty=flt(2000 / 0.6, precision), do_not_save=1)
|
||||
po.items[0].uom = "Kg"
|
||||
po.items[0].conversion_factor = 0.6
|
||||
po.insert()
|
||||
|
||||
fractional = create_purchase_order(item_code=item, qty=3333.9, do_not_save=1)
|
||||
fractional.items[0].uom = "Kg"
|
||||
fractional.items[0].conversion_factor = 0.6
|
||||
self.assertRaises(UOMMustBeIntegerError, fractional.insert)
|
||||
|
||||
def test_ordered_qty_for_closing_po(self):
|
||||
bin = frappe.get_all(
|
||||
"Bin",
|
||||
|
||||
@@ -16,6 +16,7 @@ from pypika import Order
|
||||
|
||||
import erpnext
|
||||
from erpnext.accounts.utils import build_qb_match_conditions
|
||||
from erpnext.stock.doctype.item.item_search import get_item_search_candidates
|
||||
from erpnext.stock.get_item_details import ItemDetailsCtx, _get_item_tax_template
|
||||
from erpnext.stock.utils import get_combine_datetime
|
||||
|
||||
@@ -176,7 +177,15 @@ def tax_account_query(doctype, txt, searchfield, start, page_len, filters):
|
||||
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=False):
|
||||
def item_query(
|
||||
doctype: str,
|
||||
txt: str,
|
||||
searchfield: str,
|
||||
start: int,
|
||||
page_len: int,
|
||||
filters: dict | str | None,
|
||||
as_dict: bool = False,
|
||||
):
|
||||
doctype = "Item"
|
||||
conditions = []
|
||||
|
||||
@@ -207,6 +216,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
]
|
||||
if field not in searchfields
|
||||
]
|
||||
searched_fields = list(searchfields)
|
||||
searchfields = " or ".join([field + " like %(txt)s" for field in searchfields])
|
||||
|
||||
if filters and isinstance(filters, dict):
|
||||
@@ -263,7 +273,17 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
if frappe.db.estimate_count(doctype) < 50000:
|
||||
# scan description only if items are less than 50000
|
||||
description_cond = "or tabItem.description LIKE %(txt)s"
|
||||
searched_fields.append("description")
|
||||
|
||||
candidate_cond = ""
|
||||
candidates = get_item_search_candidates(txt, searched_fields)
|
||||
if candidates is not None:
|
||||
if not candidates:
|
||||
return [] if as_dict else ()
|
||||
|
||||
candidate_cond = "and tabItem.name in %(candidates)s"
|
||||
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-sql-format-injection
|
||||
return frappe.db.sql(
|
||||
"""select
|
||||
tabItem.name {columns}
|
||||
@@ -274,7 +294,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
and (tabItem.end_of_life > %(today)s or ifnull(tabItem.end_of_life, '0000-00-00')='0000-00-00')
|
||||
and ({scond} or tabItem.item_code IN (select parent from `tabItem Barcode` where barcode LIKE %(txt)s)
|
||||
{description_cond})
|
||||
{fcond} {mcond}
|
||||
{fcond} {mcond} {candidate_cond}
|
||||
order by
|
||||
if(locate(%(_txt)s, name), locate(%(_txt)s, name), 99999),
|
||||
if(locate(%(_txt)s, item_name), locate(%(_txt)s, item_name), 99999),
|
||||
@@ -286,6 +306,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
fcond=get_filters_cond(doctype, filters, conditions).replace("%", "%%"),
|
||||
mcond=get_match_cond(doctype).replace("%", "%%"),
|
||||
description_cond=description_cond,
|
||||
candidate_cond=candidate_cond,
|
||||
),
|
||||
{
|
||||
"today": nowdate(),
|
||||
@@ -293,6 +314,7 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
|
||||
"_txt": txt.replace("%", ""),
|
||||
"start": start,
|
||||
"page_len": page_len,
|
||||
"candidates": tuple(candidates or ()),
|
||||
},
|
||||
as_dict=as_dict,
|
||||
)
|
||||
|
||||
@@ -904,6 +904,77 @@ class SellingController(StockController):
|
||||
title=_("Not Allowed"),
|
||||
)
|
||||
|
||||
def validate_produced_serial_nos_against_reservation(self):
|
||||
"""Restrict delivery to the serial nos reserved for a Sales Order Item with ensure delivery by serial no."""
|
||||
|
||||
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_serial_nos_for_voucher_detail_nos,
|
||||
)
|
||||
|
||||
if self.is_return or not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
|
||||
return
|
||||
|
||||
so_field = "sales_order" if self.doctype == "Sales Invoice" else "against_sales_order"
|
||||
rows = [d for d in self.items if d.get(so_field) and d.so_detail]
|
||||
if not rows:
|
||||
return
|
||||
|
||||
flagged_so_details = frappe.get_all(
|
||||
"Sales Order Item",
|
||||
filters={
|
||||
"name": ("in", [d.so_detail for d in rows]),
|
||||
"ensure_delivery_based_on_produced_serial_no": 1,
|
||||
},
|
||||
pluck="name",
|
||||
)
|
||||
rows = [d for d in rows if d.so_detail in flagged_so_details]
|
||||
if not rows:
|
||||
return
|
||||
|
||||
reserved_serial_nos = get_sre_reserved_serial_nos_for_voucher_detail_nos(
|
||||
"Sales Order", flagged_so_details
|
||||
)
|
||||
bundle_map = dict(
|
||||
frappe.get_all(
|
||||
rows[0].doctype,
|
||||
filters={"name": ("in", [d.name for d in rows])},
|
||||
fields=["name", "serial_and_batch_bundle"],
|
||||
as_list=True,
|
||||
)
|
||||
)
|
||||
bundle_serial_nos = frappe._dict()
|
||||
if bundles := [b for b in bundle_map.values() if b]:
|
||||
for entry in frappe.get_all(
|
||||
"Serial and Batch Entry",
|
||||
filters={"parent": ("in", bundles), "serial_no": ("is", "set")},
|
||||
fields=["parent", "serial_no"],
|
||||
):
|
||||
bundle_serial_nos.setdefault(entry.parent, []).append(entry.serial_no)
|
||||
|
||||
for row in rows:
|
||||
if not reserved_serial_nos.get(row.so_detail):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Delivery of Item {1} is ensured by produced Serial No, but no Serial No is reserved against Sales Order {2}. Reserve the produced Serial Nos from the Sales Order."
|
||||
).format(row.idx, frappe.bold(row.item_code), frappe.bold(row.get(so_field))),
|
||||
title=_("Serial No Not Reserved"),
|
||||
)
|
||||
|
||||
bundle = bundle_map.get(row.name)
|
||||
serial_nos = bundle_serial_nos.get(bundle, []) if bundle else get_serial_nos(row.serial_no)
|
||||
if invalid_serial_nos := [
|
||||
sn for sn in serial_nos if sn not in reserved_serial_nos[row.so_detail]
|
||||
]:
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row #{0}: Serial No {1} is not reserved against Sales Order {2}. Deliver only the Serial Nos produced and reserved for it."
|
||||
).format(
|
||||
row.idx, frappe.bold(", ".join(invalid_serial_nos)), frappe.bold(row.get(so_field))
|
||||
),
|
||||
title=_("Serial No Not Reserved"),
|
||||
)
|
||||
|
||||
def update_stock_reservation_entries(self) -> None:
|
||||
"""Updates Delivered Qty in Stock Reservation Entries."""
|
||||
|
||||
|
||||
@@ -347,6 +347,8 @@ period_closing_doctypes = [
|
||||
"Subcontracting Receipt",
|
||||
]
|
||||
|
||||
sqlite_search = ["erpnext.stock.doctype.item.item_search.ItemSearch"]
|
||||
|
||||
doc_events = {
|
||||
"*": {
|
||||
"validate": [
|
||||
@@ -357,6 +359,10 @@ doc_events = {
|
||||
tuple(period_closing_doctypes): {
|
||||
"validate": "erpnext.accounts.doctype.accounting_period.accounting_period.validate_accounting_period_on_doc_save",
|
||||
},
|
||||
"Item": {
|
||||
"on_update": "erpnext.stock.doctype.item.item_search.reindex_item",
|
||||
"after_rename": "erpnext.stock.doctype.item.item_search.reindex_renamed_item",
|
||||
},
|
||||
"Stock Entry": {
|
||||
"on_submit": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||
"on_cancel": "erpnext.stock.doctype.material_request.material_request.update_completed_and_requested_qty",
|
||||
|
||||
@@ -1508,6 +1508,7 @@ def get_bom_items_as_dict(
|
||||
bom_item.idx,
|
||||
item.item_name,
|
||||
sum(bom_item.{qty_field}/ifnull(bom.quantity, 1)) * %(qty)s as qty,
|
||||
sum(bom_item.stock_qty/ifnull(bom.quantity, 1)) * %(qty)s as stock_qty,
|
||||
item.image,
|
||||
bom.project,
|
||||
item.stock_uom,
|
||||
@@ -1584,11 +1585,12 @@ def get_bom_items_as_dict(
|
||||
if item.operation:
|
||||
key = (item.item_code, item.operation)
|
||||
|
||||
stock_qty = item.pop("stock_qty")
|
||||
if item.get("is_phantom_item"):
|
||||
data = get_bom_items_as_dict(
|
||||
item.get("bom_no"),
|
||||
company,
|
||||
qty=item.get("qty"),
|
||||
qty=stock_qty,
|
||||
fetch_exploded=fetch_exploded,
|
||||
fetch_secondary_items=fetch_secondary_items,
|
||||
include_non_stock_items=include_non_stock_items,
|
||||
|
||||
@@ -84,6 +84,36 @@ class TestBOM(ERPNextTestSuite):
|
||||
),
|
||||
)
|
||||
|
||||
@timeout
|
||||
def test_get_items_explodes_phantom_row_by_stock_qty(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_items_as_dict
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
|
||||
rm = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
kit = make_item(
|
||||
properties={"is_stock_item": 0, "uoms": [{"uom": "Box", "conversion_factor": 5}]}
|
||||
).name
|
||||
phantom_bom = make_bom(item=kit, raw_materials=[rm], do_not_save=True)
|
||||
phantom_bom.is_phantom_bom = 1
|
||||
phantom_bom.save()
|
||||
phantom_bom.submit()
|
||||
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
bom = make_bom(item=fg_item, raw_materials=[kit], do_not_save=True)
|
||||
bom.items[0].update({"qty": 2, "uom": "Box", "bom_no": phantom_bom.name})
|
||||
bom.save()
|
||||
bom.submit()
|
||||
|
||||
for fetch_qty_in_stock_uom in (True, False):
|
||||
items_dict = get_bom_items_as_dict(
|
||||
bom.name,
|
||||
"_Test Company",
|
||||
qty=1,
|
||||
fetch_exploded=0,
|
||||
fetch_qty_in_stock_uom=fetch_qty_in_stock_uom,
|
||||
)
|
||||
self.assertEqual(flt(items_dict[rm].qty), 10.0)
|
||||
|
||||
@timeout
|
||||
def test_get_bom_diff_checks_both_boms(self):
|
||||
from erpnext.manufacturing.doctype.bom.bom import get_bom_diff
|
||||
|
||||
@@ -1595,6 +1595,99 @@ class TestJobCard(ERPNextTestSuite):
|
||||
8,
|
||||
)
|
||||
|
||||
def test_semi_fg_secondary_items_across_split_job_cards(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
warehouse = "Stores - _TC"
|
||||
rm = make_item("Split JC Scrap RM", {"is_stock_item": 1, "valuation_rate": 100}).name
|
||||
fg = make_item("Split JC Scrap FG", {"is_stock_item": 1}).name
|
||||
scrap = make_item("Split JC Scrap", {"is_stock_item": 1, "valuation_rate": 5}).name
|
||||
|
||||
fg_bom = frappe.new_doc(
|
||||
"BOM",
|
||||
company="_Test Company",
|
||||
item=fg,
|
||||
quantity=1,
|
||||
with_operations=1,
|
||||
track_semi_finished_goods=1,
|
||||
)
|
||||
fg_bom.append("items", {"item_code": rm, "qty": 1, "operation_row_id": 1})
|
||||
fg_bom.append("secondary_items", {"item_code": scrap, "qty": 1, "secondary_item_type": "Scrap"})
|
||||
|
||||
operation = {
|
||||
"operation": "Split JC Scrap Op",
|
||||
"workstation": "_Test Workstation A",
|
||||
"finished_good": fg,
|
||||
"finished_good_qty": 1,
|
||||
"is_final_finished_good": 1,
|
||||
"sequence_id": 1,
|
||||
"time_in_mins": 60,
|
||||
"source_warehouse": warehouse,
|
||||
"fg_warehouse": warehouse,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
make_workstation(operation)
|
||||
make_operation(operation)
|
||||
fg_bom.append("operations", operation)
|
||||
fg_bom.insert()
|
||||
fg_bom.submit()
|
||||
|
||||
work_order = make_wo_order_test_record(
|
||||
item=fg,
|
||||
qty=10,
|
||||
source_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
bom_no=fg_bom.name,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
work_order.operations[0].time_in_mins = 60
|
||||
work_order.save()
|
||||
work_order.submit()
|
||||
|
||||
make_stock_entry(item_code=rm, target=warehouse, qty=100, basic_rate=100)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name}, "name")
|
||||
)
|
||||
job_card.for_quantity = 5
|
||||
job_card.secondary_items[0].stock_qty = 5
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-01 08:00:00", "to_time": "2024-02-01 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item()).submit()
|
||||
|
||||
make_job_card(
|
||||
work_order.name,
|
||||
[
|
||||
{
|
||||
"name": work_order.operations[0].name,
|
||||
"operation": "Split JC Scrap Op",
|
||||
"qty": 5,
|
||||
"pending_qty": 5,
|
||||
"skip_material_transfer": 1,
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
job_card = frappe.get_doc(
|
||||
"Job Card", frappe.db.get_value("Job Card", {"work_order": work_order.name, "docstatus": 0})
|
||||
)
|
||||
job_card.append(
|
||||
"time_logs",
|
||||
{"from_time": "2024-02-02 08:00:00", "to_time": "2024-02-02 09:00:00", "completed_qty": 5},
|
||||
)
|
||||
job_card.save()
|
||||
job_card.submit()
|
||||
|
||||
stock_entry = frappe.get_doc(job_card.make_stock_entry_for_semi_fg_item())
|
||||
scrap_qty = sum(row.qty for row in stock_entry.items if row.item_code == scrap)
|
||||
self.assertEqual(scrap_qty, 5)
|
||||
|
||||
def test_semi_fg_process_loss_rolls_up_to_work_order(self):
|
||||
from erpnext.manufacturing.doctype.operation.test_operation import make_operation
|
||||
from erpnext.stock.doctype.item.test_item import make_item
|
||||
|
||||
@@ -595,8 +595,14 @@ class ProductionPlan(Document):
|
||||
data.db_update()
|
||||
|
||||
self.calculate_total_produced_qty()
|
||||
self.update_status_and_bin_qty()
|
||||
|
||||
def update_status_and_bin_qty(self):
|
||||
previous_status = self.status
|
||||
self.set_status()
|
||||
self.db_set("status", self.status)
|
||||
if previous_status != self.status and "Completed" in (previous_status, self.status):
|
||||
self.update_bin_qty()
|
||||
|
||||
def on_submit(self):
|
||||
self.update_bin_qty()
|
||||
@@ -678,11 +684,7 @@ class ProductionPlan(Document):
|
||||
return so_wise_planned_qty
|
||||
|
||||
def update_bin_qty(self):
|
||||
for d in self.mr_items:
|
||||
if d.warehouse:
|
||||
bin_name = get_or_make_bin(d.item_code, d.warehouse)
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_production_plan()
|
||||
self.update_raw_material_bin_qty()
|
||||
|
||||
for d in self.sub_assembly_items:
|
||||
if d.fg_warehouse and d.type_of_manufacturing == "In House":
|
||||
@@ -690,6 +692,13 @@ class ProductionPlan(Document):
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_for_sub_assembly()
|
||||
|
||||
def update_raw_material_bin_qty(self, item_codes: set[str] | None = None):
|
||||
for d in self.mr_items:
|
||||
if d.warehouse and (item_codes is None or d.item_code in item_codes):
|
||||
bin_name = get_or_make_bin(d.item_code, d.warehouse)
|
||||
bin = frappe.get_doc("Bin", bin_name, for_update=True)
|
||||
bin.update_reserved_qty_for_production_plan()
|
||||
|
||||
def delete_draft_work_order(self):
|
||||
for d in frappe.get_all(
|
||||
"Work Order", fields=["name"], filters={"docstatus": 0, "production_plan": ("=", self.name)}
|
||||
@@ -700,6 +709,9 @@ class ProductionPlan(Document):
|
||||
def set_status(self, close: bool | None = None, update_bin: bool = False):
|
||||
self.check_permission("write")
|
||||
|
||||
if close is None and self.status == "Closed":
|
||||
return
|
||||
|
||||
self.status = {0: "Draft", 1: "Submitted", 2: "Cancelled"}.get(self.docstatus)
|
||||
|
||||
if close:
|
||||
@@ -2161,51 +2173,88 @@ def set_default_warehouses(row, default_warehouses):
|
||||
|
||||
|
||||
def get_reserved_qty_for_production_plan(item_code, warehouse):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import get_reserved_qty_for_production
|
||||
plan_reservations = _get_plan_reservations(item_code)
|
||||
if not plan_reservations:
|
||||
return None
|
||||
|
||||
work_order_reservations = _get_work_order_reservations(item_code, list(plan_reservations))
|
||||
reserved_qty = 0.0
|
||||
for plan, plan_qty_by_warehouse in plan_reservations.items():
|
||||
reserved_qty += _get_remaining_reserved_qty(
|
||||
plan_qty_by_warehouse, work_order_reservations.get(plan, {}), warehouse
|
||||
)
|
||||
|
||||
return reserved_qty
|
||||
|
||||
|
||||
def _get_remaining_reserved_qty(plan_qty_by_warehouse, work_order_qty_by_warehouse, warehouse):
|
||||
remaining_qty_by_warehouse = {
|
||||
plan_warehouse: max(qty - work_order_qty_by_warehouse.get(plan_warehouse, 0.0), 0.0)
|
||||
for plan_warehouse, qty in plan_qty_by_warehouse.items()
|
||||
}
|
||||
total_remaining_qty = sum(remaining_qty_by_warehouse.values())
|
||||
if not total_remaining_qty:
|
||||
return 0.0
|
||||
|
||||
matched_qty = sum(plan_qty_by_warehouse.values()) - total_remaining_qty
|
||||
unmatched_qty = min(sum(work_order_qty_by_warehouse.values()) - matched_qty, total_remaining_qty)
|
||||
remaining_qty = remaining_qty_by_warehouse.get(warehouse, 0.0)
|
||||
return remaining_qty - remaining_qty * unmatched_qty / total_remaining_qty
|
||||
|
||||
|
||||
def _get_plan_reservations(item_code):
|
||||
table = frappe.qb.DocType("Production Plan")
|
||||
child = frappe.qb.DocType("Material Request Plan Item")
|
||||
|
||||
non_completed_production_plans = get_non_completed_production_plans()
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(table)
|
||||
.inner_join(child)
|
||||
.on(table.name == child.parent)
|
||||
.select(
|
||||
table.name.as_("production_plan"),
|
||||
child.warehouse,
|
||||
Sum(
|
||||
Case().when(child.quantity == 0, child.required_bom_qty).else_(child.quantity)
|
||||
* child.conversion_factor
|
||||
)
|
||||
).as_("reserved_qty"),
|
||||
)
|
||||
.where(
|
||||
(table.docstatus == 1)
|
||||
& (child.item_code == item_code)
|
||||
& (child.warehouse == warehouse)
|
||||
& (table.status.notin(["Completed", "Closed"]))
|
||||
)
|
||||
.groupby(table.name, child.warehouse)
|
||||
)
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
if non_completed_production_plans:
|
||||
query = query.where(table.name.isin(non_completed_production_plans))
|
||||
|
||||
query = query.run()
|
||||
|
||||
if not query or query[0][0] is None:
|
||||
return None
|
||||
|
||||
reserved_qty_for_production_plan = flt(query[0][0])
|
||||
|
||||
reserved_qty_for_production = flt(
|
||||
get_reserved_qty_for_production(
|
||||
item_code, warehouse, non_completed_production_plans, check_production_plan=True
|
||||
def _get_work_order_reservations(item_code, plan_names):
|
||||
work_order = frappe.qb.DocType("Work Order")
|
||||
work_order_item = frappe.qb.DocType("Work Order Item")
|
||||
query = (
|
||||
frappe.qb.from_(work_order)
|
||||
.from_(work_order_item)
|
||||
.select(
|
||||
work_order.production_plan,
|
||||
work_order_item.source_warehouse.as_("warehouse"),
|
||||
Sum(work_order_item.required_qty).as_("reserved_qty"),
|
||||
)
|
||||
.where(
|
||||
(work_order_item.item_code == item_code)
|
||||
& (work_order_item.parent == work_order.name)
|
||||
& (work_order.docstatus == 1)
|
||||
& (IfNull(work_order_item.source_warehouse, "") != "")
|
||||
& work_order.production_plan.isin(plan_names)
|
||||
)
|
||||
.groupby(work_order.production_plan, work_order_item.source_warehouse)
|
||||
)
|
||||
return _group_by_plan_and_warehouse(query)
|
||||
|
||||
if reserved_qty_for_production > reserved_qty_for_production_plan:
|
||||
return 0.0
|
||||
|
||||
return reserved_qty_for_production_plan - reserved_qty_for_production
|
||||
def _group_by_plan_and_warehouse(query):
|
||||
reservations = {}
|
||||
for row in query.run(as_dict=True):
|
||||
reservations.setdefault(row.production_plan, {})[row.warehouse] = flt(row.reserved_qty)
|
||||
return reservations
|
||||
|
||||
|
||||
def get_non_completed_production_plans():
|
||||
|
||||
@@ -2004,6 +2004,180 @@ class TestProductionPlan(ERPNextTestSuite):
|
||||
|
||||
self.assertEqual(after_qty, before_qty)
|
||||
|
||||
def test_plan_reservation_offsets_work_order_in_another_warehouse(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
plan_warehouse = "_Test Warehouse - _TC"
|
||||
work_order_warehouse = "_Test Warehouse 1 - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
|
||||
|
||||
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
|
||||
bin_name = frappe.db.get_value("Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "name")
|
||||
bin = frappe.get_doc("Bin", bin_name)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
|
||||
projected_qty = bin.projected_qty
|
||||
|
||||
work_order = submit_work_order_from_plan(plan, 5, work_order_warehouse)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 5)
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
self.assertEqual(bin.projected_qty, projected_qty + 5)
|
||||
|
||||
work_order.cancel()
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 10)
|
||||
self.assertEqual(bin.projected_qty, projected_qty)
|
||||
|
||||
def test_plan_reservation_ignores_work_orders_of_other_plans(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
first_warehouse = "_Test Warehouse - _TC"
|
||||
second_warehouse = "_Test Warehouse 1 - _TC"
|
||||
plans = []
|
||||
for warehouse in (first_warehouse, second_warehouse):
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
plans.append(
|
||||
create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
)
|
||||
|
||||
submit_work_order_from_plan(plans[1], 10, first_warehouse)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, first_warehouse), 10)
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, second_warehouse), 0)
|
||||
|
||||
def test_plan_reservation_kept_for_work_order_without_source_warehouse(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
get_reserved_qty_for_production_plan,
|
||||
)
|
||||
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
plan_warehouse = "_Test Warehouse - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=plan_warehouse)
|
||||
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
|
||||
submit_work_order_from_plan(plan, 5, None)
|
||||
|
||||
self.assertEqual(get_reserved_qty_for_production_plan(rm_item, plan_warehouse), 10)
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": rm_item, "warehouse": plan_warehouse}, "reserved_qty_for_production_plan"
|
||||
),
|
||||
10,
|
||||
)
|
||||
|
||||
def test_plan_reservation_released_when_plan_completes(self):
|
||||
plan, work_order = make_plan_with_sub_assembly()
|
||||
work_order.submit()
|
||||
make_stock_entry(
|
||||
item_code=plan.sub_assembly_items[0].production_item,
|
||||
qty=5,
|
||||
rate=10,
|
||||
target=work_order.source_warehouse,
|
||||
)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
raw_material = plan.mr_items[0]
|
||||
bin = frappe.get_doc(
|
||||
"Bin", {"item_code": raw_material.item_code, "warehouse": raw_material.warehouse}
|
||||
)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
manufacture = frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5))
|
||||
manufacture.submit()
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
|
||||
|
||||
manufacture.cancel()
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
def test_plan_reservation_released_when_last_work_order_is_closed(self):
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import close_work_order
|
||||
|
||||
plan, work_order = make_plan_with_sub_assembly()
|
||||
work_order.submit()
|
||||
plan.make_work_order()
|
||||
sub_assembly = plan.sub_assembly_items[0]
|
||||
sub_assembly_work_order = frappe.get_doc(
|
||||
"Work Order", {"production_plan": plan.name, "production_item": sub_assembly.production_item}
|
||||
)
|
||||
sub_assembly_work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
sub_assembly_work_order.submit()
|
||||
|
||||
make_stock_entry(
|
||||
item_code=sub_assembly.production_item, qty=5, rate=10, target=work_order.source_warehouse
|
||||
)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
|
||||
bin = frappe.get_doc(
|
||||
"Bin", {"item_code": sub_assembly.production_item, "warehouse": sub_assembly.fg_warehouse}
|
||||
)
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 5)
|
||||
|
||||
close_work_order(sub_assembly_work_order.name, "Closed")
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Completed")
|
||||
bin.reload()
|
||||
self.assertEqual(bin.reserved_qty_for_production_plan, 0)
|
||||
|
||||
def test_closed_plan_stays_closed_on_production(self):
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
plan = create_production_plan(item_code=fg_item, planned_qty=10, ignore_existing_ordered_qty=1)
|
||||
work_order = submit_work_order_from_plan(plan, 5, warehouse)
|
||||
plan.set_status(close=True)
|
||||
|
||||
make_stock_entry(item_code=rm_item, qty=5, rate=10, target=warehouse)
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Material Transfer for Manufacture", 5)).submit()
|
||||
frappe.get_doc(make_se_from_wo(work_order.name, "Manufacture", 5)).submit()
|
||||
|
||||
self.assertEqual(frappe.db.get_value("Production Plan", plan.name, "status"), "Closed")
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Bin", {"item_code": rm_item, "warehouse": warehouse}, "reserved_qty_for_production_plan"
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
def test_plan_reservation_offsets_are_distributed_across_warehouses(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.production_plan import (
|
||||
_get_remaining_reserved_qty,
|
||||
)
|
||||
|
||||
reservations = {"Warehouse A": 6, "Warehouse B": 4}
|
||||
cases = [
|
||||
({"Warehouse A": 5}, 1, 4),
|
||||
({"Warehouse C": 5}, 3, 2),
|
||||
({"Warehouse A": 8}, 0, 2),
|
||||
({"Warehouse C": 20}, 0, 0),
|
||||
]
|
||||
for work_order_reservations, warehouse_a_qty, warehouse_b_qty in cases:
|
||||
with self.subTest(work_order_reservations=work_order_reservations):
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse A"),
|
||||
warehouse_a_qty,
|
||||
)
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty(reservations, work_order_reservations, "Warehouse B"),
|
||||
warehouse_b_qty,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
_get_remaining_reserved_qty({"Warehouse A": 5}, {"Warehouse B": 4}, "Warehouse A"), 1
|
||||
)
|
||||
|
||||
def test_reserved_qty_for_production_plan_for_less_rm_qty(self):
|
||||
from erpnext.stock.utils import get_or_make_bin
|
||||
|
||||
@@ -3972,6 +4146,51 @@ def create_production_plan(**args):
|
||||
return pln
|
||||
|
||||
|
||||
def submit_work_order_from_plan(plan, qty, source_warehouse):
|
||||
production_item = next(iter(plan.get_production_items().values()))
|
||||
production_item["qty"] = qty
|
||||
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
|
||||
work_order.source_warehouse = source_warehouse
|
||||
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
work_order.fg_warehouse = "_Test Warehouse - _TC"
|
||||
for item in work_order.required_items:
|
||||
item.source_warehouse = source_warehouse
|
||||
work_order.submit()
|
||||
return work_order
|
||||
|
||||
|
||||
def make_plan_with_sub_assembly():
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
rm_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
sub_assembly_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
fg_item = make_item(properties={"is_stock_item": 1, "valuation_rate": 10}).name
|
||||
make_bom(item=sub_assembly_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
make_bom(item=fg_item, raw_materials=[sub_assembly_item], source_warehouse=warehouse)
|
||||
|
||||
plan = create_production_plan(
|
||||
item_code=fg_item,
|
||||
planned_qty=5,
|
||||
ignore_existing_ordered_qty=1,
|
||||
sub_assembly_warehouse="_Test Warehouse 1 - _TC",
|
||||
skip_getting_mr_items=1,
|
||||
do_not_submit=1,
|
||||
)
|
||||
plan.get_sub_assembly_items()
|
||||
for row in get_items_for_material_requests(plan.as_dict()):
|
||||
plan.append("mr_items", row)
|
||||
plan.submit()
|
||||
|
||||
production_item = next(iter(plan.get_production_items().values()))
|
||||
production_item["use_multi_level_bom"] = 0
|
||||
work_order = frappe.get_doc("Work Order", plan.create_work_order(production_item))
|
||||
work_order.source_warehouse = warehouse
|
||||
work_order.wip_warehouse = "_Test Warehouse 2 - _TC"
|
||||
work_order.fg_warehouse = warehouse
|
||||
for item in work_order.required_items:
|
||||
item.source_warehouse = warehouse
|
||||
return plan, work_order
|
||||
|
||||
|
||||
def make_bom(**args):
|
||||
args = frappe._dict(args)
|
||||
|
||||
|
||||
@@ -3859,6 +3859,126 @@ class TestWorkOrder(ERPNextTestSuite):
|
||||
|
||||
self.assertRaises(frappe.ValidationError, transfer_entry.submit)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
|
||||
)
|
||||
def test_transfer_of_other_batch_keeps_reservation_open(self):
|
||||
production_item = "Test Other Batch Release FG"
|
||||
rm_item = "Test Other Batch Release RM"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(
|
||||
rm_item,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"batch_number_series": "TST-BATCH-OTH-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
|
||||
|
||||
batches = []
|
||||
for _ in range(2):
|
||||
receipt = test_stock_entry.make_stock_entry(
|
||||
item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100
|
||||
)
|
||||
batches.append(get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle))
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
|
||||
)
|
||||
sre = frappe.get_doc(
|
||||
"Stock Reservation Entry",
|
||||
{"voucher_no": wo.name, "warehouse": source_warehouse, "docstatus": 1},
|
||||
)
|
||||
reserved_batch = sre.sb_entries[0].batch_no
|
||||
other_batch = batches[1] if batches[0] == reserved_batch else batches[0]
|
||||
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50))
|
||||
for row in transfer.items:
|
||||
row.update(
|
||||
{"batch_no": other_batch, "use_serial_batch_fields": 1, "serial_and_batch_bundle": None}
|
||||
)
|
||||
transfer.insert()
|
||||
transfer.submit()
|
||||
|
||||
sre.reload()
|
||||
self.assertEqual(sre.status, "Reserved")
|
||||
self.assertEqual(sre.transferred_qty, 0)
|
||||
self.assertEqual([(row.batch_no, row.delivered_qty) for row in sre.sb_entries], [(reserved_batch, 0)])
|
||||
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Manufacture", 50)).submit()
|
||||
wo.reload()
|
||||
self.assertEqual(wo.required_items[0].stock_reserved_qty, 50)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "auto_reserve_serial_and_batch": 1},
|
||||
)
|
||||
@ERPNextTestSuite.change_settings("Manufacturing Settings", {"material_consumption": 1})
|
||||
def test_material_consumption_uses_batch_reservation(self):
|
||||
production_item = "Test Consumption Reservation FG"
|
||||
rm_item = "Test Consumption Reservation RM"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(
|
||||
rm_item,
|
||||
{
|
||||
"is_stock_item": 1,
|
||||
"has_batch_no": 1,
|
||||
"batch_number_series": "TST-BATCH-MCM-.###",
|
||||
"create_new_batch": 1,
|
||||
},
|
||||
)
|
||||
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
|
||||
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=50, basic_rate=100)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item, qty=50, reserve_stock=1, source_warehouse=source_warehouse
|
||||
)
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 50)).submit()
|
||||
frappe.get_doc(make_stock_entry(wo.name, "Material Consumption for Manufacture", 50)).submit()
|
||||
|
||||
wip_reservation = frappe.db.get_value(
|
||||
"Stock Reservation Entry",
|
||||
{"voucher_no": wo.name, "warehouse": wo.wip_warehouse, "docstatus": 1},
|
||||
["consumed_qty", "status"],
|
||||
as_dict=True,
|
||||
)
|
||||
self.assertEqual(wip_reservation.consumed_qty, 50)
|
||||
self.assertEqual(wip_reservation.status, "Delivered")
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "allow_negative_stock": 0},
|
||||
)
|
||||
def test_ledger_preview_ignores_own_work_order_reservation(self):
|
||||
from erpnext.controllers.stock_controller import get_stock_ledger_preview
|
||||
|
||||
production_item = "Test Preview Reservation FG"
|
||||
rm_item = "Test Preview Reservation RM"
|
||||
source_warehouse = "Stores - _TC"
|
||||
|
||||
make_item(production_item, {"is_stock_item": 1})
|
||||
make_item(rm_item, {"is_stock_item": 1})
|
||||
make_bom(item=production_item, source_warehouse=source_warehouse, raw_materials=[rm_item])
|
||||
test_stock_entry.make_stock_entry(item_code=rm_item, target=source_warehouse, qty=20, basic_rate=100)
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=production_item, qty=20, reserve_stock=1, source_warehouse=source_warehouse
|
||||
)
|
||||
transfer = frappe.get_doc(make_stock_entry(wo.name, "Material Transfer for Manufacture", 20))
|
||||
transfer.insert()
|
||||
|
||||
transfer.run_method("before_sl_preview")
|
||||
_, sl_data = get_stock_ledger_preview(transfer, frappe._dict(company=transfer.company))
|
||||
|
||||
self.assertEqual(len(sl_data), 2)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "allow_partial_reservation": 1},
|
||||
|
||||
@@ -788,8 +788,6 @@ erpnext.work_order = {
|
||||
);
|
||||
}
|
||||
|
||||
erpnext.work_order.setup_stock_reservation(frm);
|
||||
|
||||
if (!frm.doc.track_semi_finished_goods) {
|
||||
const show_start_btn =
|
||||
frm.doc.skip_transfer || frm.doc.transfer_material_against == "Job Card" ? 0 : 1;
|
||||
@@ -933,11 +931,14 @@ erpnext.work_order = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
erpnext.work_order.setup_stock_reservation(frm);
|
||||
},
|
||||
|
||||
setup_stock_reservation(frm) {
|
||||
if (frm.doc.docstatus === 1 && frm.doc.reserve_stock) {
|
||||
if (
|
||||
!["Closed", "Completed"].includes(frm.doc.status) &&
|
||||
frm.events.has_unreserved_stock(frm) &&
|
||||
(frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty)
|
||||
) {
|
||||
@@ -949,13 +950,11 @@ erpnext.work_order = {
|
||||
}
|
||||
|
||||
if (frm.events.has_reserved_stock(frm)) {
|
||||
if (frm.doc.skip_transfer || frm.doc.material_transferred_for_manufacturing < frm.doc.qty) {
|
||||
frm.add_custom_button(
|
||||
__("Unreserve"),
|
||||
() => erpnext.stock_reservation.unreserve_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
}
|
||||
frm.add_custom_button(
|
||||
__("Unreserve"),
|
||||
() => erpnext.stock_reservation.unreserve_stock(frm),
|
||||
__("Stock Reservation")
|
||||
);
|
||||
|
||||
frm.add_custom_button(
|
||||
__("Reserved Stock"),
|
||||
|
||||
@@ -46,6 +46,8 @@ from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty
|
||||
from erpnext.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company
|
||||
from erpnext.utilities.transaction_base import validate_uom_is_integer
|
||||
|
||||
CONSUMPTION_PURPOSES = ("Manufacture", "Material Consumption for Manufacture")
|
||||
|
||||
|
||||
class OverProductionError(frappe.ValidationError):
|
||||
pass
|
||||
@@ -261,6 +263,9 @@ class WorkOrder(Document):
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
def before_insert(self):
|
||||
self.enable_reserve_stock_for_produced_serial_no()
|
||||
|
||||
def validate(self):
|
||||
self.validate_production_item()
|
||||
if self.bom_no:
|
||||
@@ -328,6 +333,20 @@ class WorkOrder(Document):
|
||||
title=_("Target Warehouse Reservation Error"),
|
||||
)
|
||||
|
||||
def enable_reserve_stock_for_produced_serial_no(self):
|
||||
"""Reserve the produced serial nos for a Sales Order Item with ensure delivery by serial no."""
|
||||
|
||||
if self.reserve_stock or not self.sales_order_item:
|
||||
return
|
||||
|
||||
if not frappe.db.get_single_value("Stock Settings", "enable_stock_reservation"):
|
||||
return
|
||||
|
||||
if frappe.db.get_value(
|
||||
"Sales Order Item", self.sales_order_item, "ensure_delivery_based_on_produced_serial_no"
|
||||
):
|
||||
self.reserve_stock = 1
|
||||
|
||||
def set_reserve_stock(self):
|
||||
for row in self.required_items:
|
||||
row.reserve_stock = self.reserve_stock
|
||||
@@ -1383,8 +1402,8 @@ class WorkOrder(Document):
|
||||
|
||||
doc = frappe.get_doc("Production Plan", self.production_plan)
|
||||
doc.flags.ignore_permissions = True
|
||||
doc.set_status()
|
||||
doc.db_set("status", doc.status)
|
||||
doc.update_status_and_bin_qty()
|
||||
doc.update_raw_material_bin_qty({d.item_code for d in self.required_items})
|
||||
|
||||
def update_work_order_qty_in_so(self):
|
||||
if (not self.sales_order and not self.sales_order_item) or self.production_plan_sub_assembly_item:
|
||||
@@ -1903,15 +1922,17 @@ class WorkOrder(Document):
|
||||
if qty_to_update < 0:
|
||||
continue
|
||||
|
||||
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
qty_to_update = doc.matched_serial_batch_qty
|
||||
|
||||
doc.db_set("transferred_qty", flt(qty_to_update), update_modified=False)
|
||||
if doc.transferred_qty >= doc.reserved_qty:
|
||||
doc.db_set("status", "Closed", update_modified=False)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
doc.update_reserved_qty_in_voucher()
|
||||
|
||||
def update_returned_qty(self):
|
||||
returned_dict = self._material_transfer_qty_by_item(is_return=1)
|
||||
@@ -1950,7 +1971,7 @@ class WorkOrder(Document):
|
||||
if not self.skip_transfer:
|
||||
filters["from_voucher_no"] = ("is", "set")
|
||||
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.name, "Manufacture")
|
||||
row_wise_serial_batch = get_row_wise_serial_batch(self.name, CONSUMPTION_PURPOSES)
|
||||
|
||||
if names := frappe.get_all(
|
||||
"Stock Reservation Entry", filters=filters, pluck="name", order_by="creation"
|
||||
@@ -1968,9 +1989,11 @@ class WorkOrder(Document):
|
||||
|
||||
if (doc.has_batch_no or doc.has_serial_no) and doc.reservation_based_on == "Serial and Batch":
|
||||
doc.consume_serial_batch_for_material_transfer(row_wise_serial_batch)
|
||||
doc.db_set("consumed_qty", doc.matched_serial_batch_qty, update_modified=False)
|
||||
|
||||
doc.update_status()
|
||||
doc.update_reserved_stock_in_bin()
|
||||
doc.update_reserved_qty_in_voucher()
|
||||
|
||||
def validate_reserved_qty(self):
|
||||
sre_details = get_sre_details(self.name)
|
||||
@@ -2527,7 +2550,7 @@ def get_consumed_qty(work_order, item_code):
|
||||
.select(fn.Sum(stock_entry_detail.transfer_qty).as_("qty"))
|
||||
.where(
|
||||
(stock_entry.work_order == work_order)
|
||||
& (stock_entry.purpose.isin(["Manufacture", "Material Consumption for Manufacture"]))
|
||||
& (stock_entry.purpose.isin(CONSUMPTION_PURPOSES))
|
||||
& (stock_entry.docstatus == 1)
|
||||
& (stock_entry_detail.s_warehouse.isnotnull())
|
||||
& ((stock_entry_detail.item_code == item_code) | (stock_entry_detail.original_item == item_code))
|
||||
@@ -3213,11 +3236,12 @@ def get_row_wise_serial_batch(work_order, purpose=None):
|
||||
if not purpose:
|
||||
purpose = "Material Transfer for Manufacture"
|
||||
|
||||
purposes = [purpose] if isinstance(purpose, str) else purpose
|
||||
stock_entries = frappe.get_all(
|
||||
"Stock Entry",
|
||||
filters={
|
||||
"work_order": work_order,
|
||||
"purpose": purpose,
|
||||
"purpose": ("in", purposes),
|
||||
"docstatus": 1,
|
||||
},
|
||||
pluck="name",
|
||||
|
||||
@@ -1,4 +1,49 @@
|
||||
frappe.provide("erpnext.accounts.bank_reconciliation");
|
||||
frappe.provide("erpnext.accounts.bank_reconciliation.voucher_types");
|
||||
|
||||
// other apps can register more "Create Voucher" types: { get_fields(dm), is_applicable(bank_transaction), create(dm, values, allow_edit) }
|
||||
erpnext.accounts.bank_reconciliation.voucher_types = {
|
||||
"Payment Entry": {
|
||||
create(dialog_manager, values, allow_edit) {
|
||||
return frappe.xcall(
|
||||
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
{
|
||||
bank_transaction_name: dialog_manager.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
allow_edit: allow_edit,
|
||||
company_bank_account: values?.bank_account || dialog_manager?.bank_account,
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
"Journal Entry": {
|
||||
create(dialog_manager, values, allow_edit) {
|
||||
return frappe.xcall(
|
||||
"erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
{
|
||||
bank_transaction_name: dialog_manager.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
allow_edit: allow_edit,
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
...erpnext.accounts.bank_reconciliation.voucher_types,
|
||||
};
|
||||
|
||||
erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
constructor(
|
||||
@@ -49,6 +94,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
this.bank_transaction = r.message;
|
||||
r.message.payment_entry = 1;
|
||||
r.message.journal_entry = 1;
|
||||
this.set_document_type_options();
|
||||
this.dialog.set_values(r.message);
|
||||
this.copy_data_to_voucher();
|
||||
this.dialog.show();
|
||||
@@ -57,6 +103,29 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
});
|
||||
}
|
||||
|
||||
get_voucher_types() {
|
||||
return erpnext.accounts.bank_reconciliation.voucher_types;
|
||||
}
|
||||
|
||||
get_document_types() {
|
||||
return Object.entries(this.get_voucher_types())
|
||||
.filter(
|
||||
([, voucher_type]) =>
|
||||
!this.bank_transaction ||
|
||||
!voucher_type.is_applicable ||
|
||||
voucher_type.is_applicable(this.bank_transaction)
|
||||
)
|
||||
.map(([document_type]) => document_type);
|
||||
}
|
||||
|
||||
set_document_type_options() {
|
||||
const document_types = this.get_document_types();
|
||||
this.dialog.set_df_property("document_type", "options", document_types.join("\n"));
|
||||
if (!document_types.includes(this.dialog.get_value("document_type"))) {
|
||||
this.dialog.set_value("document_type", document_types[0]);
|
||||
}
|
||||
}
|
||||
|
||||
copy_data_to_voucher() {
|
||||
let copied = {
|
||||
reference_number: this.bank_transaction.reference_number || this.bank_transaction.description,
|
||||
@@ -186,7 +255,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
label: __("Document Type"),
|
||||
fieldname: "document_type",
|
||||
fieldtype: "Select",
|
||||
options: `Payment Entry\nJournal Entry`,
|
||||
options: this.get_document_types().join("\n"),
|
||||
default: "Payment Entry",
|
||||
depends_on: "eval:doc.action=='Create Voucher'",
|
||||
},
|
||||
@@ -398,6 +467,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
};
|
||||
},
|
||||
},
|
||||
...this.get_additional_voucher_fields(),
|
||||
{
|
||||
fieldtype: "Section Break",
|
||||
fieldname: "details_section",
|
||||
@@ -459,6 +529,12 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
];
|
||||
}
|
||||
|
||||
get_additional_voucher_fields() {
|
||||
return Object.values(this.get_voucher_types()).flatMap((voucher_type) =>
|
||||
voucher_type.get_fields ? voucher_type.get_fields(this) : []
|
||||
);
|
||||
}
|
||||
|
||||
get_selected_attributes() {
|
||||
let selected_attributes = [];
|
||||
this.dialog.$wrapper.find(".checkbox input").each((i, col) => {
|
||||
@@ -477,10 +553,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
|
||||
reconciliation_dialog_primary_action(values) {
|
||||
if (values.action == "Match Against Voucher") this.match(values);
|
||||
if (values.action == "Create Voucher" && values.document_type == "Payment Entry")
|
||||
this.add_payment_entry(values);
|
||||
if (values.action == "Create Voucher" && values.document_type == "Journal Entry")
|
||||
this.add_journal_entry(values);
|
||||
else if (values.action == "Create Voucher") this.create_voucher(values);
|
||||
else if (values.action == "Update Bank Transaction") this.update_transaction(values);
|
||||
}
|
||||
|
||||
@@ -513,54 +586,25 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
});
|
||||
}
|
||||
|
||||
add_payment_entry(values) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
company_bank_account: values?.bank_account || this?.bank_account,
|
||||
},
|
||||
callback: (response) => {
|
||||
const alert_string = __("Bank Transaction {0} added as Payment Entry", [
|
||||
this.bank_transaction.name,
|
||||
]);
|
||||
frappe.show_alert(alert_string);
|
||||
this.update_dt_cards(response.message);
|
||||
this.dialog.hide();
|
||||
},
|
||||
});
|
||||
}
|
||||
create_voucher(values, allow_edit = false) {
|
||||
const voucher_type = this.get_voucher_types()[values.document_type];
|
||||
if (!voucher_type) {
|
||||
frappe.throw(__("Cannot create {0} from a Bank Transaction", [values.document_type]));
|
||||
}
|
||||
|
||||
add_journal_entry(values) {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
},
|
||||
callback: (response) => {
|
||||
const alert_string = __("Bank Transaction {0} added as Journal Entry", [
|
||||
this.bank_transaction.name,
|
||||
]);
|
||||
frappe.show_alert(alert_string);
|
||||
this.update_dt_cards(response.message);
|
||||
this.dialog.hide();
|
||||
},
|
||||
return voucher_type.create(this, values, allow_edit).then((message) => {
|
||||
if (allow_edit) {
|
||||
const doc = frappe.model.sync(message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
return;
|
||||
}
|
||||
|
||||
frappe.show_alert(
|
||||
__("Bank Transaction {0} added as {1}", [this.bank_transaction.name, values.document_type])
|
||||
);
|
||||
this.update_dt_cards(message);
|
||||
this.dialog.hide();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -584,50 +628,7 @@ erpnext.accounts.bank_reconciliation.DialogManager = class DialogManager {
|
||||
|
||||
edit_in_full_page() {
|
||||
const values = this.dialog.get_values(true);
|
||||
if (values.document_type == "Payment Entry") {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_payment_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
project: values.project,
|
||||
cost_center: values.cost_center,
|
||||
allow_edit: true,
|
||||
company_bank_account: values?.bank_account || this?.bank_account,
|
||||
},
|
||||
callback: (r) => {
|
||||
const doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
frappe.call({
|
||||
method: "erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_journal_entry_bts",
|
||||
args: {
|
||||
bank_transaction_name: this.bank_transaction.name,
|
||||
reference_number: values.reference_number,
|
||||
reference_date: values.reference_date,
|
||||
party_type: values.party_type,
|
||||
party: values.party,
|
||||
posting_date: values.posting_date,
|
||||
mode_of_payment: values.mode_of_payment,
|
||||
entry_type: values.journal_entry_type,
|
||||
second_account: values.second_account,
|
||||
allow_edit: true,
|
||||
},
|
||||
callback: (r) => {
|
||||
var doc = frappe.model.sync(r.message);
|
||||
track_voucher(doc[0].doctype, doc[0].name, this.bank_transaction.name);
|
||||
frappe.set_route("Form", doc[0].doctype, doc[0].name);
|
||||
},
|
||||
});
|
||||
}
|
||||
return this.create_voucher(values, true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -639,7 +640,7 @@ const track_voucher = (doctype, docname, bank_transaction_name) => {
|
||||
pending_reconciliations.set(voucher_key(doctype, docname), bank_transaction_name);
|
||||
};
|
||||
|
||||
for (const voucher_doctype of ["Payment Entry", "Journal Entry"]) {
|
||||
for (const voucher_doctype of Object.keys(erpnext.accounts.bank_reconciliation.voucher_types)) {
|
||||
frappe.ui.form.on(voucher_doctype, {
|
||||
before_save(frm) {
|
||||
frm.__pending_reconciliation_key = voucher_key(frm.doctype, frm.doc.name);
|
||||
|
||||
@@ -216,8 +216,11 @@ $.extend(erpnext.stock_reservation, {
|
||||
unreserve_stock(frm) {
|
||||
erpnext.stock_reservation.get_stock_reservation_entries(frm.doctype, frm.docname).then((r) => {
|
||||
if (!r.exc && r.message) {
|
||||
if (r.message.length > 0) {
|
||||
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, r.message);
|
||||
const sre_entries = r.message.filter(
|
||||
(sre) => erpnext.stock_reservation.get_held_qty(sre) > 0
|
||||
);
|
||||
if (sre_entries.length > 0) {
|
||||
erpnext.stock_reservation.prepare_for_cancel_sre_entries(frm, sre_entries);
|
||||
} else {
|
||||
frappe.msgprint(__("No reserved stock to unreserve."));
|
||||
}
|
||||
@@ -253,7 +256,7 @@ $.extend(erpnext.stock_reservation, {
|
||||
sre: sre.name,
|
||||
item_code: sre.item_code,
|
||||
warehouse: sre.warehouse,
|
||||
qty: flt(sre.reserved_qty) - flt(sre.delivered_qty),
|
||||
qty: erpnext.stock_reservation.get_held_qty(sre),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -261,6 +264,12 @@ $.extend(erpnext.stock_reservation, {
|
||||
dialog.show();
|
||||
},
|
||||
|
||||
get_held_qty(sre) {
|
||||
return (
|
||||
flt(sre.reserved_qty) - flt(sre.delivered_qty) - flt(sre.transferred_qty) - flt(sre.consumed_qty)
|
||||
);
|
||||
},
|
||||
|
||||
cancel_stock_reservation(dialog, frm) {
|
||||
let data = { sr_entries: dialog.fields_dict.sr_entries.grid.get_selected_children() };
|
||||
let method = "erpnext.manufacturing.doctype.work_order.work_order.cancel_stock_reservation_entries";
|
||||
|
||||
@@ -6,6 +6,10 @@ erpnext.utils.CRMActivities = class CRMActivities {
|
||||
refresh() {
|
||||
var me = this;
|
||||
$(this.open_activities_wrapper).empty();
|
||||
|
||||
// an unsaved doc has no activities and its temp name can't be permission-checked
|
||||
if (this.frm.is_new()) return;
|
||||
|
||||
let cur_form_footer = this.form_wrapper.find(".form-footer");
|
||||
|
||||
// all activities
|
||||
|
||||
@@ -98,6 +98,106 @@ class TestUaeVat201(ERPNextTestSuite):
|
||||
self.assertEqual(get_standard_rated_expenses_total(filters), 917.5)
|
||||
self.assertEqual(get_standard_rated_expenses_tax(filters), 50)
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Accounts Settings", {"allow_multi_currency_invoices_against_single_party_account": True}
|
||||
)
|
||||
def test_uae_vat_201_sales_vat_in_foreign_currency(self):
|
||||
"""VAT on a foreign currency invoice must be reported in company currency."""
|
||||
si = create_sales_invoice(
|
||||
company="_Test Company UAE VAT",
|
||||
customer="_Test UAE Customer",
|
||||
currency="USD",
|
||||
conversion_rate=3.67,
|
||||
rate=1000,
|
||||
qty=1,
|
||||
warehouse="Finished Goods - _TCUV",
|
||||
debit_to="Debtors - _TCUV",
|
||||
income_account="Sales - _TCUV",
|
||||
expense_account="Cost of Goods Sold - _TCUV",
|
||||
cost_center="Main - _TCUV",
|
||||
item="_Test UAE VAT Item",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.vat_emirate = "Dubai"
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "VAT 5% - _TCUV",
|
||||
"cost_center": "Main - _TCUV",
|
||||
"description": "VAT 5% @ 5.0",
|
||||
"rate": 5.0,
|
||||
},
|
||||
)
|
||||
si.submit()
|
||||
|
||||
filters = {"company": "_Test Company UAE VAT"}
|
||||
amounts_by_emirate = dict(
|
||||
(emirate, (amount, vat)) for emirate, amount, vat in get_total_emiratewise(filters)
|
||||
)
|
||||
amount, vat = amounts_by_emirate["Dubai"]
|
||||
|
||||
self.assertEqual(amount, 3670)
|
||||
self.assertEqual(vat, 183.5)
|
||||
self.assertEqual(vat, si.taxes[0].base_tax_amount_after_discount_amount)
|
||||
self.assertNotEqual(vat, si.items[0].tax_amount)
|
||||
|
||||
def test_uae_vat_201_mixed_invoice_excludes_exempt_and_zero_rated_vat(self):
|
||||
si = create_sales_invoice(
|
||||
company="_Test Company UAE VAT",
|
||||
customer="_Test UAE Customer",
|
||||
currency="AED",
|
||||
rate=100,
|
||||
qty=1,
|
||||
warehouse="Finished Goods - _TCUV",
|
||||
debit_to="Debtors - _TCUV",
|
||||
income_account="Sales - _TCUV",
|
||||
expense_account="Cost of Goods Sold - _TCUV",
|
||||
cost_center="Main - _TCUV",
|
||||
item="_Test UAE VAT Item",
|
||||
do_not_save=1,
|
||||
)
|
||||
si.vat_emirate = "Ajman"
|
||||
for item_code in ("_Test UAE VAT Zero Rated Item", "_Test UAE VAT Exempt Item"):
|
||||
si.append(
|
||||
"items",
|
||||
{
|
||||
"item_code": item_code,
|
||||
"qty": 1,
|
||||
"rate": 100,
|
||||
"warehouse": "Finished Goods - _TCUV",
|
||||
"income_account": "Sales - _TCUV",
|
||||
"expense_account": "Cost of Goods Sold - _TCUV",
|
||||
"cost_center": "Main - _TCUV",
|
||||
},
|
||||
)
|
||||
si.append(
|
||||
"taxes",
|
||||
{
|
||||
"charge_type": "On Net Total",
|
||||
"account_head": "VAT 5% - _TCUV",
|
||||
"cost_center": "Main - _TCUV",
|
||||
"description": "VAT 5% @ 5.0",
|
||||
"rate": 5.0,
|
||||
},
|
||||
)
|
||||
si.submit()
|
||||
|
||||
# the single On Net Total row taxes all three items, so the invoice level figure is 15
|
||||
self.assertEqual(si.taxes[0].base_tax_amount_after_discount_amount, 15)
|
||||
|
||||
filters = {"company": "_Test Company UAE VAT"}
|
||||
amounts_by_emirate = dict(
|
||||
(emirate, (amount, vat)) for emirate, amount, vat in get_total_emiratewise(filters)
|
||||
)
|
||||
amount, vat = amounts_by_emirate["Ajman"]
|
||||
|
||||
# only the standard rated row belongs in box 1
|
||||
self.assertEqual(amount, 100)
|
||||
self.assertEqual(vat, 5)
|
||||
self.assertEqual(get_zero_rated_total(filters), 100)
|
||||
self.assertEqual(get_exempt_total(filters), 100)
|
||||
|
||||
|
||||
def set_vat_accounts():
|
||||
if not frappe.db.exists("UAE VAT Settings", "_Test Company UAE VAT"):
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.query_builder.functions import Sum
|
||||
|
||||
from erpnext import get_region
|
||||
|
||||
@@ -144,26 +145,67 @@ def append_data(data, no, legend, amount, vat_amount):
|
||||
|
||||
def get_total_emiratewise(filters):
|
||||
"""Returns Emiratewise Amount and Taxes."""
|
||||
conditions = get_conditions(filters)
|
||||
try:
|
||||
return frappe.db.sql(
|
||||
f"""
|
||||
select
|
||||
s.vat_emirate as emirate, sum(i.base_net_amount) as total, sum(i.tax_amount)
|
||||
from
|
||||
`tabSales Invoice Item` i inner join `tabSales Invoice` s
|
||||
on
|
||||
i.parent = s.name
|
||||
where
|
||||
s.docstatus = 1 and i.is_exempt != 1 and i.is_zero_rated != 1
|
||||
{conditions}
|
||||
group by
|
||||
s.vat_emirate;
|
||||
""",
|
||||
filters,
|
||||
amounts = get_emiratewise_standard_rated_amount(filters)
|
||||
vat_amounts = get_emiratewise_vat_amount(filters)
|
||||
return [
|
||||
(emirate, amounts.get(emirate, 0), vat_amounts.get(emirate, 0))
|
||||
for emirate in dict.fromkeys([*amounts, *vat_amounts])
|
||||
]
|
||||
|
||||
|
||||
def get_emiratewise_standard_rated_amount(filters):
|
||||
"""Returns emiratewise net amount of standard rated supplies in company currency."""
|
||||
i = frappe.qb.DocType("Sales Invoice Item")
|
||||
s = frappe.qb.DocType("Sales Invoice")
|
||||
query = (
|
||||
frappe.qb.from_(i)
|
||||
.inner_join(s)
|
||||
.on(i.parent == s.name)
|
||||
.select(s.vat_emirate, Sum(i.base_net_amount))
|
||||
.where((s.docstatus == 1) & (i.is_exempt != 1) & (i.is_zero_rated != 1))
|
||||
.groupby(s.vat_emirate)
|
||||
)
|
||||
for condition in get_sales_conditions(filters, s):
|
||||
query = query.where(condition)
|
||||
return dict(query.run())
|
||||
|
||||
|
||||
def get_emiratewise_vat_amount(filters):
|
||||
"""Returns emiratewise VAT on standard rated supplies in company currency.
|
||||
|
||||
Item Wise Tax Detail.amount is the item's share of the tax row already converted to
|
||||
company currency, so it keeps the item level exempt / zero rated split.
|
||||
"""
|
||||
i = frappe.qb.DocType("Sales Invoice Item")
|
||||
s = frappe.qb.DocType("Sales Invoice")
|
||||
t = frappe.qb.DocType("Sales Taxes and Charges")
|
||||
d = frappe.qb.DocType("Item Wise Tax Detail")
|
||||
uae_vat = frappe.qb.DocType("UAE VAT Account")
|
||||
query = (
|
||||
frappe.qb.from_(d)
|
||||
.inner_join(s)
|
||||
.on(d.parent == s.name)
|
||||
.inner_join(i)
|
||||
.on(d.item_row == i.name)
|
||||
.inner_join(t)
|
||||
.on(d.tax_row == t.name)
|
||||
.select(s.vat_emirate, Sum(d.amount))
|
||||
.where(
|
||||
(d.parenttype == "Sales Invoice")
|
||||
& (s.docstatus == 1)
|
||||
& (i.is_exempt != 1)
|
||||
& (i.is_zero_rated != 1)
|
||||
& t.account_head.isin(
|
||||
frappe.qb.from_(uae_vat)
|
||||
.select(uae_vat.account)
|
||||
.where(uae_vat.parent == filters.get("company"))
|
||||
)
|
||||
)
|
||||
except (IndexError, TypeError):
|
||||
return 0
|
||||
.groupby(s.vat_emirate)
|
||||
)
|
||||
for condition in get_sales_conditions(filters, s):
|
||||
query = query.where(condition)
|
||||
return dict(query.run())
|
||||
|
||||
|
||||
def get_emirates():
|
||||
@@ -423,3 +465,15 @@ def get_conditions(filters):
|
||||
if filters.get(opts[0]):
|
||||
conditions += opts[1]
|
||||
return conditions
|
||||
|
||||
|
||||
def get_sales_conditions(filters, sales_invoice):
|
||||
"""Return Query Builder conditions for Sales Invoice report filters."""
|
||||
conditions = []
|
||||
if filters.get("company"):
|
||||
conditions.append(sales_invoice.company == filters.get("company"))
|
||||
if filters.get("from_date"):
|
||||
conditions.append(sales_invoice.posting_date >= filters.get("from_date"))
|
||||
if filters.get("to_date"):
|
||||
conditions.append(sales_invoice.posting_date <= filters.get("to_date"))
|
||||
return conditions
|
||||
|
||||
@@ -158,6 +158,11 @@ frappe.ui.form.on("Sales Order", {
|
||||
frm.set_df_property("reserve_stock", "read_only", 1);
|
||||
frm.set_df_property("reserve_stock", "hidden", 1);
|
||||
frm.fields_dict.items.grid.update_docfield_property("reserve_stock", "hidden", 1);
|
||||
frm.fields_dict.items.grid.update_docfield_property(
|
||||
"ensure_delivery_based_on_produced_serial_no",
|
||||
"hidden",
|
||||
1
|
||||
);
|
||||
frm.fields_dict.items.grid.update_docfield_property(
|
||||
"reserve_stock",
|
||||
"default",
|
||||
|
||||
@@ -837,6 +837,9 @@ class SalesOrder(SellingController):
|
||||
if item.reserve_stock and (not enable_stock_reservation or not cint(item.is_stock_item)):
|
||||
item.reserve_stock = 0
|
||||
|
||||
if item.ensure_delivery_based_on_produced_serial_no and not enable_stock_reservation:
|
||||
item.ensure_delivery_based_on_produced_serial_no = 0
|
||||
|
||||
def has_unreserved_stock(self) -> bool:
|
||||
"""Returns True if there is any unreserved item in the Sales Order."""
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"disable_rounded_total",
|
||||
"disable_in_words",
|
||||
"use_posting_datetime_for_naming_documents",
|
||||
"demo_company"
|
||||
"demo_company",
|
||||
"enable_item_search_index"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -82,6 +83,14 @@
|
||||
"options": "Company",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Speeds up Item search on large catalogues. Requires a single app server: the index is a file on that server's disk, and several servers would each answer from a different copy. Building it takes about 20 minutes for 3 million items, and runs in the background.",
|
||||
"fieldname": "enable_item_search_index",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Item Search Index",
|
||||
"show_description_on_click": 1
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "When checked, the system will use the posting datetime of the document for naming the document instead of the creation datetime of the document.",
|
||||
|
||||
@@ -476,6 +476,7 @@ class DeliveryNote(SellingController):
|
||||
# Updating stock ledger should always be called after updating prevdoc status,
|
||||
# because updating reserved qty in bin depends upon updated delivered qty in SO
|
||||
self.update_stock_ledger()
|
||||
self.validate_produced_serial_nos_against_reservation()
|
||||
self.make_gl_entries()
|
||||
self.repost_future_sle_and_gle()
|
||||
|
||||
|
||||
@@ -3108,6 +3108,119 @@ class TestDeliveryNote(ERPNextTestSuite):
|
||||
dn.items[0].stock_qty = 2
|
||||
dn.save()
|
||||
|
||||
@ERPNextTestSuite.change_settings(
|
||||
"Stock Settings",
|
||||
{"enable_stock_reservation": 1, "auto_create_serial_and_batch_bundle_for_outward": 1},
|
||||
)
|
||||
def test_delivery_restricted_to_reserved_produced_serial_nos(self):
|
||||
from erpnext.selling.doctype.sales_order.sales_order import make_delivery_note
|
||||
|
||||
so, reserved, unreserved = make_so_with_reserved_produced_serial_no()
|
||||
self.assertEqual(frappe.db.get_value("Work Order", {"sales_order": so.name}, "reserve_stock"), 1)
|
||||
|
||||
frappe.db.savepoint("unreserved_serial_no")
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.items[0].use_serial_batch_fields = 1
|
||||
dn.items[0].serial_no = unreserved[0]
|
||||
dn.save()
|
||||
self.assertRaises(frappe.ValidationError, dn.submit)
|
||||
frappe.db.rollback(save_point="unreserved_serial_no")
|
||||
|
||||
dn = make_delivery_note(so.name)
|
||||
dn.save()
|
||||
dn.submit()
|
||||
self.assertEqual(get_serial_nos_from_bundle(dn.items[0].serial_and_batch_bundle), reserved)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": 0})
|
||||
def test_ensure_delivery_by_serial_no_cleared_without_stock_reservation(self):
|
||||
item_code = make_item("Test Ensure Serial Without SRE", {"is_stock_item": 1, "has_serial_no": 1}).name
|
||||
|
||||
so = make_sales_order(item_code=item_code, qty=1, do_not_save=True)
|
||||
so.items[0].ensure_delivery_based_on_produced_serial_no = 1
|
||||
so.save()
|
||||
|
||||
self.assertEqual(so.items[0].ensure_delivery_based_on_produced_serial_no, 0)
|
||||
|
||||
@ERPNextTestSuite.change_settings("Stock Settings", {"enable_stock_reservation": 1})
|
||||
def test_production_plan_work_order_reserves_stock_for_ensure_delivery_by_serial_no(self):
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import (
|
||||
create_production_plan,
|
||||
make_bom,
|
||||
)
|
||||
|
||||
fg_item = make_item(
|
||||
"Test PP Produced Serial FG",
|
||||
{"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TPPSFG-.####"},
|
||||
).name
|
||||
rm_item = make_item("Test PP Produced Serial RM", {"is_stock_item": 1}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item])
|
||||
|
||||
so = make_sales_order(item_code=fg_item, qty=1, do_not_submit=True)
|
||||
so.items[0].ensure_delivery_based_on_produced_serial_no = 1
|
||||
so.submit()
|
||||
|
||||
pln = create_production_plan(
|
||||
company=so.company, get_items_from="Sales Order", sales_order=so, skip_getting_mr_items=True
|
||||
)
|
||||
pln.make_work_order()
|
||||
|
||||
self.assertEqual(
|
||||
frappe.db.get_value(
|
||||
"Work Order", {"production_plan": pln.name}, ["sales_order_item", "reserve_stock"]
|
||||
),
|
||||
(so.items[0].name, 1),
|
||||
)
|
||||
|
||||
|
||||
def make_so_with_reserved_produced_serial_no():
|
||||
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
|
||||
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
|
||||
from erpnext.manufacturing.doctype.work_order.work_order import make_stock_entry as make_wo_entry
|
||||
from erpnext.stock.doctype.stock_reservation_entry.stock_reservation_entry import (
|
||||
get_sre_reserved_serial_nos_for_voucher_detail_nos,
|
||||
)
|
||||
|
||||
warehouse = "_Test Warehouse - _TC"
|
||||
fg_item = make_item(
|
||||
"Test Produced Serial FG", {"is_stock_item": 1, "has_serial_no": 1, "serial_no_series": "TPSFG-.####"}
|
||||
).name
|
||||
rm_item = make_item("Test Produced Serial RM", {"is_stock_item": 1}).name
|
||||
make_bom(item=fg_item, raw_materials=[rm_item], source_warehouse=warehouse)
|
||||
make_stock_entry(item_code=rm_item, target=warehouse, qty=1, basic_rate=100)
|
||||
make_stock_entry(item_code=fg_item, target=warehouse, qty=1, basic_rate=100)
|
||||
|
||||
so = make_sales_order(item_code=fg_item, qty=1, warehouse=warehouse, do_not_submit=True)
|
||||
so.items[0].ensure_delivery_based_on_produced_serial_no = 1
|
||||
so.submit()
|
||||
|
||||
wo = make_wo_order_test_record(
|
||||
item=fg_item,
|
||||
qty=1,
|
||||
sales_order=so.name,
|
||||
source_warehouse=warehouse,
|
||||
wip_warehouse=warehouse,
|
||||
fg_warehouse=warehouse,
|
||||
skip_transfer=1,
|
||||
do_not_save=True,
|
||||
)
|
||||
wo.sales_order_item = so.items[0].name
|
||||
wo.insert()
|
||||
wo.submit()
|
||||
frappe.get_doc(make_wo_entry(wo.name, "Manufacture", 1)).submit()
|
||||
|
||||
reserved = sorted(
|
||||
get_sre_reserved_serial_nos_for_voucher_detail_nos("Sales Order", [so.items[0].name])[
|
||||
so.items[0].name
|
||||
]
|
||||
)
|
||||
unreserved = frappe.get_all(
|
||||
"Serial No",
|
||||
filters={"item_code": fg_item, "status": "Active", "name": ("not in", reserved)},
|
||||
pluck="name",
|
||||
)
|
||||
|
||||
return so, reserved, unreserved
|
||||
|
||||
|
||||
def create_delivery_note(**args):
|
||||
dn = frappe.new_doc("Delivery Note")
|
||||
|
||||
252
erpnext/stock/doctype/item/item_search.py
Normal file
252
erpnext/stock/doctype/item/item_search.py
Normal file
@@ -0,0 +1,252 @@
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
|
||||
import frappe
|
||||
from frappe.search.sqlite_search import SQLiteSearch, SQLiteSearchIndexMissingError
|
||||
|
||||
MINIMUM_TERM_LENGTH = 3
|
||||
CANDIDATE_LIMIT = 25000
|
||||
LIKE_WILDCARDS = r"[%_]"
|
||||
BARCODE_COLUMN = "barcodes"
|
||||
TOKENIZER = "trigram remove_diacritics 1"
|
||||
|
||||
|
||||
def get_searched_fieldnames() -> list[str]:
|
||||
"""Item fields that item_query matches the search term against."""
|
||||
meta = frappe.get_meta("Item", cached=True)
|
||||
searchfields = meta.get_search_fields()
|
||||
extras = [f for f in ("item_code", "item_group", "item_name") if f not in searchfields]
|
||||
db_fieldnames = {field.fieldname for field in meta.fields}
|
||||
return [f for f in [*searchfields, *extras] if f in db_fieldnames]
|
||||
|
||||
|
||||
class ItemSearch(SQLiteSearch):
|
||||
"""FTS5 trigram index over Item, for substring search on large catalogues.
|
||||
|
||||
A host whose copy is behind drops valid Items from its candidate list, so results depend on
|
||||
which host answered. Switched on per site from Global Defaults, which carries that warning.
|
||||
"""
|
||||
|
||||
INDEX_NAME = "item_search.db"
|
||||
BUILD_VOCABULARY = False
|
||||
|
||||
def __init__(self, db_name=None):
|
||||
fieldnames = get_searched_fieldnames()
|
||||
mapped = {"title": "item_code", "content": "item_name"}
|
||||
plain = [f for f in dict.fromkeys(["name", *fieldnames]) if f not in mapped.values()]
|
||||
self.INDEXABLE_DOCTYPES = {
|
||||
"Item": {
|
||||
"fields": [*plain, mapped],
|
||||
"filters": {"disabled": 0, "has_variants": 0},
|
||||
}
|
||||
}
|
||||
self.INDEX_SCHEMA = {
|
||||
"tokenizer": TOKENIZER,
|
||||
"text_fields": [
|
||||
"title",
|
||||
"content",
|
||||
BARCODE_COLUMN,
|
||||
*self._extra_text_fields(fieldnames),
|
||||
],
|
||||
}
|
||||
self.indexed_fieldnames = {"name", *fieldnames}
|
||||
self._barcodes = {}
|
||||
super().__init__(db_name)
|
||||
|
||||
@staticmethod
|
||||
def _extra_text_fields(fieldnames: list[str]) -> list[str]:
|
||||
return [f for f in fieldnames if f not in ("item_code", "item_name")]
|
||||
|
||||
def index_exists(self) -> bool:
|
||||
"""A table missing a searched column cannot answer for it, so it reports itself absent.
|
||||
|
||||
The searched fields come from the Item meta, so a site that adds one leaves an older table
|
||||
short. Callers fall back and the builder replaces it. One connection: this runs on every save.
|
||||
"""
|
||||
if not os.path.exists(self.db_path):
|
||||
return False
|
||||
|
||||
return set(self.schema["text_fields"]) <= self.get_indexed_columns()
|
||||
|
||||
def get_indexed_columns(self) -> set[str]:
|
||||
"""Columns the built table carries, empty when there is no table."""
|
||||
try:
|
||||
connection = self._get_connection(read_only=True)
|
||||
except SQLiteSearchIndexMissingError:
|
||||
return set()
|
||||
|
||||
try:
|
||||
return {row["name"] for row in connection.execute("PRAGMA table_info(search_fts)")}
|
||||
except sqlite3.Error:
|
||||
return set()
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def get_documents_paginated(self, doctype, *args, **kwargs):
|
||||
"""Preload the batch's barcodes: reading them per document would be one query each."""
|
||||
documents = super().get_documents_paginated(doctype, *args, **kwargs)
|
||||
self._barcodes = get_barcodes_by_item([document.name for document in documents])
|
||||
return documents
|
||||
|
||||
def index_documents_by_name(self, doctype, names: list[str]):
|
||||
"""Preload this batch: the catch-up skips get_documents_paginated, and the barcodes left
|
||||
from the last build batch may since have moved."""
|
||||
self._barcodes = get_barcodes_by_item(names)
|
||||
super().index_documents_by_name(doctype, names)
|
||||
|
||||
def prepare_document(self, doc):
|
||||
document = super().prepare_document(doc)
|
||||
if document is None:
|
||||
return None
|
||||
|
||||
document[BARCODE_COLUMN] = self.get_barcode_text(doc.name)
|
||||
return document
|
||||
|
||||
def _process_content(self, content):
|
||||
"""Store values verbatim.
|
||||
|
||||
item_query rechecks every candidate with LIKE against the column in MariaDB, so the
|
||||
indexed text has to be what that column holds. The framework's cleaning collapses
|
||||
whitespace and replaces a URL with "[link]", which would lose those rows.
|
||||
"""
|
||||
return "" if content is None else str(content)
|
||||
|
||||
def get_barcode_text(self, item_code: str) -> str:
|
||||
"""Always a string: a text column left unset drops the document from the index entirely."""
|
||||
if item_code in self._barcodes:
|
||||
return self._barcodes[item_code]
|
||||
|
||||
return get_barcodes_by_item([item_code]).get(item_code, "")
|
||||
|
||||
def is_search_enabled(self) -> bool:
|
||||
"""Off unless Global Defaults opts in: building reads every Item, which is not free."""
|
||||
return bool(frappe.get_single_value("Global Defaults", "enable_item_search_index"))
|
||||
|
||||
def get_search_filters(self) -> dict:
|
||||
return {}
|
||||
|
||||
def get_candidate_item_codes(self, txt: str, searched_fields: list[str]) -> list[str] | None:
|
||||
"""Item codes that can match txt, a superset the caller must still recheck with LIKE.
|
||||
|
||||
Covers barcodes, which item_query also searches: an Item left out is filtered away even
|
||||
when its barcode matches. Answers only when the index carries every field the query
|
||||
searches, because a caller may pass any Item field as searchfield. `name` counts as
|
||||
indexed: Item.autoname assigns it from item_code.
|
||||
"""
|
||||
if not self.is_search_enabled() or not self.index_exists():
|
||||
return None
|
||||
|
||||
if not set(searched_fields) <= self.indexed_fieldnames:
|
||||
return None
|
||||
|
||||
match_query = build_match_query(txt)
|
||||
if match_query is None:
|
||||
return None
|
||||
|
||||
names = self.run_match(match_query)
|
||||
if names is None or len(names) >= CANDIDATE_LIMIT:
|
||||
return None
|
||||
|
||||
return names
|
||||
|
||||
def run_match(self, match_query: str) -> list[str] | None:
|
||||
"""None means the index cannot answer. An empty list means it answered: nothing matches."""
|
||||
connection = self._get_connection(read_only=True)
|
||||
try:
|
||||
matched = connection.execute(
|
||||
"SELECT name FROM search_fts WHERE search_fts MATCH ? LIMIT ?",
|
||||
(match_query, CANDIDATE_LIMIT),
|
||||
).fetchall()
|
||||
except sqlite3.Error:
|
||||
frappe.log_error("Item search index lookup failed")
|
||||
return None
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
return [row["name"] for row in matched]
|
||||
|
||||
|
||||
def build_match_query(txt: str) -> str | None:
|
||||
"""FTS5 query matching a superset of LIKE %txt%, or None when it cannot narrow the scan."""
|
||||
if "\\" in txt:
|
||||
return None
|
||||
|
||||
fragments = [fragment.strip() for fragment in re.split(LIKE_WILDCARDS, txt)]
|
||||
usable = [fragment for fragment in fragments if len(fragment) >= MINIMUM_TERM_LENGTH]
|
||||
if not usable:
|
||||
return None
|
||||
|
||||
return " AND ".join(quote_fragment(fragment) for fragment in usable)
|
||||
|
||||
|
||||
def quote_fragment(fragment: str) -> str:
|
||||
escaped = fragment.replace('"', '""')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def get_barcodes_by_item(item_codes: list[str]) -> dict[str, str]:
|
||||
"""Barcodes of each item, joined into the one string the index column holds."""
|
||||
if not item_codes:
|
||||
return {}
|
||||
|
||||
rows = frappe.get_all(
|
||||
"Item Barcode",
|
||||
filters={"parent": ("in", item_codes), "parentfield": BARCODE_COLUMN},
|
||||
fields=["parent", "barcode"],
|
||||
)
|
||||
|
||||
barcodes = {}
|
||||
for row in rows:
|
||||
barcodes[row.parent] = f"{barcodes.get(row.parent, '')} {row.barcode}".strip()
|
||||
|
||||
return barcodes
|
||||
|
||||
|
||||
def reindex_item(doc, method=None):
|
||||
"""Queue an Item on every save.
|
||||
|
||||
Item Barcode rows raise no document events, so the Item save is the only signal one moved, and
|
||||
no indexed field of the Item need have changed.
|
||||
"""
|
||||
queue_item(doc.name)
|
||||
|
||||
|
||||
def reindex_renamed_item(doc, method=None, old=None, new=None, merge=False):
|
||||
"""A rename writes the new name straight to the table without saving the Item, so on_update
|
||||
never runs and the index would keep answering with the name that is gone."""
|
||||
queue_item(doc.name, drop=old)
|
||||
|
||||
|
||||
def queue_item(item_code: str, drop: str | None = None):
|
||||
"""Index one Item, and drop the name a rename replaced.
|
||||
|
||||
Indexes before dropping, so a failure leaves the replaced name in the index rather than losing
|
||||
both: a name nobody holds is an extra candidate the query filters out, a missing one hides a row.
|
||||
|
||||
A failed write must not fail the Item save, and must not leave the index answering either.
|
||||
Nothing would record this Item as stale, and a candidate list missing it hides rows the scan
|
||||
returns, so the index goes and every caller falls back until the scheduler rebuilds it.
|
||||
"""
|
||||
search = ItemSearch()
|
||||
if not (search.is_search_enabled() and search.index_exists()):
|
||||
return
|
||||
|
||||
try:
|
||||
search.index_doc("Item", item_code)
|
||||
if drop:
|
||||
search.remove_doc("Item", drop)
|
||||
except Exception:
|
||||
frappe.log_error("Item search index update failed, dropping the index")
|
||||
try:
|
||||
search.drop_index()
|
||||
except Exception:
|
||||
frappe.log_error("Item search index could not be dropped")
|
||||
|
||||
|
||||
def get_item_search_candidates(txt: str, searched_fields: list[str]) -> list[str] | None:
|
||||
try:
|
||||
return ItemSearch().get_candidate_item_codes(txt, searched_fields)
|
||||
except Exception:
|
||||
frappe.log_error("Item search index unavailable")
|
||||
return None
|
||||
332
erpnext/stock/doctype/item/test_item_search.py
Normal file
332
erpnext/stock/doctype/item/test_item_search.py
Normal file
@@ -0,0 +1,332 @@
|
||||
import sqlite3
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import frappe
|
||||
from frappe.search.sqlite_search import get_search_classes, update_doc_index
|
||||
|
||||
from erpnext.controllers import queries
|
||||
from erpnext.stock.doctype.item.item_search import ItemSearch, build_match_query
|
||||
from erpnext.tests.utils import ERPNextTestSuite
|
||||
|
||||
|
||||
class TestBuildMatchQuery(ERPNextTestSuite):
|
||||
def test_quotes_the_term(self):
|
||||
self.assertEqual(build_match_query("widget"), '"widget"')
|
||||
|
||||
def test_escapes_embedded_quotes(self):
|
||||
self.assertEqual(build_match_query('say "hi"'), '"say ""hi"""')
|
||||
|
||||
def test_skips_terms_shorter_than_a_trigram(self):
|
||||
for txt in ("", "a", "ab", " b "):
|
||||
self.assertIsNone(build_match_query(txt), txt)
|
||||
|
||||
def test_splits_on_like_wildcards(self):
|
||||
"""A wildcard splits the term, the fragments narrow, and the caller rechecks with LIKE."""
|
||||
self.assertEqual(build_match_query("RAW_MAT_000123"), '"RAW" AND "MAT" AND "000123"')
|
||||
self.assertEqual(build_match_query("abc%def"), '"abc" AND "def"')
|
||||
|
||||
def test_skips_terms_with_no_usable_fragment(self):
|
||||
for txt in ("ab%cd", "ab_cd", "a%b%c"):
|
||||
self.assertIsNone(build_match_query(txt), txt)
|
||||
|
||||
def test_skips_escaped_terms(self):
|
||||
"""Backslash escapes the next LIKE wildcard, so the split would be wrong."""
|
||||
self.assertIsNone(build_match_query("ab\\_cd"))
|
||||
|
||||
|
||||
class TestItemSearchSetting(ERPNextTestSuite):
|
||||
def test_the_global_defaults_checkbox_drives_the_index(self):
|
||||
with self.change_settings("Global Defaults", enable_item_search_index=0):
|
||||
self.assertFalse(ItemSearch().is_search_enabled())
|
||||
|
||||
with self.change_settings("Global Defaults", enable_item_search_index=1):
|
||||
self.assertTrue(ItemSearch().is_search_enabled())
|
||||
|
||||
|
||||
class TestItemSearchIndex(ERPNextTestSuite):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.enabled = patch.object(ItemSearch, "is_search_enabled", return_value=True)
|
||||
cls.enabled.start()
|
||||
cls.search = ItemSearch()
|
||||
if cls.search.index_exists():
|
||||
cls.search.drop_index()
|
||||
cls.search.build_index()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls.search.drop_index()
|
||||
cls.enabled.stop()
|
||||
super().tearDownClass()
|
||||
|
||||
def test_item_search_is_registered_for_the_lifecycle(self):
|
||||
"""Without the sqlite_search hook nothing syncs the index and it silently rots."""
|
||||
self.assertIn(ItemSearch, get_search_classes())
|
||||
|
||||
def test_a_barcode_added_after_the_build_is_searchable(self):
|
||||
"""A barcode edit changes no Item field, so the Item save is the only signal there is."""
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-BARCODE-PROBE",
|
||||
"item_name": "Barcode Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
self.assertIn("barcodes", self.search.schema["text_fields"])
|
||||
|
||||
item.append("barcodes", {"barcode": "8809988776655"})
|
||||
item.save()
|
||||
|
||||
self.assertIn("ZZ-BARCODE-PROBE", self.candidates("8809988776655"))
|
||||
self.assertEqual(self.run_query("8809988776655", None), self.run_query("8809988776655", None, False))
|
||||
|
||||
def test_a_new_item_is_searchable_immediately(self):
|
||||
"""index_doc writes to search_fts during the save, so there is no window to miss."""
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-QUEUE-PROBE-4471",
|
||||
"item_name": "Queue Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
update_doc_index(item)
|
||||
|
||||
self.assertIn("ZZ-QUEUE-PROBE-4471", self.candidates("4471"))
|
||||
self.assertEqual(self.run_query("4471", None), self.run_query("4471", None, False))
|
||||
|
||||
def test_vocabulary_is_not_built(self):
|
||||
"""item_query matches search_fts directly and never asks for a spelling correction."""
|
||||
self.assertFalse(ItemSearch.BUILD_VOCABULARY)
|
||||
connection = self.search._get_connection(read_only=True)
|
||||
try:
|
||||
self.assertEqual(connection.execute("SELECT count(*) FROM search_vocabulary").fetchone()[0], 0)
|
||||
self.assertEqual(connection.execute("SELECT count(*) FROM search_trigrams").fetchone()[0], 0)
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_tokenizer_folds_accents(self):
|
||||
"""MariaDB's utf8mb4_unicode_ci LIKE is accent insensitive, so the index must be too."""
|
||||
self.assertEqual(self.search.schema["tokenizer"], "trigram remove_diacritics 1")
|
||||
|
||||
def test_an_accented_item_is_found_by_an_unaccented_term(self):
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-CAFÉ-7781",
|
||||
"item_name": "Café Filter",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
update_doc_index(item)
|
||||
|
||||
self.assertIn("ZZ-CAFÉ-7781", self.candidates("CAFE-7781"))
|
||||
self.assertEqual(self.run_query("CAFE-7781", None), self.run_query("CAFE-7781", None, False))
|
||||
|
||||
def test_a_broken_index_falls_back_to_the_scan(self):
|
||||
"""An unreadable index must not answer 'nothing matches' and hide every row."""
|
||||
broken = MagicMock()
|
||||
broken.execute.side_effect = sqlite3.DatabaseError("database disk image is malformed")
|
||||
with patch.object(ItemSearch, "_get_connection", return_value=broken):
|
||||
self.assertIsNone(self.candidates("Test"))
|
||||
|
||||
def drifted_search(self) -> ItemSearch:
|
||||
extra = [*self.search.schema["text_fields"], "a_new_custom_search_field"]
|
||||
|
||||
class DriftedItemSearch(ItemSearch):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.schema["text_fields"] = extra
|
||||
|
||||
return DriftedItemSearch()
|
||||
|
||||
def test_an_item_without_barcodes_is_still_indexed(self):
|
||||
"""A text column left unset drops the document from the index entirely."""
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-NO-BARCODE-3312",
|
||||
"item_name": "No Barcode Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertIn("ZZ-NO-BARCODE-3312", self.candidates("3312"))
|
||||
|
||||
def test_a_drifted_schema_reports_the_index_as_absent(self):
|
||||
"""A site that adds an Item search field leaves the built table a column short."""
|
||||
self.assertTrue(self.search.index_exists())
|
||||
self.assertFalse(self.drifted_search().index_exists())
|
||||
|
||||
def test_a_search_field_outside_the_index_is_refused(self):
|
||||
"""A caller may pass any Item field as searchfield, and the index carries only some."""
|
||||
outside = "stock_uom"
|
||||
self.assertNotIn(outside, self.search.indexed_fieldnames)
|
||||
|
||||
self.assertIsNone(self.candidates("Test", ["name", outside]))
|
||||
self.assertIsNotNone(self.candidates("Test", ["name", "item_code"]))
|
||||
|
||||
def test_item_query_with_an_unindexed_searchfield_matches_the_scan(self):
|
||||
"""Narrowing on a field the index does not carry would drop rows the scan returns."""
|
||||
self.assertEqual(
|
||||
self.run_query("Test", None, searchfield="stock_uom"),
|
||||
self.run_query("Test", None, False, searchfield="stock_uom"),
|
||||
)
|
||||
|
||||
def test_a_renamed_item_is_searchable_under_the_new_name(self):
|
||||
"""A rename writes the new name without saving the Item, so on_update never fires."""
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-RENAME-FROM-5521",
|
||||
"item_name": "Rename Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
self.assertIn("ZZ-RENAME-FROM-5521", self.candidates("5521"))
|
||||
|
||||
frappe.rename_doc("Item", item.name, "ZZ-RENAME-TO-5521", force=True)
|
||||
|
||||
self.assertIn("ZZ-RENAME-TO-5521", self.candidates("5521"))
|
||||
self.assertNotIn("ZZ-RENAME-FROM-5521", self.candidates("5521"))
|
||||
|
||||
def test_repeated_spaces_and_urls_match_the_scan(self):
|
||||
"""Values are indexed verbatim: cleaning them would lose rows the LIKE still matches."""
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-VERBATIM-6612",
|
||||
"item_name": "Valve 3 MM see https://example.com/spec",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
|
||||
for txt in ("Valve 3", "example.com"):
|
||||
with self.subTest(txt=txt):
|
||||
self.assertEqual(self.run_query(txt, None), self.run_query(txt, None, False))
|
||||
|
||||
def test_an_index_write_failure_saves_the_item_and_drops_the_index(self):
|
||||
"""The save must survive, and the index must stop answering: nothing recorded the Item as
|
||||
stale, so its candidate list would hide a row the scan returns."""
|
||||
with (
|
||||
patch.object(ItemSearch, "index_doc", side_effect=sqlite3.OperationalError("disk I/O error")),
|
||||
patch.object(frappe, "log_error") as logged,
|
||||
):
|
||||
item = frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": "ZZ-WRITE-FAILURE-3390",
|
||||
"item_name": "Write Failure Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertTrue(frappe.db.exists("Item", item.name))
|
||||
logged.assert_called()
|
||||
self.assertFalse(self.search.index_exists())
|
||||
self.assertIsNone(self.candidates("3390"), "a dropped index must force the scan")
|
||||
|
||||
self.search.build_index()
|
||||
|
||||
def test_candidates_are_a_superset_of_the_scan(self):
|
||||
"""The query re-filters, so extra candidates are safe but missing ones are not.
|
||||
|
||||
None is an answer too: the index declined, and the query scans without narrowing, which
|
||||
cannot lose a row. Any term can take that path on a large enough catalogue, so the
|
||||
property is anchored on one selective enough that it never can.
|
||||
"""
|
||||
probe = "ZZ-SUPERSET-PROBE-7413"
|
||||
frappe.get_doc(
|
||||
{
|
||||
"doctype": "Item",
|
||||
"item_code": probe,
|
||||
"item_name": "Superset Probe",
|
||||
"item_group": frappe.db.get_value("Item Group", {"is_group": 0}, "name"),
|
||||
"stock_uom": frappe.db.get_value("UOM", {}, "name"),
|
||||
}
|
||||
).insert()
|
||||
|
||||
self.assertIsNotNone(self.candidates(probe), "a term this selective cannot reach the limit")
|
||||
|
||||
for txt in (probe, "Test", "Item", "est", "_Test"):
|
||||
candidates = self.candidates(txt)
|
||||
if candidates is None:
|
||||
continue
|
||||
|
||||
matched = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("like", f"%{txt}%"), "disabled": 0, "has_variants": 0},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertTrue(set(matched) <= set(candidates), txt)
|
||||
|
||||
def test_item_query_output_is_unchanged(self):
|
||||
cases = [
|
||||
("Test", None),
|
||||
("Item", None),
|
||||
("EST", None),
|
||||
("Test", {"is_stock_item": 1}),
|
||||
("_Test", None),
|
||||
("ab", None),
|
||||
("%est", None),
|
||||
("", None),
|
||||
]
|
||||
for txt, filters in cases:
|
||||
with self.subTest(txt=txt, filters=filters):
|
||||
self.assertEqual(self.run_query(txt, filters), self.run_query(txt, filters, False))
|
||||
|
||||
def test_underscore_in_the_term_still_narrows(self):
|
||||
"""_ is a LIKE wildcard, but the fragments around it are still indexable."""
|
||||
candidates = self.candidates("_Test Item")
|
||||
self.assertIsNotNone(candidates)
|
||||
matched = frappe.get_all(
|
||||
"Item",
|
||||
filters={"name": ("like", "%_Test Item%"), "disabled": 0, "has_variants": 0},
|
||||
pluck="name",
|
||||
)
|
||||
self.assertTrue(set(matched) <= set(candidates))
|
||||
|
||||
def test_no_candidates_returns_no_rows(self):
|
||||
"""An empty candidate list must not reach the query, IN () is a syntax error."""
|
||||
with patch.object(queries, "get_item_search_candidates", return_value=[]):
|
||||
self.assertEqual(queries.item_query("Item", "Test", "name", 0, 20, None), ())
|
||||
self.assertEqual(queries.item_query("Item", "Test", "name", 0, 20, None, as_dict=True), [])
|
||||
|
||||
def test_empty_result_matches_the_scan_shape(self):
|
||||
"""The early return must give back what the query itself would, tuple or list."""
|
||||
for as_dict in (False, True):
|
||||
with self.subTest(as_dict=as_dict):
|
||||
with patch.object(queries, "get_item_search_candidates", return_value=[]):
|
||||
early = queries.item_query("Item", "ZZQQNOTHING", "name", 0, 20, None, as_dict=as_dict)
|
||||
with patch.object(queries, "get_item_search_candidates", return_value=None):
|
||||
scanned = queries.item_query("Item", "ZZQQNOTHING", "name", 0, 20, None, as_dict=as_dict)
|
||||
self.assertEqual(early, scanned)
|
||||
self.assertIs(type(early), type(scanned))
|
||||
|
||||
def test_item_query_paging_is_unchanged(self):
|
||||
for start in (0, 3, 6):
|
||||
with self.subTest(start=start):
|
||||
indexed = self.run_query("Test", None, page_len=3, start=start)
|
||||
scanned = self.run_query("Test", None, False, page_len=3, start=start)
|
||||
self.assertEqual(indexed, scanned)
|
||||
|
||||
def candidates(self, txt, searched_fields=None):
|
||||
return self.search.get_candidate_item_codes(
|
||||
txt, searched_fields or ["name", "item_code", "item_name"]
|
||||
)
|
||||
|
||||
def run_query(self, txt, filters, use_index=True, page_len=20, start=0, searchfield="name"):
|
||||
if use_index:
|
||||
return queries.item_query("Item", txt, searchfield, start, page_len, filters, as_dict=True)
|
||||
|
||||
with patch.object(queries, "get_item_search_candidates", return_value=None):
|
||||
return queries.item_query("Item", txt, searchfield, start, page_len, filters, as_dict=True)
|
||||
@@ -2658,6 +2658,20 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
return False
|
||||
|
||||
def before_sl_preview(self):
|
||||
self.release_work_order_reservation_for_preview()
|
||||
|
||||
def before_gl_preview(self):
|
||||
self.release_work_order_reservation_for_preview()
|
||||
|
||||
def release_work_order_reservation_for_preview(self):
|
||||
"""Releases the Work Order's own reservation as submit does, inside the rolled-back preview."""
|
||||
if not self.is_stock_reserve_for_work_order():
|
||||
return
|
||||
|
||||
self.db_set("docstatus", 1, update_modified=False)
|
||||
frappe.get_doc("Work Order", self.work_order).update_required_items()
|
||||
|
||||
def update_wo_reservation_for_subcontracting(self):
|
||||
# A "Send to Subcontractor" entry never keeps its `work_order` (validate clears it for this
|
||||
# purpose), so the owning Work Order is derived from the Subcontracting Order / Purchase Order
|
||||
@@ -3689,9 +3703,6 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
row.stock_qty -= flt(used_secondary_items.get(key))
|
||||
row.stock_qty = (row.stock_qty) * flt(self.fg_completed_qty) / flt(pending_qty)
|
||||
|
||||
if used_secondary_items.get(key):
|
||||
used_secondary_items[key] -= row.stock_qty
|
||||
|
||||
if cint(frappe.get_cached_value("UOM", row.stock_uom, "must_be_whole_number")):
|
||||
row.stock_qty = frappe.utils.ceil(row.stock_qty)
|
||||
|
||||
@@ -3705,7 +3716,7 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
|
||||
StockEntry = frappe.qb.DocType("Stock Entry")
|
||||
StockEntryDetail = frappe.qb.DocType("Stock Entry Detail")
|
||||
data = (
|
||||
query = (
|
||||
frappe.qb.from_(StockEntry)
|
||||
.inner_join(StockEntryDetail)
|
||||
.on(StockEntryDetail.parent == StockEntry.name)
|
||||
@@ -3725,9 +3736,11 @@ class StockEntry(StockController, SubcontractingInwardController):
|
||||
& (StockEntry.docstatus == 1)
|
||||
& (StockEntry.purpose.isin(["Repack", "Manufacture"]))
|
||||
)
|
||||
).run(as_dict=1)
|
||||
)
|
||||
if self.job_card:
|
||||
query = query.where(StockEntry.job_card == self.job_card)
|
||||
|
||||
for row in data:
|
||||
for row in query.run(as_dict=1):
|
||||
used_secondary_items[get_secondary_item_key(row)] += row.qty
|
||||
|
||||
return used_secondary_items
|
||||
|
||||
@@ -665,6 +665,10 @@ class StockReservationEntry(Document):
|
||||
|
||||
entry.db_update()
|
||||
|
||||
@property
|
||||
def matched_serial_batch_qty(self):
|
||||
return sum(min(flt(entry.delivered_qty), flt(entry.qty)) for entry in self.sb_entries)
|
||||
|
||||
|
||||
def validate_stock_reservation_settings(voucher: object) -> None:
|
||||
"""Raises an exception if `Stock Reservation` is not enabled or `Voucher Type` is not allowed."""
|
||||
@@ -958,6 +962,33 @@ def get_sre_reserved_serial_nos_details(
|
||||
return frappe._dict(query.run())
|
||||
|
||||
|
||||
def get_sre_reserved_serial_nos_for_voucher_detail_nos(voucher_type: str, voucher_detail_nos: list) -> dict:
|
||||
"""Returns {voucher_detail_no: set of reserved Serial Nos}, including the delivered ones."""
|
||||
|
||||
sre = frappe.qb.DocType("Stock Reservation Entry")
|
||||
sb_entry = frappe.qb.DocType("Serial and Batch Entry")
|
||||
query = (
|
||||
frappe.qb.from_(sre)
|
||||
.inner_join(sb_entry)
|
||||
.on(sre.name == sb_entry.parent)
|
||||
.select(sre.voucher_detail_no, sb_entry.serial_no)
|
||||
.distinct()
|
||||
.where(
|
||||
(sre.docstatus == 1)
|
||||
& (sre.voucher_type == voucher_type)
|
||||
& (sre.voucher_detail_no.isin(voucher_detail_nos))
|
||||
& (sre.reservation_based_on == "Serial and Batch")
|
||||
& (sb_entry.serial_no.isnotnull())
|
||||
)
|
||||
)
|
||||
|
||||
reserved_serial_nos = {}
|
||||
for voucher_detail_no, serial_no in query.run():
|
||||
reserved_serial_nos.setdefault(voucher_detail_no, set()).add(serial_no)
|
||||
|
||||
return reserved_serial_nos
|
||||
|
||||
|
||||
def get_sre_reserved_batch_nos_details(item_code: str, warehouse: str, batch_nos: list | None = None) -> dict:
|
||||
"""Returns a dict of `Batch Qty` reserved in Stock Reservation Entry. The dict is like {batch_no: qty, ...}"""
|
||||
|
||||
@@ -1866,6 +1897,8 @@ def _get_stock_reservation_entries_for_voucher(
|
||||
"voucher_detail_no",
|
||||
"reserved_qty",
|
||||
"delivered_qty",
|
||||
"transferred_qty",
|
||||
"consumed_qty",
|
||||
"stock_uom",
|
||||
]
|
||||
|
||||
|
||||
411
erpnext/tests/js/bank_reconciliation_dialog_manager.test.mjs
Normal file
411
erpnext/tests/js/bank_reconciliation_dialog_manager.test.mjs
Normal file
@@ -0,0 +1,411 @@
|
||||
// Unit tests for the Bank Reconciliation Tool dialog's voucher type registry.
|
||||
// The desk script is evaluated in a sandbox with stubbed frappe globals; run with
|
||||
// `node --test erpnext/tests/js`.
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, it } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
|
||||
// values built inside the sandbox carry that realm's prototypes; strip them before strict comparisons
|
||||
const plain = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const SOURCE = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../public/js/bank_reconciliation_tool/dialog_manager.js"
|
||||
);
|
||||
|
||||
class Dialog {
|
||||
constructor(opts) {
|
||||
this.fields = opts.fields;
|
||||
this.primary_action = opts.primary_action;
|
||||
this.values = {};
|
||||
this.df_properties = {};
|
||||
this.hidden = false;
|
||||
this.shown = false;
|
||||
this.fields_dict = {};
|
||||
}
|
||||
set_df_property(fieldname, property, value) {
|
||||
this.df_properties[fieldname] = {
|
||||
...this.df_properties[fieldname],
|
||||
[property]: value,
|
||||
};
|
||||
}
|
||||
get_value(fieldname) {
|
||||
return this.values[fieldname];
|
||||
}
|
||||
set_value(fieldname, value) {
|
||||
this.values[fieldname] = value;
|
||||
}
|
||||
set_values(values) {
|
||||
Object.assign(this.values, values);
|
||||
}
|
||||
get_values() {
|
||||
return { ...this.values };
|
||||
}
|
||||
show() {
|
||||
this.shown = true;
|
||||
}
|
||||
hide() {
|
||||
this.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function load_dialog_manager({
|
||||
pre_registered = {},
|
||||
bank_transaction = null,
|
||||
} = {}) {
|
||||
const calls = {
|
||||
call: [],
|
||||
xcall: [],
|
||||
alerts: [],
|
||||
msgprints: [],
|
||||
routes: [],
|
||||
synced: [],
|
||||
form_handlers: {},
|
||||
};
|
||||
const sandbox = { console };
|
||||
|
||||
sandbox.frappe = {
|
||||
provide(namespace) {
|
||||
let object = sandbox;
|
||||
for (const part of namespace.split(".")) {
|
||||
object[part] = object[part] || {};
|
||||
object = object[part];
|
||||
}
|
||||
},
|
||||
call(opts) {
|
||||
calls.call.push(opts);
|
||||
if (opts.method.endsWith("get_doctypes_for_bank_reconciliation")) {
|
||||
opts.callback({ message: ["Payment Entry", "Journal Entry"] });
|
||||
} else if (opts.method === "frappe.client.get_value") {
|
||||
opts.callback({ message: bank_transaction });
|
||||
}
|
||||
},
|
||||
xcall(method, args) {
|
||||
calls.xcall.push({ method, args });
|
||||
return Promise.resolve(sandbox.xcall_result);
|
||||
},
|
||||
ui: {
|
||||
Dialog,
|
||||
form: {
|
||||
on(doctype, handlers) {
|
||||
calls.form_handlers[doctype] = handlers;
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
sync(message) {
|
||||
calls.synced.push(message);
|
||||
return [message];
|
||||
},
|
||||
},
|
||||
set_route: (...route) => calls.routes.push(route),
|
||||
show_alert: (message) => calls.alerts.push(message),
|
||||
msgprint: (message) => calls.msgprints.push(message),
|
||||
throw(message) {
|
||||
throw new Error(message);
|
||||
},
|
||||
scrub: (text) => text.toLowerCase().replace(/ /g, "_"),
|
||||
boot: {
|
||||
party_account_types: { Customer: "Receivable", Supplier: "Payable" },
|
||||
},
|
||||
};
|
||||
sandbox.__ = (text, args = []) =>
|
||||
text.replace(/\{(\d+)\}/g, (_, index) => args[index]);
|
||||
sandbox.$ = Object.assign(() => ({}), {
|
||||
each: (items, fn) => items.forEach((item, index) => fn(index, item)),
|
||||
});
|
||||
sandbox.format_currency = (value) => String(value);
|
||||
sandbox.erpnext = {
|
||||
accounts: { bank_reconciliation: { voucher_types: { ...pre_registered } } },
|
||||
};
|
||||
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(fs.readFileSync(SOURCE, "utf8"), sandbox, {
|
||||
filename: SOURCE,
|
||||
});
|
||||
|
||||
const registry = sandbox.erpnext.accounts.bank_reconciliation;
|
||||
const dialog_manager = new registry.DialogManager(
|
||||
"_Test Company",
|
||||
"HDFC - _TC",
|
||||
"2024-05-01",
|
||||
"2024-05-31"
|
||||
);
|
||||
return { calls, sandbox, registry, dialog_manager };
|
||||
}
|
||||
|
||||
function loan_repayment_type(overrides = {}) {
|
||||
return {
|
||||
is_applicable: (bank_transaction) => bank_transaction.deposit > 0,
|
||||
get_fields: () => [
|
||||
{ fieldname: "against_loan", fieldtype: "Link", options: "Loan" },
|
||||
],
|
||||
create: () => Promise.resolve({}),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const deposit = {
|
||||
name: "ACC-BTN-0001",
|
||||
deposit: 500,
|
||||
withdrawal: 0,
|
||||
date: "2024-05-05",
|
||||
description: "NEFT",
|
||||
};
|
||||
const withdrawal = {
|
||||
name: "ACC-BTN-0002",
|
||||
deposit: 0,
|
||||
withdrawal: 500,
|
||||
date: "2024-05-05",
|
||||
};
|
||||
|
||||
describe("voucher type registry", () => {
|
||||
it("keeps types registered before the bundle loaded, after the built-in ones", () => {
|
||||
const custom = loan_repayment_type();
|
||||
const { registry } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": custom },
|
||||
});
|
||||
|
||||
assert.deepEqual(plain(Object.keys(registry.voucher_types)), [
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
"Loan Repayment",
|
||||
]);
|
||||
assert.equal(registry.voucher_types["Loan Repayment"], custom);
|
||||
});
|
||||
|
||||
it("offers every type until a transaction is loaded, then filters by is_applicable", () => {
|
||||
const { dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": loan_repayment_type() },
|
||||
});
|
||||
|
||||
assert.deepEqual(plain(dialog_manager.get_document_types()), [
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
"Loan Repayment",
|
||||
]);
|
||||
|
||||
dialog_manager.bank_transaction = deposit;
|
||||
assert.deepEqual(plain(dialog_manager.get_document_types()), [
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
"Loan Repayment",
|
||||
]);
|
||||
|
||||
dialog_manager.bank_transaction = withdrawal;
|
||||
assert.deepEqual(plain(dialog_manager.get_document_types()), [
|
||||
"Payment Entry",
|
||||
"Journal Entry",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rewrites the Document Type options per transaction and resets a value that is no longer offered", () => {
|
||||
const { dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": loan_repayment_type() },
|
||||
bank_transaction: { ...withdrawal },
|
||||
});
|
||||
dialog_manager.dialog.set_value("document_type", "Loan Repayment");
|
||||
|
||||
dialog_manager.show_dialog("ACC-BTN-0002", () => {});
|
||||
|
||||
assert.equal(
|
||||
dialog_manager.dialog.df_properties.document_type.options,
|
||||
"Payment Entry\nJournal Entry"
|
||||
);
|
||||
assert.equal(
|
||||
dialog_manager.dialog.get_value("document_type"),
|
||||
"Payment Entry"
|
||||
);
|
||||
assert.equal(dialog_manager.dialog.get_value("posting_date"), "2024-05-05");
|
||||
assert.ok(dialog_manager.dialog.shown);
|
||||
});
|
||||
|
||||
it("keeps the chosen Document Type when the transaction still allows it", () => {
|
||||
const { dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": loan_repayment_type() },
|
||||
bank_transaction: { ...deposit },
|
||||
});
|
||||
dialog_manager.dialog.set_value("document_type", "Loan Repayment");
|
||||
|
||||
dialog_manager.show_dialog("ACC-BTN-0001", () => {});
|
||||
|
||||
assert.equal(
|
||||
dialog_manager.dialog.get_value("document_type"),
|
||||
"Loan Repayment"
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a registered type's fields to the dialog ahead of the transaction details", () => {
|
||||
let received = null;
|
||||
const custom = loan_repayment_type({
|
||||
get_fields: (dm) => {
|
||||
received = dm;
|
||||
return [
|
||||
{ fieldname: "against_loan", fieldtype: "Link", options: "Loan" },
|
||||
];
|
||||
},
|
||||
});
|
||||
const { dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": custom },
|
||||
});
|
||||
|
||||
const fieldnames = dialog_manager.dialog.fields.map(
|
||||
(field) => field.fieldname
|
||||
);
|
||||
assert.equal(received, dialog_manager);
|
||||
assert.ok(fieldnames.includes("against_loan"));
|
||||
assert.ok(
|
||||
fieldnames.indexOf("against_loan") > fieldnames.indexOf("cost_center")
|
||||
);
|
||||
assert.ok(
|
||||
fieldnames.indexOf("against_loan") < fieldnames.indexOf("details_section")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_voucher", () => {
|
||||
it("submits through the registered type and reconciles the dialog", async () => {
|
||||
const created = [];
|
||||
const reconciled_transaction = {
|
||||
name: "ACC-BTN-0001",
|
||||
unallocated_amount: 0,
|
||||
};
|
||||
const custom = loan_repayment_type({
|
||||
create: (...args) => {
|
||||
created.push(args);
|
||||
return Promise.resolve(reconciled_transaction);
|
||||
},
|
||||
});
|
||||
const { calls, dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": custom },
|
||||
});
|
||||
const updated = [];
|
||||
dialog_manager.bank_transaction = deposit;
|
||||
dialog_manager.update_dt_cards = (transaction) => updated.push(transaction);
|
||||
const values = {
|
||||
action: "Create Voucher",
|
||||
document_type: "Loan Repayment",
|
||||
against_loan: "LOAN-0001",
|
||||
};
|
||||
|
||||
await dialog_manager.reconciliation_dialog_primary_action(values);
|
||||
|
||||
assert.deepEqual(created, [[dialog_manager, values, false]]);
|
||||
assert.deepEqual(updated, [reconciled_transaction]);
|
||||
assert.match(calls.alerts[0], /ACC-BTN-0001 added as Loan Repayment/);
|
||||
assert.ok(dialog_manager.dialog.hidden);
|
||||
assert.deepEqual(calls.routes, []);
|
||||
});
|
||||
|
||||
it("opens the draft in full page and reconciles it once the form is submitted", async () => {
|
||||
const draft = {
|
||||
doctype: "Loan Repayment",
|
||||
name: "new-loan-repayment-1",
|
||||
__islocal: 1,
|
||||
};
|
||||
const custom = loan_repayment_type({
|
||||
create: () => Promise.resolve(draft),
|
||||
});
|
||||
const { calls, dialog_manager } = load_dialog_manager({
|
||||
pre_registered: { "Loan Repayment": custom },
|
||||
});
|
||||
dialog_manager.bank_transaction = deposit;
|
||||
dialog_manager.dialog.set_values({
|
||||
action: "Create Voucher",
|
||||
document_type: "Loan Repayment",
|
||||
});
|
||||
|
||||
await dialog_manager.edit_in_full_page();
|
||||
|
||||
assert.deepEqual(calls.synced, [draft]);
|
||||
assert.deepEqual(calls.routes, [
|
||||
["Form", "Loan Repayment", "new-loan-repayment-1"],
|
||||
]);
|
||||
assert.equal(dialog_manager.dialog.hidden, false);
|
||||
assert.deepEqual(calls.alerts, []);
|
||||
|
||||
const handlers = calls.form_handlers["Loan Repayment"];
|
||||
assert.ok(
|
||||
handlers,
|
||||
"registered types get the after-submit reconciliation hooks"
|
||||
);
|
||||
const frm = {
|
||||
doctype: "Loan Repayment",
|
||||
doc: { name: "new-loan-repayment-1" },
|
||||
};
|
||||
handlers.before_save(frm);
|
||||
frm.doc.name = "LM-REP-0001";
|
||||
handlers.after_save(frm);
|
||||
handlers.on_submit(frm);
|
||||
|
||||
const reconcile = calls.call.at(-1);
|
||||
assert.match(reconcile.method, /reconcile_vouchers$/);
|
||||
assert.deepEqual(plain(reconcile.args), {
|
||||
bank_transaction_name: "ACC-BTN-0001",
|
||||
vouchers: [
|
||||
{ payment_doctype: "Loan Repayment", payment_name: "LM-REP-0001" },
|
||||
],
|
||||
is_new_voucher: true,
|
||||
});
|
||||
|
||||
handlers.on_submit(frm);
|
||||
assert.equal(
|
||||
calls.call.at(-1),
|
||||
reconcile,
|
||||
"a voucher is only reconciled once"
|
||||
);
|
||||
});
|
||||
|
||||
it("passes allow_edit through to the built-in server methods", async () => {
|
||||
const { calls, sandbox, dialog_manager } = load_dialog_manager();
|
||||
sandbox.xcall_result = {
|
||||
doctype: "Payment Entry",
|
||||
name: "new-payment-entry-1",
|
||||
};
|
||||
dialog_manager.bank_transaction = deposit;
|
||||
|
||||
await dialog_manager.create_voucher(
|
||||
{
|
||||
document_type: "Payment Entry",
|
||||
party_type: "Customer",
|
||||
party: "_Test Customer",
|
||||
},
|
||||
true
|
||||
);
|
||||
await dialog_manager.create_voucher(
|
||||
{
|
||||
document_type: "Journal Entry",
|
||||
journal_entry_type: "Bank Entry",
|
||||
second_account: "Debtors - _TC",
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
const [payment_entry, journal_entry] = calls.xcall;
|
||||
assert.match(payment_entry.method, /create_payment_entry_bts$/);
|
||||
assert.equal(payment_entry.args.allow_edit, true);
|
||||
assert.equal(payment_entry.args.bank_transaction_name, "ACC-BTN-0001");
|
||||
assert.equal(payment_entry.args.company_bank_account, "HDFC - _TC");
|
||||
assert.match(journal_entry.method, /create_journal_entry_bts$/);
|
||||
assert.equal(journal_entry.args.allow_edit, true);
|
||||
assert.equal(journal_entry.args.entry_type, "Bank Entry");
|
||||
assert.equal(journal_entry.args.second_account, "Debtors - _TC");
|
||||
assert.ok(
|
||||
calls.form_handlers["Payment Entry"] &&
|
||||
calls.form_handlers["Journal Entry"]
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a document type that is not registered", () => {
|
||||
const { dialog_manager } = load_dialog_manager();
|
||||
dialog_manager.bank_transaction = deposit;
|
||||
|
||||
assert.throws(
|
||||
() => dialog_manager.create_voucher({ document_type: "Sales Invoice" }),
|
||||
/Cannot create Sales Invoice from a Bank Transaction/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -635,13 +635,13 @@ def validate_uom_is_integer(doc, uom_field, qty_fields, child_dt=None):
|
||||
for f in qty_fields:
|
||||
qty = d.get(f)
|
||||
if qty:
|
||||
precision = d.precision(f)
|
||||
if abs(cint(qty) - flt(qty, precision)) > 0.0000001:
|
||||
qty = flt(qty, d.precision(f))
|
||||
if qty != cint(qty):
|
||||
frappe.throw(
|
||||
_(
|
||||
"Row {1}: Quantity ({0}) cannot be a fraction. To allow this, disable '{2}' in UOM {3}."
|
||||
).format(
|
||||
flt(qty, precision),
|
||||
qty,
|
||||
d.idx,
|
||||
frappe.bold(_("Must be Whole Number")),
|
||||
frappe.bold(d.get(uom_field)),
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"scripts": {
|
||||
"postinstall": "cd banking && yarn install",
|
||||
"dev": "cd banking && yarn dev",
|
||||
"build": "cd banking && yarn build"
|
||||
"build": "cd banking && yarn build",
|
||||
"test:js": "node --test \"erpnext/tests/js/**/*.test.mjs\""
|
||||
},
|
||||
"devDependencies": {},
|
||||
"dependencies": {
|
||||
|
||||
Reference in New Issue
Block a user