Compare commits

..

25 Commits

Author SHA1 Message Date
Mihir Kandoi
2ff89ae15f fix(manufacturing): release plan reservations when the plan completes (backport #59449) (#59450) 2026-09-25 14:15:41 +00:00
Mihir Kandoi
3dd8af58fb fix(manufacturing): keep a closed production plan closed until it is reopened (backport #59454) (#59455) 2026-09-25 13:12:03 +00:00
Shllokkk
813b3dd2f3 Merge pull request #59452 from frappe/mergify/bp/version-16-hotfix/pr-59447
fix: show only company bank accounts in Bank Statement Import (backport #59447)
2026-09-25 18:06:04 +05:30
Shllokkk
398603ff0b fix: show only company bank accounts in Bank Statement Import
(cherry picked from commit 48720781ce)
2026-09-25 12:34:10 +00:00
Mihir Kandoi
b569861136 fix(manufacturing): explode phantom BOM rows by their stock qty (backport #59445) (#59446) 2026-09-25 18:02:42 +05:30
Mihir Kandoi
c21ee5ddf7 fix(manufacturing): offset plan reservations across warehouses (backport #59429) (#59440) 2026-09-25 17:31:41 +05:30
Sudharsanan Ashok
f3c74f4639 Merge pull request #59439 from frappe/mergify/bp/version-16-hotfix/pr-59430
fix(accounts): filter purchase register by supplier group from supplier master (backport #59430)
2026-09-25 15:02:00 +05:30
Sudharsanan Ashok
6686da2cce fix(accounts): filter purchase register by supplier group from supplier master (#59430)
(cherry picked from commit 75efa2d1cf)
2026-09-25 09:14:53 +00:00
Mihir Kandoi
4426228532 fix(manufacturing): scope secondary items to job card (backport #58260) (#59435) 2026-09-25 09:08:45 +00:00
Mihir Kandoi
223adf655f fix: count only matched batches on work order reservations (backport #59424) (#59427) 2026-09-25 08:39:05 +00:00
Mihir Kandoi
544ca623bd fix: release the work order reservation before previewing a stock entry (backport #59425) (#59428) 2026-09-25 08:18:58 +00:00
Deepesh Garg
30baa235e4 Merge pull request #59313 from frappe/mergify/bp/version-16-hotfix/pr-59276
feat: make bank reconciliation dialog voucher types extensible (#59276)
2026-09-25 10:59:33 +05:30
Deepesh Garg
3a7a7dbd58 test: cover the bank reconciliation voucher type registry
Node unit tests evaluate the dialog script with stubbed frappe globals and
cover preserving pre-registered types, filtering them per transaction,
injecting their dialog fields, the submit and full-page return contracts
and rejecting unregistered types. yarn test:js runs them and a js unit
tests job runs them on every PR.

Also restores the track_voucher call on the full-page path that the merge
with develop dropped, and registers the after-submit reconciliation hooks
for every registered voucher type.
2026-09-25 10:22:09 +05:30
Deepesh Garg
4fe41c925d feat: make bank reconciliation dialog voucher types extensible
Other apps can register additional "Create Voucher" document types in
erpnext.accounts.bank_reconciliation.voucher_types with their own dialog
fields, applicability check and creation call. Payment Entry and Journal
Entry now go through the same registry, which also removes the duplicated
create and edit-in-full-page call blocks.
2026-09-25 10:20:23 +05:30
mergify[bot]
032fcad61b fix: set correct transaction type on args copy while evaulating item wise pricing rule (backport #59406) (#59409)
fix: set correct transaction type on args copy while evaulating item wise pricing rule (#59406)

(cherry picked from commit 9cc2c2272a)

Co-authored-by: Diptanil Saha <diptanil@frappe.io>
2026-09-25 06:32:57 +05:30
Shllokkk
80b26b2f28 Merge pull request #59389 from frappe/mergify/bp/version-16-hotfix/pr-59386
fix: skip activities fetch on unsaved CRM doc (backport #59386)
2026-09-24 18:18:07 +05:30
Shllokkk
ede9f11500 fix: skip activities fetch on unsaved CRM doc
(cherry picked from commit 87052020fb)
2026-09-24 12:41:44 +00:00
Sudharsanan Ashok
5306aaf53d fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set (#59381)
* fix(stock): restrict delivery to reserved serial nos when ensure delivery by serial no is set

delivery note and sales invoice with update stock now throw on submit if a sales order item with ensure delivery based on produced serial no has no serial reservation, or if a delivered serial no is not one of the serial nos reserved for it. the check runs after the stock ledger is posted, so serial nos auto picked on submit are validated too.

* test(stock): add tests for delivery restricted to reserved produced serial nos

* fix(stock): reserve stock in work order for ensure delivery by serial no items

a new work order for a sales order item with ensure delivery based on produced serial no now reserves stock by default. the checkbox is hidden and cleared on the sales order when stock reservation is disabled in stock settings, and the delivery check is skipped in that case. the missing reservation error now asks to reserve the produced serial nos from the sales order, since reserve stock cannot be enabled on a work order after production.

* test(stock): add tests for work order reserve stock and ensure delivery checkbox clearing
2026-09-24 15:57:13 +05:30
Sudharsanan Ashok
3e851108f1 Merge pull request #59366 from frappe/mergify/bp/version-16-hotfix/pr-59168
fix(regional): report uae vat 201 sales vat in company currency  (backport #59168)
2026-09-24 10:58:35 +05:30
Sudharsanan11
e55efadd0d fix: resolve conflicts 2026-09-24 10:34:58 +05:30
Sudharsanan Ashok
2387c67952 fix(regional): report uae vat 201 sales vat in company currency (#59168)
(cherry picked from commit 6e0ec5af31)

# Conflicts:
#	erpnext/regional/report/uae_vat_201/uae_vat_201.py
2026-09-24 04:39:28 +00:00
Mihir Kandoi
0c02cbaf1e perf(stock): serve Item link search from an FTS5 trigram index (backport #59166) (#59350) 2026-09-24 08:39:16 +05:30
Mihir Kandoi
ad8f945cce fix: UOM whole number check truncated instead of rounding (backport #57861) (#59364) 2026-09-24 03:06:25 +00:00
Deepesh Garg
f1cdeab601 test: cover the bank reconciliation voucher type registry
Node unit tests evaluate the dialog script with stubbed frappe globals and
cover preserving pre-registered types, filtering them per transaction,
injecting their dialog fields, the submit and full-page return contracts
and rejecting unregistered types. yarn test:js runs them and a js unit
tests job runs them on every PR.

Also restores the track_voucher call on the full-page path that the merge
with develop dropped, and registers the after-submit reconciliation hooks
for every registered voucher type.

(cherry picked from commit 60f0a736ab)

# Conflicts:
#	erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
2026-09-23 07:19:23 +00:00
Deepesh Garg
a657adba37 feat: make bank reconciliation dialog voucher types extensible
Other apps can register additional "Create Voucher" document types in
erpnext.accounts.bank_reconciliation.voucher_types with their own dialog
fields, applicability check and creation call. Payment Entry and Journal
Entry now go through the same registry, which also removes the duplicated
create and edit-in-full-page call blocks.

(cherry picked from commit 96db1071da)

# Conflicts:
#	erpnext/public/js/bank_reconciliation_tool/dialog_manager.js
2026-09-23 07:19:23 +00:00
37 changed files with 2240 additions and 176 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -7,6 +7,7 @@ frappe.ui.form.on("Bank Statement Import", {
return {
filters: {
company: doc.company,
is_company_account: 1,
},
};
});

View File

@@ -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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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",

View File

@@ -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,
)

View File

@@ -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."""

View File

@@ -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",

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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)

View File

@@ -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},

View File

@@ -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"),

View File

@@ -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",

View File

@@ -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);

View File

@@ -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";

View File

@@ -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

View File

@@ -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"):

View File

@@ -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

View File

@@ -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",

View File

@@ -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."""

View File

@@ -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.",

View File

@@ -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()

View File

@@ -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")

View 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

View 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)

View File

@@ -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

View File

@@ -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",
]

View 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/
);
});
});

View File

@@ -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)),

View File

@@ -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": {