Merge pull request #34564 from rohitwaghchaure/serial-no-normalization

Feat: Serial No Normalization and Serial Batch Bundle
This commit is contained in:
rohitwaghchaure 2023-06-02 18:13:02 +05:30 committed by GitHub
commit 14292ffc6f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
107 changed files with 8281 additions and 4726 deletions

View File

@ -3,7 +3,7 @@
import frappe import frappe
from frappe import _ from frappe import _, bold
from frappe.query_builder.functions import IfNull, Sum from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate from frappe.utils import cint, flt, get_link_to_form, getdate, nowdate
@ -16,12 +16,7 @@ from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
update_multi_mode_option, update_multi_mode_option,
) )
from erpnext.accounts.party import get_due_date, get_party_account from erpnext.accounts.party import get_due_date, get_party_account
from erpnext.stock.doctype.batch.batch import get_batch_qty, get_pos_reserved_batch_qty from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.serial_no.serial_no import (
get_delivered_serial_nos,
get_pos_reserved_serial_nos,
get_serial_nos,
)
class POSInvoice(SalesInvoice): class POSInvoice(SalesInvoice):
@ -71,6 +66,7 @@ class POSInvoice(SalesInvoice):
self.apply_loyalty_points() self.apply_loyalty_points()
self.check_phone_payments() self.check_phone_payments()
self.set_status(update=True) self.set_status(update=True)
self.submit_serial_batch_bundle()
if self.coupon_code: if self.coupon_code:
from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count from erpnext.accounts.doctype.pricing_rule.utils import update_coupon_code_count
@ -112,6 +108,29 @@ class POSInvoice(SalesInvoice):
update_coupon_code_count(self.coupon_code, "cancelled") update_coupon_code_count(self.coupon_code, "cancelled")
self.delink_serial_and_batch_bundle()
def delink_serial_and_batch_bundle(self):
for row in self.items:
if row.serial_and_batch_bundle:
if not self.consolidated_invoice:
frappe.db.set_value(
"Serial and Batch Bundle",
row.serial_and_batch_bundle,
{"is_cancelled": 1, "voucher_no": ""},
)
row.db_set("serial_and_batch_bundle", None)
def submit_serial_batch_bundle(self):
for item in self.items:
if item.serial_and_batch_bundle:
doc = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
if doc.docstatus == 0:
doc.flags.ignore_voucher_validation = True
doc.submit()
def check_phone_payments(self): def check_phone_payments(self):
for pay in self.payments: for pay in self.payments:
if pay.type == "Phone" and pay.amount >= 0: if pay.type == "Phone" and pay.amount >= 0:
@ -129,88 +148,6 @@ class POSInvoice(SalesInvoice):
if paid_amt and pay.amount != paid_amt: if paid_amt and pay.amount != paid_amt:
return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment)) return frappe.throw(_("Payment related to {0} is not completed").format(pay.mode_of_payment))
def validate_pos_reserved_serial_nos(self, item):
serial_nos = get_serial_nos(item.serial_no)
filters = {"item_code": item.item_code, "warehouse": item.warehouse}
if item.batch_no:
filters["batch_no"] = item.batch_no
reserved_serial_nos = get_pos_reserved_serial_nos(filters)
invalid_serial_nos = [s for s in serial_nos if s in reserved_serial_nos]
bold_invalid_serial_nos = frappe.bold(", ".join(invalid_serial_nos))
if len(invalid_serial_nos) == 1:
frappe.throw(
_(
"Row #{}: Serial No. {} has already been transacted into another POS Invoice. Please select valid serial no."
).format(item.idx, bold_invalid_serial_nos),
title=_("Item Unavailable"),
)
elif invalid_serial_nos:
frappe.throw(
_(
"Row #{}: Serial Nos. {} have already been transacted into another POS Invoice. Please select valid serial no."
).format(item.idx, bold_invalid_serial_nos),
title=_("Item Unavailable"),
)
def validate_pos_reserved_batch_qty(self, item):
filters = {"item_code": item.item_code, "warehouse": item.warehouse, "batch_no": item.batch_no}
available_batch_qty = get_batch_qty(item.batch_no, item.warehouse, item.item_code)
reserved_batch_qty = get_pos_reserved_batch_qty(filters)
bold_item_name = frappe.bold(item.item_name)
bold_extra_batch_qty_needed = frappe.bold(
abs(available_batch_qty - reserved_batch_qty - item.stock_qty)
)
bold_invalid_batch_no = frappe.bold(item.batch_no)
if (available_batch_qty - reserved_batch_qty) == 0:
frappe.throw(
_(
"Row #{}: Batch No. {} of item {} has no stock available. Please select valid batch no."
).format(item.idx, bold_invalid_batch_no, bold_item_name),
title=_("Item Unavailable"),
)
elif (available_batch_qty - reserved_batch_qty - item.stock_qty) < 0:
frappe.throw(
_(
"Row #{}: Batch No. {} of item {} has less than required stock available, {} more required"
).format(
item.idx, bold_invalid_batch_no, bold_item_name, bold_extra_batch_qty_needed
),
title=_("Item Unavailable"),
)
def validate_delivered_serial_nos(self, item):
delivered_serial_nos = get_delivered_serial_nos(item.serial_no)
if delivered_serial_nos:
bold_delivered_serial_nos = frappe.bold(", ".join(delivered_serial_nos))
frappe.throw(
_(
"Row #{}: Serial No. {} has already been transacted into another Sales Invoice. Please select valid serial no."
).format(item.idx, bold_delivered_serial_nos),
title=_("Item Unavailable"),
)
def validate_invalid_serial_nos(self, item):
serial_nos = get_serial_nos(item.serial_no)
error_msg = []
invalid_serials, msg = "", ""
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
invalid_serials = invalid_serials + (", " if invalid_serials else "") + serial_no
msg = _("Row #{}: Following Serial numbers for item {} are <b>Invalid</b>: {}").format(
item.idx, frappe.bold(item.get("item_code")), frappe.bold(invalid_serials)
)
if invalid_serials:
error_msg.append(msg)
if error_msg:
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True)
def validate_stock_availablility(self): def validate_stock_availablility(self):
if self.is_return: if self.is_return:
return return
@ -223,13 +160,7 @@ class POSInvoice(SalesInvoice):
from erpnext.stock.stock_ledger import is_negative_stock_allowed from erpnext.stock.stock_ledger import is_negative_stock_allowed
for d in self.get("items"): for d in self.get("items"):
if d.serial_no: if not d.serial_and_batch_bundle:
self.validate_pos_reserved_serial_nos(d)
self.validate_delivered_serial_nos(d)
self.validate_invalid_serial_nos(d)
elif d.batch_no:
self.validate_pos_reserved_batch_qty(d)
else:
if is_negative_stock_allowed(item_code=d.item_code): if is_negative_stock_allowed(item_code=d.item_code):
return return
@ -258,36 +189,15 @@ class POSInvoice(SalesInvoice):
def validate_serialised_or_batched_item(self): def validate_serialised_or_batched_item(self):
error_msg = [] error_msg = []
for d in self.get("items"): for d in self.get("items"):
serialized = d.get("has_serial_no") error_msg = ""
batched = d.get("has_batch_no") if d.get("has_serial_no") and not d.serial_and_batch_bundle:
no_serial_selected = not d.get("serial_no") error_msg = f"Row #{d.idx}: Please select Serial No. for item {bold(d.item_code)}"
no_batch_selected = not d.get("batch_no")
msg = "" elif d.get("has_batch_no") and not d.serial_and_batch_bundle:
item_code = frappe.bold(d.item_code) error_msg = f"Row #{d.idx}: Please select Batch No. for item {bold(d.item_code)}"
serial_nos = get_serial_nos(d.serial_no)
if serialized and batched and (no_batch_selected or no_serial_selected):
msg = _(
"Row #{}: Please select a serial no and batch against item: {} or remove it to complete transaction."
).format(d.idx, item_code)
elif serialized and no_serial_selected:
msg = _(
"Row #{}: No serial number selected against item: {}. Please select one or remove it to complete transaction."
).format(d.idx, item_code)
elif batched and no_batch_selected:
msg = _(
"Row #{}: No batch selected against item: {}. Please select a batch or remove it to complete transaction."
).format(d.idx, item_code)
elif serialized and not no_serial_selected and len(serial_nos) != d.qty:
msg = _("Row #{}: You must select {} serial numbers for item {}.").format(
d.idx, frappe.bold(cint(d.qty)), item_code
)
if msg:
error_msg.append(msg)
if error_msg: if error_msg:
frappe.throw(error_msg, title=_("Invalid Item"), as_list=True) frappe.throw(error_msg, title=_("Serial / Batch Bundle Missing"), as_list=True)
def validate_return_items_qty(self): def validate_return_items_qty(self):
if not self.get("is_return"): if not self.get("is_return"):
@ -652,7 +562,7 @@ def get_bundle_availability(bundle_item_code, warehouse):
item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse) item_pos_reserved_qty = get_pos_reserved_qty(item.item_code, warehouse)
available_qty = item_bin_qty - item_pos_reserved_qty available_qty = item_bin_qty - item_pos_reserved_qty
max_available_bundles = available_qty / item.stock_qty max_available_bundles = available_qty / item.qty
if bundle_bin_qty > max_available_bundles and frappe.get_value( if bundle_bin_qty > max_available_bundles and frappe.get_value(
"Item", item.item_code, "is_stock_item" "Item", item.item_code, "is_stock_item"
): ):

View File

@ -5,12 +5,18 @@ import copy
import unittest import unittest
import frappe import frappe
from frappe import _
from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return from erpnext.accounts.doctype.pos_invoice.pos_invoice import make_sales_return
from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@ -249,7 +255,7 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
pos = create_pos_invoice( pos = create_pos_invoice(
company="_Test Company", company="_Test Company",
@ -260,11 +266,11 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
serial_no=[serial_nos[0]],
rate=1000, rate=1000,
do_not_save=1, do_not_save=1,
) )
pos.get("items")[0].serial_no = serial_nos[0]
pos.append( pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1} "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
) )
@ -276,7 +282,9 @@ class TestPOSInvoice(unittest.TestCase):
pos_return.insert() pos_return.insert()
pos_return.submit() pos_return.submit()
self.assertEqual(pos_return.get("items")[0].serial_no, serial_nos[0]) self.assertEqual(
get_serial_nos_from_bundle(pos_return.get("items")[0].serial_and_batch_bundle)[0], serial_nos[0]
)
def test_partial_pos_returns(self): def test_partial_pos_returns(self):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@ -289,7 +297,7 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
pos = create_pos_invoice( pos = create_pos_invoice(
company="_Test Company", company="_Test Company",
@ -300,12 +308,12 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
serial_no=serial_nos,
qty=2, qty=2,
rate=1000, rate=1000,
do_not_save=1, do_not_save=1,
) )
pos.get("items")[0].serial_no = serial_nos[0] + "\n" + serial_nos[1]
pos.append( pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1} "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
) )
@ -317,14 +325,27 @@ class TestPOSInvoice(unittest.TestCase):
# partial return 1 # partial return 1
pos_return1.get("items")[0].qty = -1 pos_return1.get("items")[0].qty = -1
pos_return1.get("items")[0].serial_no = serial_nos[0]
bundle_id = frappe.get_doc(
"Serial and Batch Bundle", pos_return1.get("items")[0].serial_and_batch_bundle
)
bundle_id.remove(bundle_id.entries[1])
bundle_id.save()
bundle_id.load_from_db()
serial_no = bundle_id.entries[0].serial_no
self.assertEqual(serial_no, serial_nos[0])
pos_return1.insert() pos_return1.insert()
pos_return1.submit() pos_return1.submit()
# partial return 2 # partial return 2
pos_return2 = make_sales_return(pos.name) pos_return2 = make_sales_return(pos.name)
self.assertEqual(pos_return2.get("items")[0].qty, -1) self.assertEqual(pos_return2.get("items")[0].qty, -1)
self.assertEqual(pos_return2.get("items")[0].serial_no, serial_nos[1]) serial_no = get_serial_nos_from_bundle(pos_return2.get("items")[0].serial_and_batch_bundle)[0]
self.assertEqual(serial_no, serial_nos[1])
def test_pos_change_amount(self): def test_pos_change_amount(self):
pos = create_pos_invoice( pos = create_pos_invoice(
@ -368,7 +389,7 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
pos = create_pos_invoice( pos = create_pos_invoice(
company="_Test Company", company="_Test Company",
@ -380,10 +401,10 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
serial_no=[serial_nos[0]],
do_not_save=1, do_not_save=1,
) )
pos.get("items")[0].serial_no = serial_nos[0]
pos.append( pos.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
) )
@ -401,10 +422,10 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
serial_no=[serial_nos[0]],
do_not_save=1, do_not_save=1,
) )
pos2.get("items")[0].serial_no = serial_nos[0]
pos2.append( pos2.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
) )
@ -423,7 +444,7 @@ class TestPOSInvoice(unittest.TestCase):
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
si = create_sales_invoice( si = create_sales_invoice(
company="_Test Company", company="_Test Company",
@ -435,11 +456,11 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
update_stock=1,
serial_no=[serial_nos[0]],
do_not_save=1, do_not_save=1,
) )
si.get("items")[0].serial_no = serial_nos[0]
si.update_stock = 1
si.insert() si.insert()
si.submit() si.submit()
@ -453,10 +474,10 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
serial_no=[serial_nos[0]],
do_not_save=1, do_not_save=1,
) )
pos2.get("items")[0].serial_no = serial_nos[0]
pos2.append( pos2.append(
"payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000} "payments", {"mode_of_payment": "Bank Draft", "account": "_Test Bank - _TC", "amount": 1000}
) )
@ -473,7 +494,7 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = se.get("items")[0].serial_no + "wrong" serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0] + "wrong"
pos = create_pos_invoice( pos = create_pos_invoice(
company="_Test Company", company="_Test Company",
@ -486,14 +507,13 @@ class TestPOSInvoice(unittest.TestCase):
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
qty=2, qty=2,
serial_nos=[serial_nos],
do_not_save=1, do_not_save=1,
) )
pos.get("items")[0].has_serial_no = 1 pos.get("items")[0].has_serial_no = 1
pos.get("items")[0].serial_no = serial_nos
pos.insert()
self.assertRaises(frappe.ValidationError, pos.submit) self.assertRaises(frappe.ValidationError, pos.insert)
def test_value_error_on_serial_no_validation(self): def test_value_error_on_serial_no_validation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
@ -504,7 +524,7 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
expense_account="Cost of Goods Sold - _TC", expense_account="Cost of Goods Sold - _TC",
) )
serial_nos = se.get("items")[0].serial_no serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
# make a pos invoice # make a pos invoice
pos = create_pos_invoice( pos = create_pos_invoice(
@ -517,11 +537,11 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
serial_no=[serial_nos[0]],
qty=1, qty=1,
do_not_save=1, do_not_save=1,
) )
pos.get("items")[0].has_serial_no = 1 pos.get("items")[0].has_serial_no = 1
pos.get("items")[0].serial_no = serial_nos.split("\n")[0]
pos.set("payments", []) pos.set("payments", [])
pos.append( pos.append(
"payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1} "payments", {"mode_of_payment": "Cash", "account": "Cash - _TC", "amount": 1000, "default": 1}
@ -547,12 +567,12 @@ class TestPOSInvoice(unittest.TestCase):
cost_center="Main - _TC", cost_center="Main - _TC",
item=se.get("items")[0].item_code, item=se.get("items")[0].item_code,
rate=1000, rate=1000,
serial_no=[serial_nos[0]],
qty=1, qty=1,
do_not_save=1, do_not_save=1,
) )
pos2.get("items")[0].has_serial_no = 1 pos2.get("items")[0].has_serial_no = 1
pos2.get("items")[0].serial_no = serial_nos.split("\n")[0]
# Value error should not be triggered on validation # Value error should not be triggered on validation
pos2.save() pos2.save()
@ -748,16 +768,16 @@ class TestPOSInvoice(unittest.TestCase):
self.assertEqual(rounded_total, 400) self.assertEqual(rounded_total, 400)
def test_pos_batch_item_qty_validation(self): def test_pos_batch_item_qty_validation(self):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
BatchNegativeStockError,
)
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_batch_item_with_batch, create_batch_item_with_batch,
) )
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01") create_batch_item_with_batch("_BATCH ITEM", "TestBatch 01")
item = frappe.get_doc("Item", "_BATCH ITEM") item = frappe.get_doc("Item", "_BATCH ITEM")
batch = frappe.get_doc("Batch", "TestBatch 01")
batch.submit()
item.batch_no = "TestBatch 01"
item.save()
se = make_stock_entry( se = make_stock_entry(
target="_Test Warehouse - _TC", target="_Test Warehouse - _TC",
@ -767,16 +787,28 @@ class TestPOSInvoice(unittest.TestCase):
batch_no="TestBatch 01", batch_no="TestBatch 01",
) )
pos_inv1 = create_pos_invoice(item=item.name, rate=300, qty=1, do_not_submit=1) pos_inv1 = create_pos_invoice(
pos_inv1.items[0].batch_no = "TestBatch 01" item=item.name, rate=300, qty=1, do_not_submit=1, batch_no="TestBatch 01"
)
pos_inv1.save() pos_inv1.save()
pos_inv1.submit() pos_inv1.submit()
pos_inv2 = create_pos_invoice(item=item.name, rate=300, qty=2, do_not_submit=1) pos_inv2 = create_pos_invoice(item=item.name, rate=300, qty=2, do_not_submit=1)
pos_inv2.items[0].batch_no = "TestBatch 01"
pos_inv2.save()
self.assertRaises(frappe.ValidationError, pos_inv2.submit) sn_doc = SerialBatchCreation(
{
"item_code": item.name,
"warehouse": pos_inv2.items[0].warehouse,
"voucher_type": "Delivery Note",
"qty": 2,
"avg_rate": 300,
"batches": frappe._dict({"TestBatch 01": 2}),
"type_of_transaction": "Outward",
"company": pos_inv2.company,
}
)
self.assertRaises(BatchNegativeStockError, sn_doc.make_serial_and_batch_bundle)
# teardown # teardown
pos_inv1.reload() pos_inv1.reload()
@ -785,9 +817,6 @@ class TestPOSInvoice(unittest.TestCase):
pos_inv2.reload() pos_inv2.reload()
pos_inv2.delete() pos_inv2.delete()
se.cancel() se.cancel()
batch.reload()
batch.cancel()
batch.delete()
def test_ignore_pricing_rule(self): def test_ignore_pricing_rule(self):
from erpnext.accounts.doctype.pricing_rule.test_pricing_rule import make_pricing_rule from erpnext.accounts.doctype.pricing_rule.test_pricing_rule import make_pricing_rule
@ -838,18 +867,18 @@ class TestPOSInvoice(unittest.TestCase):
frappe.db.savepoint("before_test_delivered_serial_no_case") frappe.db.savepoint("before_test_delivered_serial_no_case")
try: try:
se = make_serialized_item() se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]
dn = create_delivery_note(item_code="_Test Serialized Item With Series", serial_no=serial_no) dn = create_delivery_note(item_code="_Test Serialized Item With Series", serial_no=[serial_no])
delivered_serial_no = get_serial_nos_from_bundle(dn.get("items")[0].serial_and_batch_bundle)[0]
delivery_document_no = frappe.db.get_value("Serial No", serial_no, "delivery_document_no") self.assertEqual(serial_no, delivered_serial_no)
self.assertEquals(delivery_document_no, dn.name)
init_user_and_profile() init_user_and_profile()
pos_inv = create_pos_invoice( pos_inv = create_pos_invoice(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
serial_no=serial_no, serial_no=[serial_no],
qty=1, qty=1,
rate=100, rate=100,
do_not_submit=True, do_not_submit=True,
@ -861,42 +890,6 @@ class TestPOSInvoice(unittest.TestCase):
frappe.db.rollback(save_point="before_test_delivered_serial_no_case") frappe.db.rollback(save_point="before_test_delivered_serial_no_case")
frappe.set_user("Administrator") frappe.set_user("Administrator")
def test_returned_serial_no_case(self):
from erpnext.accounts.doctype.pos_invoice_merge_log.test_pos_invoice_merge_log import (
init_user_and_profile,
)
from erpnext.stock.doctype.serial_no.serial_no import get_pos_reserved_serial_nos
from erpnext.stock.doctype.serial_no.test_serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
frappe.db.savepoint("before_test_returned_serial_no_case")
try:
se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0]
init_user_and_profile()
pos_inv = create_pos_invoice(
item_code="_Test Serialized Item With Series",
serial_no=serial_no,
qty=1,
rate=100,
)
pos_return = make_sales_return(pos_inv.name)
pos_return.flags.ignore_validate = True
pos_return.insert()
pos_return.submit()
pos_reserved_serial_nos = get_pos_reserved_serial_nos(
{"item_code": "_Test Serialized Item With Series", "warehouse": "_Test Warehouse - _TC"}
)
self.assertTrue(serial_no not in pos_reserved_serial_nos)
finally:
frappe.db.rollback(save_point="before_test_returned_serial_no_case")
frappe.set_user("Administrator")
def create_pos_invoice(**args): def create_pos_invoice(**args):
args = frappe._dict(args) args = frappe._dict(args)
@ -926,6 +919,40 @@ def create_pos_invoice(**args):
pos_inv.set_missing_values() pos_inv.set_missing_values()
bundle_id = None
if args.get("batch_no") or args.get("serial_no"):
type_of_transaction = args.type_of_transaction or "Outward"
if pos_inv.is_return:
type_of_transaction = "Inward"
qty = args.get("qty") or 1
qty *= -1 if type_of_transaction == "Outward" else 1
batches = {}
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": args.item or args.item_code or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Delivery Note",
"serial_nos": args.serial_no,
"posting_date": pos_inv.posting_date,
"posting_time": pos_inv.posting_time,
"type_of_transaction": type_of_transaction,
"do_not_submit": True,
}
)
).name
if not bundle_id:
msg = f"Serial No {args.serial_no} not available for Item {args.item}"
frappe.throw(_(msg))
pos_inv.append( pos_inv.append(
"items", "items",
{ {
@ -936,8 +963,7 @@ def create_pos_invoice(**args):
"income_account": args.income_account or "Sales - _TC", "income_account": args.income_account or "Sales - _TC",
"expense_account": args.expense_account or "Cost of Goods Sold - _TC", "expense_account": args.expense_account or "Cost of Goods Sold - _TC",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle_id,
"batch_no": args.batch_no,
}, },
) )

View File

@ -79,6 +79,7 @@
"warehouse", "warehouse",
"target_warehouse", "target_warehouse",
"quality_inspection", "quality_inspection",
"serial_and_batch_bundle",
"batch_no", "batch_no",
"col_break5", "col_break5",
"allow_zero_valuation_rate", "allow_zero_valuation_rate",
@ -628,10 +629,11 @@
{ {
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "hidden": 1,
"label": "Batch No", "label": "Batch No",
"options": "Batch", "options": "Batch",
"print_hide": 1 "print_hide": 1,
"read_only": 1
}, },
{ {
"fieldname": "col_break5", "fieldname": "col_break5",
@ -648,10 +650,12 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"hidden": 1,
"in_list_view": 1, "in_list_view": 1,
"label": "Serial No", "label": "Serial No",
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
"oldfieldtype": "Small Text" "oldfieldtype": "Small Text",
"read_only": 1
}, },
{ {
"fieldname": "item_tax_rate", "fieldname": "item_tax_rate",
@ -817,11 +821,19 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Has Item Scanned", "label": "Has Item Scanned",
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-11-02 12:52:39.125295", "modified": "2023-03-12 13:36:40.160468",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "POS Invoice Item", "name": "POS Invoice Item",

View File

@ -184,6 +184,8 @@ class POSInvoiceMergeLog(Document):
item.base_amount = item.base_net_amount item.base_amount = item.base_net_amount
item.price_list_rate = 0 item.price_list_rate = 0
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"}) si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
if item.serial_and_batch_bundle:
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
items.append(si_item) items.append(si_item)
for tax in doc.get("taxes"): for tax in doc.get("taxes"):
@ -385,7 +387,7 @@ def split_invoices(invoices):
] ]
for pos_invoice in pos_return_docs: for pos_invoice in pos_return_docs:
for item in pos_invoice.items: for item in pos_invoice.items:
if not item.serial_no: if not item.serial_no and not item.serial_and_batch_bundle:
continue continue
return_against_is_added = any( return_against_is_added = any(

View File

@ -13,6 +13,9 @@ from erpnext.accounts.doctype.pos_invoice.test_pos_invoice import create_pos_inv
from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import ( from erpnext.accounts.doctype.pos_invoice_merge_log.pos_invoice_merge_log import (
consolidate_pos_invoices, consolidate_pos_invoices,
) )
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_serial_nos_from_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@ -410,13 +413,13 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
try: try:
se = make_serialized_item() se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]
init_user_and_profile() init_user_and_profile()
pos_inv = create_pos_invoice( pos_inv = create_pos_invoice(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
serial_no=serial_no, serial_no=[serial_no],
qty=1, qty=1,
rate=100, rate=100,
do_not_submit=1, do_not_submit=1,
@ -430,7 +433,7 @@ class TestPOSInvoiceMergeLog(unittest.TestCase):
pos_inv2 = create_pos_invoice( pos_inv2 = create_pos_invoice(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
serial_no=serial_no, serial_no=[serial_no],
qty=1, qty=1,
rate=100, rate=100,
do_not_submit=1, do_not_submit=1,

View File

@ -237,10 +237,6 @@ def apply_pricing_rule(args, doc=None):
item_list = args.get("items") item_list = args.get("items")
args.pop("items") args.pop("items")
set_serial_nos_based_on_fifo = frappe.db.get_single_value(
"Stock Settings", "automatically_set_serial_nos_based_on_fifo"
)
item_code_list = tuple(item.get("item_code") for item in item_list) item_code_list = tuple(item.get("item_code") for item in item_list)
query_items = frappe.get_all( query_items = frappe.get_all(
"Item", "Item",
@ -258,28 +254,9 @@ def apply_pricing_rule(args, doc=None):
data = get_pricing_rule_for_item(args_copy, doc=doc) data = get_pricing_rule_for_item(args_copy, doc=doc)
out.append(data) out.append(data)
if (
serialized_items.get(item.get("item_code"))
and not item.get("serial_no")
and set_serial_nos_based_on_fifo
and not args.get("is_return")
):
out[0].update(get_serial_no_for_item(args_copy))
return out return out
def get_serial_no_for_item(args):
from erpnext.stock.get_item_details import get_serial_no
item_details = frappe._dict(
{"doctype": args.doctype, "name": args.name, "serial_no": args.serial_no}
)
if args.get("parenttype") in ("Sales Invoice", "Delivery Note") and flt(args.stock_qty) > 0:
item_details.serial_no = get_serial_no(args)
return item_details
def update_pricing_rule_uom(pricing_rule, args): def update_pricing_rule_uom(pricing_rule, args):
child_doc = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}.get( child_doc = {"Item Code": "items", "Item Group": "item_groups", "Brand": "brands"}.get(
pricing_rule.apply_on pricing_rule.apply_on

View File

@ -102,9 +102,6 @@ class PurchaseInvoice(BuyingController):
# validate service stop date to lie in between start and end date # validate service stop date to lie in between start and end date
validate_service_stop_date(self) validate_service_stop_date(self)
if self._action == "submit" and self.update_stock:
self.make_batches("warehouse")
self.validate_release_date() self.validate_release_date()
self.check_conversion_rate() self.check_conversion_rate()
self.validate_credit_to_acc() self.validate_credit_to_acc()
@ -513,10 +510,6 @@ class PurchaseInvoice(BuyingController):
if self.is_old_subcontracting_flow: if self.is_old_subcontracting_flow:
self.set_consumed_qty_in_subcontract_order() self.set_consumed_qty_in_subcontract_order()
from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit
update_serial_nos_after_submit(self, "items")
# this sequence because outstanding may get -negative # this sequence because outstanding may get -negative
self.make_gl_entries() self.make_gl_entries()
@ -1448,6 +1441,7 @@ class PurchaseInvoice(BuyingController):
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Payment Ledger Entry", "Payment Ledger Entry",
"Tax Withheld Vouchers", "Tax Withheld Vouchers",
"Serial and Batch Bundle",
) )
self.update_advance_tax_references(cancel=1) self.update_advance_tax_references(cancel=1)

View File

@ -26,6 +26,11 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_taxes, get_taxes,
make_purchase_receipt, make_purchase_receipt,
) )
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction from erpnext.stock.doctype.stock_entry.test_stock_entry import get_qty_after_transaction
from erpnext.stock.tests.test_utils import StockTestMixin from erpnext.stock.tests.test_utils import StockTestMixin
@ -888,14 +893,20 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
rejected_warehouse="_Test Rejected Warehouse - _TC", rejected_warehouse="_Test Rejected Warehouse - _TC",
allow_zero_valuation_rate=1, allow_zero_valuation_rate=1,
) )
pi.load_from_db()
serial_no = get_serial_nos_from_bundle(pi.get("items")[0].serial_and_batch_bundle)[0]
rejected_serial_no = get_serial_nos_from_bundle(
pi.get("items")[0].rejected_serial_and_batch_bundle
)[0]
self.assertEqual( self.assertEqual(
frappe.db.get_value("Serial No", pi.get("items")[0].serial_no, "warehouse"), frappe.db.get_value("Serial No", serial_no, "warehouse"),
pi.get("items")[0].warehouse, pi.get("items")[0].warehouse,
) )
self.assertEqual( self.assertEqual(
frappe.db.get_value("Serial No", pi.get("items")[0].rejected_serial_no, "warehouse"), frappe.db.get_value("Serial No", rejected_serial_no, "warehouse"),
pi.get("items")[0].rejected_warehouse, pi.get("items")[0].rejected_warehouse,
) )
@ -1652,7 +1663,7 @@ class TestPurchaseInvoice(unittest.TestCase, StockTestMixin):
) )
pi.load_from_db() pi.load_from_db()
batch_no = pi.items[0].batch_no batch_no = get_batch_from_bundle(pi.items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(nowdate(), -1)) frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(nowdate(), -1))
@ -1734,6 +1745,32 @@ def make_purchase_invoice(**args):
pi.supplier_warehouse = args.supplier_warehouse or "_Test Warehouse 1 - _TC" pi.supplier_warehouse = args.supplier_warehouse or "_Test Warehouse 1 - _TC"
pi.cost_center = args.parent_cost_center pi.cost_center = args.parent_cost_center
bundle_id = None
if args.get("batch_no") or args.get("serial_no"):
batches = {}
qty = args.qty or 5
item_code = args.item or args.item_code or "_Test Item"
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
serial_nos = args.get("serial_no") or []
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_code,
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Purchase Invoice",
"serial_nos": serial_nos,
"type_of_transaction": "Inward",
"posting_date": args.posting_date or today(),
"posting_time": args.posting_time,
}
)
).name
pi.append( pi.append(
"items", "items",
{ {
@ -1748,12 +1785,11 @@ def make_purchase_invoice(**args):
"discount_account": args.discount_account or None, "discount_account": args.discount_account or None,
"discount_amount": args.discount_amount or 0, "discount_amount": args.discount_amount or 0,
"conversion_factor": 1.0, "conversion_factor": 1.0,
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle_id,
"stock_uom": args.uom or "_Test UOM", "stock_uom": args.uom or "_Test UOM",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"project": args.project, "project": args.project,
"rejected_warehouse": args.rejected_warehouse or "", "rejected_warehouse": args.rejected_warehouse or "",
"rejected_serial_no": args.rejected_serial_no or "",
"asset_location": args.location or "", "asset_location": args.location or "",
"allow_zero_valuation_rate": args.get("allow_zero_valuation_rate") or 0, "allow_zero_valuation_rate": args.get("allow_zero_valuation_rate") or 0,
}, },
@ -1797,6 +1833,31 @@ def make_purchase_invoice_against_cost_center(**args):
if args.supplier_warehouse: if args.supplier_warehouse:
pi.supplier_warehouse = "_Test Warehouse 1 - _TC" pi.supplier_warehouse = "_Test Warehouse 1 - _TC"
bundle_id = None
if args.get("batch_no") or args.get("serial_no"):
batches = {}
qty = args.qty or 5
item_code = args.item or args.item_code or "_Test Item"
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
serial_nos = args.get("serial_no") or []
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_code,
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Purchase Receipt",
"serial_nos": serial_nos,
"posting_date": args.posting_date or today(),
"posting_time": args.posting_time,
}
)
).name
pi.append( pi.append(
"items", "items",
{ {
@ -1807,12 +1868,11 @@ def make_purchase_invoice_against_cost_center(**args):
"rejected_qty": args.rejected_qty or 0, "rejected_qty": args.rejected_qty or 0,
"rate": args.rate or 50, "rate": args.rate or 50,
"conversion_factor": 1.0, "conversion_factor": 1.0,
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle_id,
"stock_uom": "_Test UOM", "stock_uom": "_Test UOM",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"project": args.project, "project": args.project,
"rejected_warehouse": args.rejected_warehouse or "", "rejected_warehouse": args.rejected_warehouse or "",
"rejected_serial_no": args.rejected_serial_no or "",
}, },
) )
if not args.do_not_save: if not args.do_not_save:

View File

@ -64,9 +64,11 @@
"warehouse", "warehouse",
"from_warehouse", "from_warehouse",
"quality_inspection", "quality_inspection",
"serial_and_batch_bundle",
"serial_no", "serial_no",
"col_br_wh", "col_br_wh",
"rejected_warehouse", "rejected_warehouse",
"rejected_serial_and_batch_bundle",
"batch_no", "batch_no",
"rejected_serial_no", "rejected_serial_no",
"manufacture_details", "manufacture_details",
@ -436,9 +438,10 @@
"depends_on": "eval:!doc.is_fixed_asset", "depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 1,
"label": "Batch No", "label": "Batch No",
"no_copy": 1, "options": "Batch",
"options": "Batch" "read_only": 1
}, },
{ {
"fieldname": "col_br_wh", "fieldname": "col_br_wh",
@ -448,8 +451,9 @@
"depends_on": "eval:!doc.is_fixed_asset", "depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Text", "fieldtype": "Text",
"hidden": 1,
"label": "Serial No", "label": "Serial No",
"no_copy": 1 "read_only": 1
}, },
{ {
"depends_on": "eval:!doc.is_fixed_asset", "depends_on": "eval:!doc.is_fixed_asset",
@ -457,7 +461,8 @@
"fieldtype": "Text", "fieldtype": "Text",
"label": "Rejected Serial No", "label": "Rejected Serial No",
"no_copy": 1, "no_copy": 1,
"print_hide": 1 "print_hide": 1,
"read_only": 1
}, },
{ {
"fieldname": "accounting", "fieldname": "accounting",
@ -875,12 +880,30 @@
"fieldname": "apply_tds", "fieldname": "apply_tds",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Apply TDS" "label": "Apply TDS"
},
{
"depends_on": "eval:parent.update_stock == 1",
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"depends_on": "eval:parent.update_stock == 1",
"fieldname": "rejected_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Rejected Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-11-29 13:01:20.438217", "modified": "2023-04-01 20:08:54.545160",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Purchase Invoice Item", "name": "Purchase Invoice Item",

View File

@ -36,13 +36,8 @@ from erpnext.controllers.accounts_controller import validate_account_head
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data from erpnext.projects.doctype.timesheet.timesheet import get_projectwise_timesheet_data
from erpnext.setup.doctype.company.company import update_company_current_month_sales from erpnext.setup.doctype.company.company import update_company_current_month_sales
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so from erpnext.stock.doctype.delivery_note.delivery_note import update_billed_amount_based_on_so
from erpnext.stock.doctype.serial_no.serial_no import ( from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
get_delivery_note_serial_no,
get_serial_nos,
update_serial_nos_after_submit,
)
form_grid_templates = {"items": "templates/form_grid/item_grid.html"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@ -129,9 +124,6 @@ class SalesInvoice(SellingController):
if not self.is_opening: if not self.is_opening:
self.is_opening = "No" self.is_opening = "No"
if self._action != "submit" and self.update_stock and not self.is_return:
set_batch_nos(self, "warehouse", True)
if self.redeem_loyalty_points: if self.redeem_loyalty_points:
lp = frappe.get_doc("Loyalty Program", self.loyalty_program) lp = frappe.get_doc("Loyalty Program", self.loyalty_program)
self.loyalty_redemption_account = ( self.loyalty_redemption_account = (
@ -262,8 +254,6 @@ class SalesInvoice(SellingController):
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
if self.update_stock == 1: if self.update_stock == 1:
self.update_stock_ledger() self.update_stock_ledger()
if self.is_return and self.update_stock:
update_serial_nos_after_submit(self, "items")
# this sequence because outstanding may get -ve # this sequence because outstanding may get -ve
self.make_gl_entries() self.make_gl_entries()
@ -276,8 +266,6 @@ class SalesInvoice(SellingController):
self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.check_credit_limit() self.check_credit_limit()
self.update_serial_no()
if not cint(self.is_pos) == 1 and not self.is_return: if not cint(self.is_pos) == 1 and not self.is_return:
self.update_against_document_in_jv() self.update_against_document_in_jv()
@ -361,7 +349,6 @@ class SalesInvoice(SellingController):
if not self.is_return: if not self.is_return:
self.update_billing_status_for_zero_amount_refdoc("Delivery Note") self.update_billing_status_for_zero_amount_refdoc("Delivery Note")
self.update_billing_status_for_zero_amount_refdoc("Sales Order") self.update_billing_status_for_zero_amount_refdoc("Sales Order")
self.update_serial_no(in_cancel=True)
# Updating stock ledger should always be called after updating prevdoc status, # Updating stock ledger should always be called after updating prevdoc status,
# because updating reserved qty in bin depends upon updated delivered qty in SO # because updating reserved qty in bin depends upon updated delivered qty in SO
@ -400,6 +387,7 @@ class SalesInvoice(SellingController):
"Repost Payment Ledger", "Repost Payment Ledger",
"Repost Payment Ledger Items", "Repost Payment Ledger Items",
"Payment Ledger Entry", "Payment Ledger Entry",
"Serial and Batch Bundle",
) )
def update_status_updater_args(self): def update_status_updater_args(self):
@ -1518,20 +1506,6 @@ class SalesInvoice(SellingController):
self.set("write_off_amount", reference_doc.get("write_off_amount")) self.set("write_off_amount", reference_doc.get("write_off_amount"))
self.due_date = None self.due_date = None
def update_serial_no(self, in_cancel=False):
"""update Sales Invoice refrence in Serial No"""
invoice = None if (in_cancel or self.is_return) else self.name
if in_cancel and self.is_return:
invoice = self.return_against
for item in self.items:
if not item.serial_no:
continue
for serial_no in get_serial_nos(item.serial_no):
if serial_no and frappe.db.get_value("Serial No", serial_no, "item_code") == item.item_code:
frappe.db.set_value("Serial No", serial_no, "sales_invoice", invoice)
def validate_serial_numbers(self): def validate_serial_numbers(self):
""" """
validate serial number agains Delivery Note and Sales Invoice validate serial number agains Delivery Note and Sales Invoice

View File

@ -30,6 +30,11 @@ from erpnext.exceptions import InvalidAccountCurrency, InvalidCurrency
from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice from erpnext.stock.doctype.delivery_note.delivery_note import make_sales_invoice
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import SerialNoWarehouseError from erpnext.stock.doctype.serial_no.serial_no import SerialNoWarehouseError
from erpnext.stock.doctype.stock_entry.test_stock_entry import ( from erpnext.stock.doctype.stock_entry.test_stock_entry import (
get_qty_after_transaction, get_qty_after_transaction,
@ -1348,55 +1353,47 @@ class TestSalesInvoice(unittest.TestCase):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
se = make_serialized_item() se = make_serialized_item()
serial_nos = get_serial_nos(se.get("items")[0].serial_no) se.load_from_db()
serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
si = frappe.copy_doc(test_records[0]) si = frappe.copy_doc(test_records[0])
si.update_stock = 1 si.update_stock = 1
si.get("items")[0].item_code = "_Test Serialized Item With Series" si.get("items")[0].item_code = "_Test Serialized Item With Series"
si.get("items")[0].qty = 1 si.get("items")[0].qty = 1
si.get("items")[0].serial_no = serial_nos[0] si.get("items")[0].warehouse = se.get("items")[0].t_warehouse
si.get("items")[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": si.get("items")[0].item_code,
"warehouse": si.get("items")[0].warehouse,
"company": si.company,
"qty": 1,
"voucher_type": "Stock Entry",
"serial_nos": [serial_nos[0]],
"posting_date": si.posting_date,
"posting_time": si.posting_time,
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
).name
si.insert() si.insert()
si.submit() si.submit()
self.assertFalse(frappe.db.get_value("Serial No", serial_nos[0], "warehouse")) self.assertFalse(frappe.db.get_value("Serial No", serial_nos[0], "warehouse"))
self.assertEqual(
frappe.db.get_value("Serial No", serial_nos[0], "delivery_document_no"), si.name
)
return si return si
def test_serialized_cancel(self): def test_serialized_cancel(self):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
si = self.test_serialized() si = self.test_serialized()
si.cancel() si.cancel()
serial_nos = get_serial_nos(si.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(si.get("items")[0].serial_and_batch_bundle)
self.assertEqual( self.assertEqual(
frappe.db.get_value("Serial No", serial_nos[0], "warehouse"), "_Test Warehouse - _TC" frappe.db.get_value("Serial No", serial_nos[0], "warehouse"), "_Test Warehouse - _TC"
) )
self.assertFalse(frappe.db.get_value("Serial No", serial_nos[0], "delivery_document_no"))
self.assertFalse(frappe.db.get_value("Serial No", serial_nos[0], "sales_invoice"))
def test_serialize_status(self):
serial_no = frappe.get_doc(
{
"doctype": "Serial No",
"item_code": "_Test Serialized Item With Series",
"serial_no": make_autoname("SR", "Serial No"),
}
)
serial_no.save()
si = frappe.copy_doc(test_records[0])
si.update_stock = 1
si.get("items")[0].item_code = "_Test Serialized Item With Series"
si.get("items")[0].qty = 1
si.get("items")[0].serial_no = serial_no.name
si.insert()
self.assertRaises(SerialNoWarehouseError, si.submit)
def test_serial_numbers_against_delivery_note(self): def test_serial_numbers_against_delivery_note(self):
""" """
@ -1404,20 +1401,22 @@ class TestSalesInvoice(unittest.TestCase):
serial numbers are same serial numbers are same
""" """
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
se = make_serialized_item() se = make_serialized_item()
serial_nos = get_serial_nos(se.get("items")[0].serial_no) se.load_from_db()
serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]
dn = create_delivery_note(item=se.get("items")[0].item_code, serial_no=serial_nos[0]) dn = create_delivery_note(item=se.get("items")[0].item_code, serial_no=[serial_nos])
dn.submit() dn.submit()
dn.load_from_db()
serial_nos = get_serial_nos_from_bundle(dn.get("items")[0].serial_and_batch_bundle)[0]
self.assertTrue(get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0])
si = make_sales_invoice(dn.name) si = make_sales_invoice(dn.name)
si.save() si.save()
self.assertEqual(si.get("items")[0].serial_no, dn.get("items")[0].serial_no)
def test_return_sales_invoice(self): def test_return_sales_invoice(self):
make_stock_entry(item_code="_Test Item", target="Stores - TCP1", qty=50, basic_rate=100) make_stock_entry(item_code="_Test Item", target="Stores - TCP1", qty=50, basic_rate=100)
@ -2573,7 +2572,7 @@ class TestSalesInvoice(unittest.TestCase):
"posting_date": si.posting_date, "posting_date": si.posting_date,
"posting_time": si.posting_time, "posting_time": si.posting_time,
"qty": -1 * flt(d.get("stock_qty")), "qty": -1 * flt(d.get("stock_qty")),
"serial_no": d.serial_no, "serial_and_batch_bundle": d.serial_and_batch_bundle,
"company": si.company, "company": si.company,
"voucher_type": "Sales Invoice", "voucher_type": "Sales Invoice",
"voucher_no": si.name, "voucher_no": si.name,
@ -2982,7 +2981,7 @@ class TestSalesInvoice(unittest.TestCase):
# Sales Invoice with Payment Schedule # Sales Invoice with Payment Schedule
si_with_payment_schedule = create_sales_invoice(do_not_submit=True) si_with_payment_schedule = create_sales_invoice(do_not_submit=True)
si_with_payment_schedule.extend( si_with_payment_schedule.set(
"payment_schedule", "payment_schedule",
[ [
{ {
@ -3174,7 +3173,7 @@ class TestSalesInvoice(unittest.TestCase):
item_code="_Test Serialized Item With Series", update_stock=True, is_return=True, qty=-1 item_code="_Test Serialized Item With Series", update_stock=True, is_return=True, qty=-1
) )
si.reload() si.reload()
self.assertTrue(si.items[0].serial_no) self.assertTrue(get_serial_nos_from_bundle(si.items[0].serial_and_batch_bundle))
def test_sales_invoice_with_disabled_account(self): def test_sales_invoice_with_disabled_account(self):
try: try:
@ -3283,11 +3282,11 @@ class TestSalesInvoice(unittest.TestCase):
pr = make_purchase_receipt(qty=1, item_code=item.name) pr = make_purchase_receipt(qty=1, item_code=item.name)
batch_no = pr.items[0].batch_no batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
si = create_sales_invoice(qty=1, item_code=item.name, update_stock=1, batch_no=batch_no) si = create_sales_invoice(qty=1, item_code=item.name, update_stock=1, batch_no=batch_no)
si.load_from_db() si.load_from_db()
batch_no = si.items[0].batch_no batch_no = get_batch_from_bundle(si.items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1)) frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1))
@ -3386,6 +3385,33 @@ def create_sales_invoice(**args):
si.naming_series = args.naming_series or "T-SINV-" si.naming_series = args.naming_series or "T-SINV-"
si.cost_center = args.parent_cost_center si.cost_center = args.parent_cost_center
bundle_id = None
if si.update_stock and (args.get("batch_no") or args.get("serial_no")):
batches = {}
qty = args.qty or 1
item_code = args.item or args.item_code or "_Test Item"
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
serial_nos = args.get("serial_no") or []
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_code,
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Sales Invoice",
"serial_nos": serial_nos,
"type_of_transaction": "Outward" if not args.is_return else "Inward",
"posting_date": si.posting_date or today(),
"posting_time": si.posting_time,
"do_not_submit": True,
}
)
).name
si.append( si.append(
"items", "items",
{ {
@ -3405,10 +3431,9 @@ def create_sales_invoice(**args):
"discount_amount": args.discount_amount or 0, "discount_amount": args.discount_amount or 0,
"asset": args.asset or None, "asset": args.asset or None,
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_no": args.serial_no,
"conversion_factor": args.get("conversion_factor", 1), "conversion_factor": args.get("conversion_factor", 1),
"incoming_rate": args.incoming_rate or 0, "incoming_rate": args.incoming_rate or 0,
"batch_no": args.batch_no or None, "serial_and_batch_bundle": bundle_id,
}, },
) )
@ -3418,6 +3443,8 @@ def create_sales_invoice(**args):
si.submit() si.submit()
else: else:
si.payment_schedule = [] si.payment_schedule = []
si.load_from_db()
else: else:
si.payment_schedule = [] si.payment_schedule = []
@ -3452,7 +3479,6 @@ def create_sales_invoice_against_cost_center(**args):
"income_account": "Sales - _TC", "income_account": "Sales - _TC",
"expense_account": "Cost of Goods Sold - _TC", "expense_account": "Cost of Goods Sold - _TC",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_no": args.serial_no,
}, },
) )

View File

@ -81,6 +81,7 @@
"warehouse", "warehouse",
"target_warehouse", "target_warehouse",
"quality_inspection", "quality_inspection",
"serial_and_batch_bundle",
"batch_no", "batch_no",
"incoming_rate", "incoming_rate",
"col_break5", "col_break5",
@ -600,10 +601,10 @@
{ {
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1, "hidden": 1,
"label": "Batch No", "label": "Batch No",
"options": "Batch", "options": "Batch",
"print_hide": 1 "read_only": 1
}, },
{ {
"fieldname": "col_break5", "fieldname": "col_break5",
@ -620,10 +621,11 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"in_list_view": 1, "hidden": 1,
"label": "Serial No", "label": "Serial No",
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
"oldfieldtype": "Small Text" "oldfieldtype": "Small Text",
"read_only": 1
}, },
{ {
"fieldname": "item_group", "fieldname": "item_group",
@ -885,12 +887,20 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Has Item Scanned", "label": "Has Item Scanned",
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-12-28 16:17:33.484531", "modified": "2023-03-12 13:42:24.303113",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Accounts", "module": "Accounts",
"name": "Sales Invoice Item", "name": "Sales Invoice Item",

View File

@ -703,6 +703,9 @@ class GrossProfitGenerator(object):
} }
) )
if row.serial_and_batch_bundle:
args.update({"serial_and_batch_bundle": row.serial_and_batch_bundle})
average_buying_rate = get_incoming_rate(args) average_buying_rate = get_incoming_rate(args)
self.average_buying_rate[item_code] = flt(average_buying_rate) self.average_buying_rate[item_code] = flt(average_buying_rate)
@ -805,7 +808,7 @@ class GrossProfitGenerator(object):
`tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty, `tabSales Invoice Item`.delivery_note, `tabSales Invoice Item`.stock_qty as qty,
`tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount, `tabSales Invoice Item`.base_net_rate, `tabSales Invoice Item`.base_net_amount,
`tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return, `tabSales Invoice Item`.name as "item_row", `tabSales Invoice`.is_return,
`tabSales Invoice Item`.cost_center `tabSales Invoice Item`.cost_center, `tabSales Invoice Item`.serial_and_batch_bundle
{sales_person_cols} {sales_person_cols}
{payment_term_cols} {payment_term_cols}
from from

View File

@ -6,6 +6,7 @@ frappe.provide("erpnext.assets");
erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.stock.StockController { erpnext.assets.AssetCapitalization = class AssetCapitalization extends erpnext.stock.StockController {
setup() { setup() {
this.frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle'];
this.setup_posting_date_time_check(); this.setup_posting_date_time_check();
} }

View File

@ -334,7 +334,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"is_submittable": 1, "is_submittable": 1,
"links": [], "links": [],
"modified": "2022-09-12 15:09:40.771332", "modified": "2022-10-12 15:09:40.771332",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset Capitalization", "name": "Asset Capitalization",

View File

@ -65,6 +65,10 @@ class AssetCapitalization(StockController):
self.calculate_totals() self.calculate_totals()
self.set_title() self.set_title()
def on_update(self):
if self.stock_items:
self.set_serial_and_batch_bundle(table_name="stock_items")
def before_submit(self): def before_submit(self):
self.validate_source_mandatory() self.validate_source_mandatory()
@ -74,7 +78,12 @@ class AssetCapitalization(StockController):
self.update_target_asset() self.update_target_asset()
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Serial and Batch Bundle",
)
self.update_stock_ledger() self.update_stock_ledger()
self.make_gl_entries() self.make_gl_entries()
self.update_target_asset() self.update_target_asset()
@ -316,9 +325,7 @@ class AssetCapitalization(StockController):
for d in self.stock_items: for d in self.stock_items:
sle = self.get_sl_entries( sle = self.get_sl_entries(
d, d,
{ {"actual_qty": -flt(d.stock_qty), "serial_and_batch_bundle": d.serial_and_batch_bundle},
"actual_qty": -flt(d.stock_qty),
},
) )
sl_entries.append(sle) sl_entries.append(sle)
@ -328,8 +335,6 @@ class AssetCapitalization(StockController):
{ {
"item_code": self.target_item_code, "item_code": self.target_item_code,
"warehouse": self.target_warehouse, "warehouse": self.target_warehouse,
"batch_no": self.target_batch_no,
"serial_no": self.target_serial_no,
"actual_qty": flt(self.target_qty), "actual_qty": flt(self.target_qty),
"incoming_rate": flt(self.target_incoming_rate), "incoming_rate": flt(self.target_incoming_rate),
}, },

View File

@ -16,6 +16,11 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
get_asset_depr_schedule_doc, get_asset_depr_schedule_doc,
) )
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
class TestAssetCapitalization(unittest.TestCase): class TestAssetCapitalization(unittest.TestCase):
@ -371,14 +376,32 @@ def create_asset_capitalization(**args):
asset_capitalization.set_posting_time = 1 asset_capitalization.set_posting_time = 1
if flt(args.stock_rate): if flt(args.stock_rate):
bundle = None
if args.stock_batch_no or args.stock_serial_no:
bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": args.stock_item,
"warehouse": source_warehouse,
"company": frappe.get_cached_value("Warehouse", source_warehouse, "company"),
"qty": (flt(args.stock_qty) or 1) * -1,
"voucher_type": "Asset Capitalization",
"type_of_transaction": "Outward",
"serial_nos": args.stock_serial_no,
"posting_date": asset_capitalization.posting_date,
"posting_time": asset_capitalization.posting_time,
"do_not_submit": True,
}
)
).name
asset_capitalization.append( asset_capitalization.append(
"stock_items", "stock_items",
{ {
"item_code": args.stock_item or "Capitalization Source Stock Item", "item_code": args.stock_item or "Capitalization Source Stock Item",
"warehouse": source_warehouse, "warehouse": source_warehouse,
"stock_qty": flt(args.stock_qty) or 1, "stock_qty": flt(args.stock_qty) or 1,
"batch_no": args.stock_batch_no, "serial_and_batch_bundle": bundle,
"serial_no": args.stock_serial_no,
}, },
) )

View File

@ -17,8 +17,9 @@
"valuation_rate", "valuation_rate",
"amount", "amount",
"batch_and_serial_no_section", "batch_and_serial_no_section",
"batch_no", "serial_and_batch_bundle",
"column_break_13", "column_break_13",
"batch_no",
"serial_no", "serial_no",
"accounting_dimensions_section", "accounting_dimensions_section",
"cost_center", "cost_center",
@ -41,7 +42,10 @@
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Batch No", "label": "Batch No",
"options": "Batch" "no_copy": 1,
"options": "Batch",
"print_hide": 1,
"read_only": 1
}, },
{ {
"fieldname": "section_break_6", "fieldname": "section_break_6",
@ -100,7 +104,10 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Serial No" "hidden": 1,
"label": "Serial No",
"print_hide": 1,
"read_only": 1
}, },
{ {
"fieldname": "item_code", "fieldname": "item_code",
@ -139,12 +146,20 @@
{ {
"fieldname": "dimension_col_break", "fieldname": "dimension_col_break",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-09-08 15:56:20.230548", "modified": "2023-04-06 01:10:17.947952",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset Capitalization Stock Item", "name": "Asset Capitalization Stock Item",
@ -152,5 +167,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -147,6 +147,8 @@ class AssetRepair(AccountsController):
) )
for stock_item in self.get("stock_items"): for stock_item in self.get("stock_items"):
self.validate_serial_no(stock_item)
stock_entry.append( stock_entry.append(
"items", "items",
{ {
@ -154,7 +156,7 @@ class AssetRepair(AccountsController):
"item_code": stock_item.item_code, "item_code": stock_item.item_code,
"qty": stock_item.consumed_quantity, "qty": stock_item.consumed_quantity,
"basic_rate": stock_item.valuation_rate, "basic_rate": stock_item.valuation_rate,
"serial_no": stock_item.serial_no, "serial_no": stock_item.serial_and_batch_bundle,
"cost_center": self.cost_center, "cost_center": self.cost_center,
"project": self.project, "project": self.project,
}, },
@ -165,6 +167,23 @@ class AssetRepair(AccountsController):
self.db_set("stock_entry", stock_entry.name) self.db_set("stock_entry", stock_entry.name)
def validate_serial_no(self, stock_item):
if not stock_item.serial_and_batch_bundle and frappe.get_cached_value(
"Item", stock_item.item_code, "has_serial_no"
):
msg = f"Serial No Bundle is mandatory for Item {stock_item.item_code}"
frappe.throw(msg, title=_("Missing Serial No Bundle"))
if stock_item.serial_and_batch_bundle:
values_to_update = {
"type_of_transaction": "Outward",
"voucher_type": "Stock Entry",
}
frappe.db.set_value(
"Serial and Batch Bundle", stock_item.serial_and_batch_bundle, values_to_update
)
def increase_stock_quantity(self): def increase_stock_quantity(self):
if self.stock_entry: if self.stock_entry:
stock_entry = frappe.get_doc("Stock Entry", self.stock_entry) stock_entry = frappe.get_doc("Stock Entry", self.stock_entry)

View File

@ -4,7 +4,7 @@
import unittest import unittest
import frappe import frappe
from frappe.utils import flt, nowdate from frappe.utils import flt, nowdate, nowtime, today
from erpnext.assets.doctype.asset.asset import ( from erpnext.assets.doctype.asset.asset import (
get_asset_account, get_asset_account,
@ -19,6 +19,10 @@ from erpnext.assets.doctype.asset_depreciation_schedule.asset_depreciation_sched
get_asset_depr_schedule_doc, get_asset_depr_schedule_doc,
) )
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
class TestAssetRepair(unittest.TestCase): class TestAssetRepair(unittest.TestCase):
@ -84,19 +88,19 @@ class TestAssetRepair(unittest.TestCase):
self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity) self.assertEqual(stock_entry.items[0].qty, asset_repair.stock_items[0].consumed_quantity)
def test_serialized_item_consumption(self): def test_serialized_item_consumption(self):
from erpnext.stock.doctype.serial_no.serial_no import SerialNoRequiredError
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
stock_entry = make_serialized_item() stock_entry = make_serialized_item()
serial_nos = stock_entry.get("items")[0].serial_no bundle_id = stock_entry.get("items")[0].serial_and_batch_bundle
serial_no = serial_nos.split("\n")[0] serial_nos = get_serial_nos_from_bundle(bundle_id)
serial_no = serial_nos[0]
# should not raise any error # should not raise any error
create_asset_repair( create_asset_repair(
stock_consumption=1, stock_consumption=1,
item_code=stock_entry.get("items")[0].item_code, item_code=stock_entry.get("items")[0].item_code,
warehouse="_Test Warehouse - _TC", warehouse="_Test Warehouse - _TC",
serial_no=serial_no, serial_no=[serial_no],
submit=1, submit=1,
) )
@ -108,7 +112,7 @@ class TestAssetRepair(unittest.TestCase):
) )
asset_repair.repair_status = "Completed" asset_repair.repair_status = "Completed"
self.assertRaises(SerialNoRequiredError, asset_repair.submit) self.assertRaises(frappe.ValidationError, asset_repair.submit)
def test_increase_in_asset_value_due_to_stock_consumption(self): def test_increase_in_asset_value_due_to_stock_consumption(self):
asset = create_asset(calculate_depreciation=1, submit=1) asset = create_asset(calculate_depreciation=1, submit=1)
@ -290,13 +294,32 @@ def create_asset_repair(**args):
asset_repair.warehouse = args.warehouse or create_warehouse( asset_repair.warehouse = args.warehouse or create_warehouse(
"Test Warehouse", company=asset.company "Test Warehouse", company=asset.company
) )
bundle = None
if args.serial_no:
bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": args.item_code,
"warehouse": asset_repair.warehouse,
"company": frappe.get_cached_value("Warehouse", asset_repair.warehouse, "company"),
"qty": (flt(args.stock_qty) or 1) * -1,
"voucher_type": "Asset Repair",
"type_of_transaction": "Asset Repair",
"serial_nos": args.serial_no,
"posting_date": today(),
"posting_time": nowtime(),
}
)
).name
asset_repair.append( asset_repair.append(
"stock_items", "stock_items",
{ {
"item_code": args.item_code or "_Test Stock Item", "item_code": args.item_code or "_Test Stock Item",
"valuation_rate": args.rate if args.get("rate") is not None else 100, "valuation_rate": args.rate if args.get("rate") is not None else 100,
"consumed_quantity": args.qty or 1, "consumed_quantity": args.qty or 1,
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle,
}, },
) )

View File

@ -9,7 +9,8 @@
"valuation_rate", "valuation_rate",
"consumed_quantity", "consumed_quantity",
"total_value", "total_value",
"serial_no" "serial_no",
"serial_and_batch_bundle"
], ],
"fields": [ "fields": [
{ {
@ -34,7 +35,9 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Serial No" "hidden": 1,
"label": "Serial No",
"print_hide": 1
}, },
{ {
"fieldname": "item_code", "fieldname": "item_code",
@ -42,12 +45,18 @@
"in_list_view": 1, "in_list_view": 1,
"label": "Item", "label": "Item",
"options": "Item" "options": "Item"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"options": "Serial and Batch Bundle"
} }
], ],
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-02-08 17:37:20.028290", "modified": "2023-04-06 02:24:20.375870",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Assets", "module": "Assets",
"name": "Asset Repair Consumed Item", "name": "Asset Repair Consumed Item",
@ -55,5 +64,6 @@
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC", "sort_order": "DESC",
"states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -5,7 +5,7 @@
import frappe import frappe
from frappe import ValidationError, _, msgprint from frappe import ValidationError, _, msgprint
from frappe.contacts.doctype.address.address import get_address_display from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import cint, cstr, flt, getdate from frappe.utils import cint, flt, getdate
from frappe.utils.data import nowtime from frappe.utils.data import nowtime
from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget from erpnext.accounts.doctype.budget.budget import validate_expense_against_budget
@ -38,6 +38,7 @@ class BuyingController(SubcontractingController):
self.set_supplier_address() self.set_supplier_address()
self.validate_asset_return() self.validate_asset_return()
self.validate_auto_repeat_subscription_dates() self.validate_auto_repeat_subscription_dates()
self.create_package_for_transfer()
if self.doctype == "Purchase Invoice": if self.doctype == "Purchase Invoice":
self.validate_purchase_receipt_if_update_stock() self.validate_purchase_receipt_if_update_stock()
@ -58,6 +59,7 @@ class BuyingController(SubcontractingController):
if self.doctype in ("Purchase Receipt", "Purchase Invoice"): if self.doctype in ("Purchase Receipt", "Purchase Invoice"):
self.update_valuation_rate() self.update_valuation_rate()
self.set_serial_and_batch_bundle()
def onload(self): def onload(self):
super(BuyingController, self).onload() super(BuyingController, self).onload()
@ -68,6 +70,36 @@ class BuyingController(SubcontractingController):
), ),
) )
def create_package_for_transfer(self) -> None:
"""Create serial and batch package for Sourece Warehouse in case of inter transfer."""
if self.is_internal_transfer() and (
self.doctype == "Purchase Receipt" or (self.doctype == "Purchase Invoice" and self.update_stock)
):
field = "delivery_note_item" if self.doctype == "Purchase Receipt" else "sales_invoice_item"
doctype = "Delivery Note Item" if self.doctype == "Purchase Receipt" else "Sales Invoice Item"
ids = [d.get(field) for d in self.get("items") if d.get(field)]
bundle_ids = {}
if ids:
for bundle in frappe.get_all(
doctype, filters={"name": ("in", ids)}, fields=["serial_and_batch_bundle", "name"]
):
bundle_ids[bundle.name] = bundle.serial_and_batch_bundle
if not bundle_ids:
return
for item in self.get("items"):
if item.get(field) and not item.serial_and_batch_bundle and bundle_ids.get(item.get(field)):
item.serial_and_batch_bundle = self.make_package_for_transfer(
bundle_ids.get(item.get(field)),
item.from_warehouse,
type_of_transaction="Outward",
do_not_submit=True,
)
def set_missing_values(self, for_validate=False): def set_missing_values(self, for_validate=False):
super(BuyingController, self).set_missing_values(for_validate) super(BuyingController, self).set_missing_values(for_validate)
@ -305,8 +337,7 @@ class BuyingController(SubcontractingController):
"posting_date": self.get("posting_date") or self.get("transation_date"), "posting_date": self.get("posting_date") or self.get("transation_date"),
"posting_time": posting_time, "posting_time": posting_time,
"qty": -1 * flt(d.get("stock_qty")), "qty": -1 * flt(d.get("stock_qty")),
"serial_no": d.get("serial_no"), "serial_and_batch_bundle": d.get("serial_and_batch_bundle"),
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
@ -463,7 +494,15 @@ class BuyingController(SubcontractingController):
sl_entries.append(from_warehouse_sle) sl_entries.append(from_warehouse_sle)
sle = self.get_sl_entries( sle = self.get_sl_entries(
d, {"actual_qty": flt(pr_qty), "serial_no": cstr(d.serial_no).strip()} d,
{
"actual_qty": flt(pr_qty),
"serial_and_batch_bundle": (
d.serial_and_batch_bundle
if not self.is_internal_transfer()
else self.get_package_for_target_warehouse(d)
),
},
) )
if self.is_return: if self.is_return:
@ -471,7 +510,13 @@ class BuyingController(SubcontractingController):
self.doctype, self.name, d.item_code, self.return_against, item_row=d self.doctype, self.name, d.item_code, self.return_against, item_row=d
) )
sle.update({"outgoing_rate": outgoing_rate, "recalculate_rate": 1}) sle.update(
{
"outgoing_rate": outgoing_rate,
"recalculate_rate": 1,
"serial_and_batch_bundle": d.serial_and_batch_bundle,
}
)
if d.from_warehouse: if d.from_warehouse:
sle.dependant_sle_voucher_detail_no = d.name sle.dependant_sle_voucher_detail_no = d.name
else: else:
@ -504,20 +549,30 @@ class BuyingController(SubcontractingController):
{ {
"warehouse": d.rejected_warehouse, "warehouse": d.rejected_warehouse,
"actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor), "actual_qty": flt(d.rejected_qty) * flt(d.conversion_factor),
"serial_no": cstr(d.rejected_serial_no).strip(),
"incoming_rate": 0.0, "incoming_rate": 0.0,
"serial_and_batch_bundle": d.rejected_serial_and_batch_bundle,
}, },
) )
) )
if self.get("is_old_subcontracting_flow"): if self.get("is_old_subcontracting_flow"):
self.make_sl_entries_for_supplier_warehouse(sl_entries) self.make_sl_entries_for_supplier_warehouse(sl_entries)
self.make_sl_entries( self.make_sl_entries(
sl_entries, sl_entries,
allow_negative_stock=allow_negative_stock, allow_negative_stock=allow_negative_stock,
via_landed_cost_voucher=via_landed_cost_voucher, via_landed_cost_voucher=via_landed_cost_voucher,
) )
def get_package_for_target_warehouse(self, item) -> str:
if not item.serial_and_batch_bundle:
return ""
return self.make_package_for_transfer(
item.serial_and_batch_bundle,
item.warehouse,
)
def update_ordered_and_reserved_qty(self): def update_ordered_and_reserved_qty(self):
po_map = {} po_map = {}
for d in self.get("items"): for d in self.get("items"):

View File

@ -323,8 +323,6 @@ def get_returned_qty_map_for_row(return_against, party, row_name, doctype):
def make_return_doc(doctype: str, source_name: str, target_doc=None): def make_return_doc(doctype: str, source_name: str, target_doc=None):
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
company = frappe.db.get_value("Delivery Note", source_name, "company") company = frappe.db.get_value("Delivery Note", source_name, "company")
default_warehouse_for_sales_return = frappe.get_cached_value( default_warehouse_for_sales_return = frappe.get_cached_value(
"Company", company, "default_warehouse_for_sales_return" "Company", company, "default_warehouse_for_sales_return"
@ -392,23 +390,69 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
doc.run_method("calculate_taxes_and_totals") doc.run_method("calculate_taxes_and_totals")
def update_item(source_doc, target_doc, source_parent): def update_item(source_doc, target_doc, source_parent):
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
target_doc.qty = -1 * source_doc.qty target_doc.qty = -1 * source_doc.qty
item_details = frappe.get_cached_value(
"Item", source_doc.item_code, ["has_batch_no", "has_serial_no"], as_dict=1
)
if source_doc.serial_no: returned_serial_nos = []
returned_serial_nos = get_returned_serial_nos(source_doc, source_parent) if source_doc.get("serial_and_batch_bundle"):
serial_nos = list(set(get_serial_nos(source_doc.serial_no)) - set(returned_serial_nos)) if item_details.has_serial_no:
if serial_nos: returned_serial_nos = get_returned_serial_nos(source_doc, source_parent)
target_doc.serial_no = "\n".join(serial_nos)
if source_doc.get("rejected_serial_no"): type_of_transaction = "Inward"
returned_serial_nos = get_returned_serial_nos( if (
source_doc, source_parent, serial_no_field="rejected_serial_no" frappe.db.get_value(
"Serial and Batch Bundle", source_doc.serial_and_batch_bundle, "type_of_transaction"
)
== "Inward"
):
type_of_transaction = "Outward"
cls_obj = SerialBatchCreation(
{
"type_of_transaction": type_of_transaction,
"serial_and_batch_bundle": source_doc.serial_and_batch_bundle,
"returned_against": source_doc.name,
"item_code": source_doc.item_code,
"returned_serial_nos": returned_serial_nos,
}
) )
rejected_serial_nos = list(
set(get_serial_nos(source_doc.rejected_serial_no)) - set(returned_serial_nos) cls_obj.duplicate_package()
if cls_obj.serial_and_batch_bundle:
target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle
if source_doc.get("rejected_serial_and_batch_bundle"):
if item_details.has_serial_no:
returned_serial_nos = get_returned_serial_nos(
source_doc, source_parent, serial_no_field="rejected_serial_and_batch_bundle"
)
type_of_transaction = "Inward"
if (
frappe.db.get_value(
"Serial and Batch Bundle", source_doc.rejected_serial_and_batch_bundle, "type_of_transaction"
)
== "Inward"
):
type_of_transaction = "Outward"
cls_obj = SerialBatchCreation(
{
"type_of_transaction": type_of_transaction,
"serial_and_batch_bundle": source_doc.rejected_serial_and_batch_bundle,
"returned_against": source_doc.name,
"item_code": source_doc.item_code,
"returned_serial_nos": returned_serial_nos,
}
) )
if rejected_serial_nos:
target_doc.rejected_serial_no = "\n".join(rejected_serial_nos) cls_obj.duplicate_package()
if cls_obj.serial_and_batch_bundle:
target_doc.serial_and_batch_bundle = cls_obj.serial_and_batch_bundle
if doctype in ["Purchase Receipt", "Subcontracting Receipt"]: if doctype in ["Purchase Receipt", "Subcontracting Receipt"]:
returned_qty_map = get_returned_qty_map_for_row( returned_qty_map = get_returned_qty_map_for_row(
@ -573,8 +617,7 @@ def get_rate_for_return(
"posting_date": sle.get("posting_date"), "posting_date": sle.get("posting_date"),
"posting_time": sle.get("posting_time"), "posting_time": sle.get("posting_time"),
"qty": sle.actual_qty, "qty": sle.actual_qty,
"serial_no": sle.get("serial_no"), "serial_and_batch_bundle": sle.get("serial_and_batch_bundle"),
"batch_no": sle.get("batch_no"),
"company": sle.company, "company": sle.company,
"voucher_type": sle.voucher_type, "voucher_type": sle.voucher_type,
"voucher_no": sle.voucher_no, "voucher_no": sle.voucher_no,
@ -620,8 +663,20 @@ def get_filters(
return filters return filters
def get_returned_serial_nos(child_doc, parent_doc, serial_no_field="serial_no"): def get_returned_serial_nos(
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos child_doc, parent_doc, serial_no_field=None, ignore_voucher_detail_no=None
):
from erpnext.stock.doctype.serial_no.serial_no import (
get_serial_nos as get_serial_nos_from_serial_no,
)
from erpnext.stock.serial_batch_bundle import get_serial_nos
if not serial_no_field:
serial_no_field = "serial_and_batch_bundle"
old_field = "serial_no"
if serial_no_field == "rejected_serial_and_batch_bundle":
old_field = "rejected_serial_no"
return_ref_field = frappe.scrub(child_doc.doctype) return_ref_field = frappe.scrub(child_doc.doctype)
if child_doc.doctype == "Delivery Note Item": if child_doc.doctype == "Delivery Note Item":
@ -629,7 +684,10 @@ def get_returned_serial_nos(child_doc, parent_doc, serial_no_field="serial_no"):
serial_nos = [] serial_nos = []
fields = [f"`{'tab' + child_doc.doctype}`.`{serial_no_field}`"] fields = [
f"`{'tab' + child_doc.doctype}`.`{serial_no_field}`",
f"`{'tab' + child_doc.doctype}`.`{old_field}`",
]
filters = [ filters = [
[parent_doc.doctype, "return_against", "=", parent_doc.name], [parent_doc.doctype, "return_against", "=", parent_doc.name],
@ -638,7 +696,16 @@ def get_returned_serial_nos(child_doc, parent_doc, serial_no_field="serial_no"):
[parent_doc.doctype, "docstatus", "=", 1], [parent_doc.doctype, "docstatus", "=", 1],
] ]
# Required for POS Invoice
if ignore_voucher_detail_no:
filters.append([child_doc.doctype, "name", "!=", ignore_voucher_detail_no])
ids = []
for row in frappe.get_all(parent_doc.doctype, fields=fields, filters=filters): for row in frappe.get_all(parent_doc.doctype, fields=fields, filters=filters):
serial_nos.extend(get_serial_nos(row.get(serial_no_field))) ids.append(row.get("serial_and_batch_bundle"))
if row.get(old_field):
serial_nos.extend(get_serial_nos_from_serial_no(row.get(old_field)))
serial_nos.extend(get_serial_nos(ids))
return serial_nos return serial_nos

View File

@ -5,7 +5,7 @@
import frappe import frappe
from frappe import _, bold, throw from frappe import _, bold, throw
from frappe.contacts.doctype.address.address import get_address_display from frappe.contacts.doctype.address.address import get_address_display
from frappe.utils import cint, cstr, flt, get_link_to_form, nowtime from frappe.utils import cint, flt, get_link_to_form, nowtime
from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.sales_and_purchase_return import get_rate_for_return from erpnext.controllers.sales_and_purchase_return import get_rate_for_return
@ -38,6 +38,9 @@ class SellingController(StockController):
self.validate_for_duplicate_items() self.validate_for_duplicate_items()
self.validate_target_warehouse() self.validate_target_warehouse()
self.validate_auto_repeat_subscription_dates() self.validate_auto_repeat_subscription_dates()
for table_field in ["items", "packed_items"]:
if self.get(table_field):
self.set_serial_and_batch_bundle(table_field)
def set_missing_values(self, for_validate=False): def set_missing_values(self, for_validate=False):
@ -299,8 +302,8 @@ class SellingController(StockController):
"item_code": p.item_code, "item_code": p.item_code,
"qty": flt(p.qty), "qty": flt(p.qty),
"uom": p.uom, "uom": p.uom,
"batch_no": cstr(p.batch_no).strip(), "serial_and_batch_bundle": p.serial_and_batch_bundle
"serial_no": cstr(p.serial_no).strip(), or get_serial_and_batch_bundle(p, self),
"name": d.name, "name": d.name,
"target_warehouse": p.target_warehouse, "target_warehouse": p.target_warehouse,
"company": self.company, "company": self.company,
@ -323,8 +326,7 @@ class SellingController(StockController):
"uom": d.uom, "uom": d.uom,
"stock_uom": d.stock_uom, "stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor, "conversion_factor": d.conversion_factor,
"batch_no": cstr(d.get("batch_no")).strip(), "serial_and_batch_bundle": d.serial_and_batch_bundle,
"serial_no": cstr(d.get("serial_no")).strip(),
"name": d.name, "name": d.name,
"target_warehouse": d.target_warehouse, "target_warehouse": d.target_warehouse,
"company": self.company, "company": self.company,
@ -337,6 +339,7 @@ class SellingController(StockController):
} }
) )
) )
return il return il
def has_product_bundle(self, item_code): def has_product_bundle(self, item_code):
@ -427,8 +430,7 @@ class SellingController(StockController):
"posting_date": self.get("posting_date") or self.get("transaction_date"), "posting_date": self.get("posting_date") or self.get("transaction_date"),
"posting_time": self.get("posting_time") or nowtime(), "posting_time": self.get("posting_time") or nowtime(),
"qty": qty if cint(self.get("is_return")) else (-1 * qty), "qty": qty if cint(self.get("is_return")) else (-1 * qty),
"serial_no": d.get("serial_no"), "serial_and_batch_bundle": d.serial_and_batch_bundle,
"batch_no": d.get("batch_no"),
"company": self.company, "company": self.company,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
@ -511,6 +513,7 @@ class SellingController(StockController):
"actual_qty": -1 * flt(item_row.qty), "actual_qty": -1 * flt(item_row.qty),
"incoming_rate": item_row.incoming_rate, "incoming_rate": item_row.incoming_rate,
"recalculate_rate": cint(self.is_return), "recalculate_rate": cint(self.is_return),
"serial_and_batch_bundle": item_row.serial_and_batch_bundle,
}, },
) )
if item_row.target_warehouse and not cint(self.is_return): if item_row.target_warehouse and not cint(self.is_return):
@ -531,6 +534,11 @@ class SellingController(StockController):
if item_row.warehouse: if item_row.warehouse:
sle.dependant_sle_voucher_detail_no = item_row.name sle.dependant_sle_voucher_detail_no = item_row.name
if item_row.serial_and_batch_bundle:
sle["serial_and_batch_bundle"] = self.make_package_for_transfer(
item_row.serial_and_batch_bundle, item_row.target_warehouse
)
return sle return sle
def set_po_nos(self, for_validate=False): def set_po_nos(self, for_validate=False):
@ -669,3 +677,40 @@ def set_default_income_account_for_item(obj):
if d.item_code: if d.item_code:
if getattr(d, "income_account", None): if getattr(d, "income_account", None):
set_item_default(d.item_code, obj.company, "income_account", d.income_account) set_item_default(d.item_code, obj.company, "income_account", d.income_account)
def get_serial_and_batch_bundle(child, parent):
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
if not frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
):
return
item_details = frappe.db.get_value(
"Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
if not item_details.has_serial_no and not item_details.has_batch_no:
return
sn_doc = SerialBatchCreation(
{
"item_code": child.item_code,
"warehouse": child.warehouse,
"voucher_type": parent.doctype,
"voucher_no": parent.name,
"voucher_detail_no": child.name,
"posting_date": parent.posting_date,
"posting_time": parent.posting_time,
"qty": child.qty,
"type_of_transaction": "Outward" if child.qty > 0 else "Inward",
"company": parent.company,
"do_not_submit": "True",
}
)
doc = sn_doc.make_serial_and_batch_bundle()
child.db_set("serial_and_batch_bundle", doc.name)
return doc.name

View File

@ -7,7 +7,7 @@ from typing import List, Tuple
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cint, cstr, flt, get_link_to_form, getdate from frappe.utils import cint, flt, get_link_to_form, getdate
import erpnext import erpnext
from erpnext.accounts.general_ledger import ( from erpnext.accounts.general_ledger import (
@ -325,29 +325,6 @@ class StockController(AccountsController):
stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle) stock_ledger.setdefault(sle.voucher_detail_no, []).append(sle)
return stock_ledger return stock_ledger
def make_batches(self, warehouse_field):
"""Create batches if required. Called before submit"""
for d in self.items:
if d.get(warehouse_field) and not d.batch_no:
has_batch_no, create_new_batch = frappe.get_cached_value(
"Item", d.item_code, ["has_batch_no", "create_new_batch"]
)
if has_batch_no and create_new_batch:
d.batch_no = (
frappe.get_doc(
dict(
doctype="Batch",
item=d.item_code,
supplier=getattr(self, "supplier", None),
reference_doctype=self.doctype,
reference_name=self.name,
)
)
.insert()
.name
)
def check_expense_account(self, item): def check_expense_account(self, item):
if not item.get("expense_account"): if not item.get("expense_account"):
msg = _("Please set an Expense Account in the Items table") msg = _("Please set an Expense Account in the Items table")
@ -387,27 +364,73 @@ class StockController(AccountsController):
) )
def delete_auto_created_batches(self): def delete_auto_created_batches(self):
for d in self.items: for row in self.items:
if not d.batch_no: if row.serial_and_batch_bundle:
continue frappe.db.set_value(
"Serial and Batch Bundle", row.serial_and_batch_bundle, {"is_cancelled": 1}
)
frappe.db.set_value( row.db_set("serial_and_batch_bundle", None)
"Serial No", {"batch_no": d.batch_no, "status": "Inactive"}, "batch_no", None
)
d.batch_no = None def set_serial_and_batch_bundle(self, table_name=None, ignore_validate=False):
d.db_set("batch_no", None) if not table_name:
table_name = "items"
for data in frappe.get_all( QTY_FIELD = {
"Batch", {"reference_name": self.name, "reference_doctype": self.doctype} "serial_and_batch_bundle": "qty",
): "current_serial_and_batch_bundle": "current_qty",
frappe.delete_doc("Batch", data.name) "rejected_serial_and_batch_bundle": "rejected_qty",
}
for row in self.get(table_name):
for field in [
"serial_and_batch_bundle",
"current_serial_and_batch_bundle",
"rejected_serial_and_batch_bundle",
]:
if row.get(field):
frappe.get_doc("Serial and Batch Bundle", row.get(field)).set_serial_and_batch_values(
self, row, qty_field=QTY_FIELD[field]
)
def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None
):
bundle_doc = frappe.get_doc("Serial and Batch Bundle", serial_and_batch_bundle)
if not type_of_transaction:
type_of_transaction = "Inward"
bundle_doc = frappe.copy_doc(bundle_doc)
bundle_doc.warehouse = warehouse
bundle_doc.type_of_transaction = type_of_transaction
bundle_doc.voucher_type = self.doctype
bundle_doc.voucher_no = self.name
bundle_doc.is_cancelled = 0
for row in bundle_doc.entries:
row.is_outward = 0
row.qty = abs(row.qty)
row.stock_value_difference = abs(row.stock_value_difference)
if type_of_transaction == "Outward":
row.qty *= -1
row.stock_value_difference *= row.stock_value_difference
row.is_outward = 1
row.warehouse = warehouse
bundle_doc.calculate_qty_and_amount()
bundle_doc.flags.ignore_permissions = True
bundle_doc.save(ignore_permissions=True)
return bundle_doc.name
def get_sl_entries(self, d, args): def get_sl_entries(self, d, args):
sl_dict = frappe._dict( sl_dict = frappe._dict(
{ {
"item_code": d.get("item_code", None), "item_code": d.get("item_code", None),
"warehouse": d.get("warehouse", None), "warehouse": d.get("warehouse", None),
"serial_and_batch_bundle": d.get("serial_and_batch_bundle"),
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"fiscal_year": get_fiscal_year(self.posting_date, company=self.company)[0], "fiscal_year": get_fiscal_year(self.posting_date, company=self.company)[0],
@ -420,8 +443,6 @@ class StockController(AccountsController):
), ),
"incoming_rate": 0, "incoming_rate": 0,
"company": self.company, "company": self.company,
"batch_no": cstr(d.get("batch_no")).strip(),
"serial_no": d.get("serial_no"),
"project": d.get("project") or self.get("project"), "project": d.get("project") or self.get("project"),
"is_cancelled": 1 if self.docstatus == 2 else 0, "is_cancelled": 1 if self.docstatus == 2 else 0,
} }

View File

@ -8,10 +8,14 @@ from collections import defaultdict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.mapper import get_mapped_doc from frappe.model.mapper import get_mapped_doc
from frappe.utils import cint, cstr, flt, get_link_to_form from frappe.utils import cint, flt, get_link_to_form
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_voucher_wise_serial_batch_from_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.serial_batch_bundle import SerialBatchCreation, get_serial_nos_from_bundle
from erpnext.stock.utils import get_incoming_rate from erpnext.stock.utils import get_incoming_rate
@ -169,7 +173,11 @@ class SubcontractingController(StockController):
self.qty_to_be_received[(row.item_code, row.parent)] += row.qty self.qty_to_be_received[(row.item_code, row.parent)] += row.qty
def __get_transferred_items(self): def __get_transferred_items(self):
fields = [f"`tabStock Entry`.`{self.subcontract_data.order_field}`"] fields = [
f"`tabStock Entry`.`{self.subcontract_data.order_field}`",
"`tabStock Entry`.`name` as voucher_no",
]
alias_dict = { alias_dict = {
"item_code": "rm_item_code", "item_code": "rm_item_code",
"subcontracted_item": "main_item_code", "subcontracted_item": "main_item_code",
@ -184,6 +192,7 @@ class SubcontractingController(StockController):
"basic_rate", "basic_rate",
"amount", "amount",
"serial_no", "serial_no",
"serial_and_batch_bundle",
"uom", "uom",
"subcontracted_item", "subcontracted_item",
"stock_uom", "stock_uom",
@ -234,9 +243,11 @@ class SubcontractingController(StockController):
"serial_no", "serial_no",
"rm_item_code", "rm_item_code",
"reference_name", "reference_name",
"serial_and_batch_bundle",
"batch_no", "batch_no",
"consumed_qty", "consumed_qty",
"main_item_code", "main_item_code",
"parent as voucher_no",
], ],
filters={"docstatus": 1, "reference_name": ("in", list(receipt_items)), "parenttype": doctype}, filters={"docstatus": 1, "reference_name": ("in", list(receipt_items)), "parenttype": doctype},
) )
@ -253,6 +264,13 @@ class SubcontractingController(StockController):
} }
consumed_materials = self.__get_consumed_items(doctype, receipt_items.keys()) consumed_materials = self.__get_consumed_items(doctype, receipt_items.keys())
voucher_nos = [d.voucher_no for d in consumed_materials if d.voucher_no]
voucher_bundle_data = get_voucher_wise_serial_batch_from_bundle(
voucher_no=voucher_nos,
is_outward=1,
get_subcontracted_item=("Subcontracting Receipt Supplied Item", "main_item_code"),
)
if return_consumed_items: if return_consumed_items:
return (consumed_materials, receipt_items) return (consumed_materials, receipt_items)
@ -262,11 +280,29 @@ class SubcontractingController(StockController):
continue continue
self.available_materials[key]["qty"] -= row.consumed_qty self.available_materials[key]["qty"] -= row.consumed_qty
bundle_key = (row.rm_item_code, row.main_item_code, self.supplier_warehouse, row.voucher_no)
consumed_bundles = voucher_bundle_data.get(bundle_key, frappe._dict())
if consumed_bundles.serial_nos:
self.available_materials[key]["serial_no"] = list(
set(self.available_materials[key]["serial_no"]) - set(consumed_bundles.serial_nos)
)
if consumed_bundles.batch_nos:
for batch_no, qty in consumed_bundles.batch_nos.items():
if qty:
# Conumed qty is negative therefore added it instead of subtracting
self.available_materials[key]["batch_no"][batch_no] += qty
consumed_bundles.batch_nos[batch_no] += abs(qty)
# Will be deprecated in v16
if row.serial_no: if row.serial_no:
self.available_materials[key]["serial_no"] = list( self.available_materials[key]["serial_no"] = list(
set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no)) set(self.available_materials[key]["serial_no"]) - set(get_serial_nos(row.serial_no))
) )
# Will be deprecated in v16
if row.batch_no: if row.batch_no:
self.available_materials[key]["batch_no"][row.batch_no] -= row.consumed_qty self.available_materials[key]["batch_no"][row.batch_no] -= row.consumed_qty
@ -281,7 +317,16 @@ class SubcontractingController(StockController):
if not self.subcontract_orders: if not self.subcontract_orders:
return return
for row in self.__get_transferred_items(): transferred_items = self.__get_transferred_items()
voucher_nos = [row.voucher_no for row in transferred_items]
voucher_bundle_data = get_voucher_wise_serial_batch_from_bundle(
voucher_no=voucher_nos,
is_outward=0,
get_subcontracted_item=("Stock Entry Detail", "subcontracted_item"),
)
for row in transferred_items:
key = (row.rm_item_code, row.main_item_code, row.get(self.subcontract_data.order_field)) key = (row.rm_item_code, row.main_item_code, row.get(self.subcontract_data.order_field))
if key not in self.available_materials: if key not in self.available_materials:
@ -310,6 +355,20 @@ class SubcontractingController(StockController):
if row.batch_no: if row.batch_no:
details.batch_no[row.batch_no] += row.qty details.batch_no[row.batch_no] += row.qty
if voucher_bundle_data:
bundle_key = (row.rm_item_code, row.main_item_code, row.t_warehouse, row.voucher_no)
bundle_data = voucher_bundle_data.get(bundle_key, frappe._dict())
if bundle_data.serial_nos:
details.serial_no.extend(bundle_data.serial_nos)
bundle_data.serial_nos = []
if bundle_data.batch_nos:
for batch_no, qty in bundle_data.batch_nos.items():
if qty > 0:
details.batch_no[batch_no] += qty
bundle_data.batch_nos[batch_no] -= qty
self.__set_alternative_item_details(row) self.__set_alternative_item_details(row)
self.__transferred_items = copy.deepcopy(self.available_materials) self.__transferred_items = copy.deepcopy(self.available_materials)
@ -327,6 +386,7 @@ class SubcontractingController(StockController):
self.set(self.raw_material_table, []) self.set(self.raw_material_table, [])
for item in self._doc_before_save.supplied_items: for item in self._doc_before_save.supplied_items:
if item.reference_name in self.__changed_name: if item.reference_name in self.__changed_name:
self.__remove_serial_and_batch_bundle(item)
continue continue
if item.reference_name not in self.__reference_name: if item.reference_name not in self.__reference_name:
@ -337,6 +397,10 @@ class SubcontractingController(StockController):
i += 1 i += 1
def __remove_serial_and_batch_bundle(self, item):
if item.serial_and_batch_bundle:
frappe.delete_doc("Serial and Batch Bundle", item.serial_and_batch_bundle, force=True)
def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0): def __get_materials_from_bom(self, item_code, bom_no, exploded_item=0):
doctype = "BOM Item" if not exploded_item else "BOM Explosion Item" doctype = "BOM Item" if not exploded_item else "BOM Explosion Item"
fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"] fields = [f"`tab{doctype}`.`stock_qty` / `tabBOM`.`quantity` as qty_consumed_per_unit"]
@ -377,68 +441,89 @@ class SubcontractingController(StockController):
if self.alternative_item_details.get(bom_item.rm_item_code): if self.alternative_item_details.get(bom_item.rm_item_code):
bom_item.update(self.alternative_item_details[bom_item.rm_item_code]) bom_item.update(self.alternative_item_details[bom_item.rm_item_code])
def __set_serial_nos(self, item_row, rm_obj): def __set_serial_and_batch_bundle(self, item_row, rm_obj, qty):
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field)) key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field))
if not self.available_materials.get(key):
return
if (
not self.available_materials[key]["serial_no"] and not self.available_materials[key]["batch_no"]
):
return
serial_nos = []
batches = frappe._dict({})
if self.available_materials.get(key) and self.available_materials[key]["serial_no"]: if self.available_materials.get(key) and self.available_materials[key]["serial_no"]:
used_serial_nos = self.available_materials[key]["serial_no"][0 : cint(rm_obj.consumed_qty)] serial_nos = self.__get_serial_nos_for_bundle(qty, key)
rm_obj.serial_no = "\n".join(used_serial_nos)
# Removed the used serial nos from the list elif self.available_materials.get(key) and self.available_materials[key]["batch_no"]:
for sn in used_serial_nos: batches = self.__get_batch_nos_for_bundle(qty, key)
self.available_materials[key]["serial_no"].remove(sn)
def __set_batch_no_as_per_qty(self, item_row, rm_obj, batch_no, qty): bundle = SerialBatchCreation(
rm_obj.update( frappe._dict(
{ {
"consumed_qty": qty, "company": self.company,
"batch_no": batch_no, "item_code": rm_obj.rm_item_code,
"required_qty": qty, "warehouse": self.supplier_warehouse,
self.subcontract_data.order_field: item_row.get(self.subcontract_data.order_field), "qty": qty,
} "serial_nos": serial_nos,
) "batches": batches,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": "Subcontracting Receipt",
"do_not_submit": True,
"type_of_transaction": "Outward" if qty > 0 else "Inward",
}
)
).make_serial_and_batch_bundle()
self.__set_serial_nos(item_row, rm_obj) return bundle.name
def __set_consumed_qty(self, rm_obj, consumed_qty, required_qty=0): def __get_batch_nos_for_bundle(self, qty, key):
rm_obj.required_qty = required_qty available_batches = defaultdict(float)
rm_obj.consumed_qty = consumed_qty
def __set_batch_nos(self, bom_item, item_row, rm_obj, qty): for batch_no, batch_qty in self.available_materials[key]["batch_no"].items():
key = (rm_obj.rm_item_code, item_row.item_code, item_row.get(self.subcontract_data.order_field)) qty_to_consumed = 0
if qty > 0:
if batch_qty >= qty:
qty_to_consumed = qty
else:
qty_to_consumed = batch_qty
if self.available_materials.get(key) and self.available_materials[key]["batch_no"]: qty -= qty_to_consumed
new_rm_obj = None if qty_to_consumed > 0:
for batch_no, batch_qty in self.available_materials[key]["batch_no"].items(): available_batches[batch_no] += qty_to_consumed
if batch_qty >= qty or ( self.available_materials[key]["batch_no"][batch_no] -= qty_to_consumed
rm_obj.consumed_qty == 0
and self.backflush_based_on == "BOM"
and len(self.available_materials[key]["batch_no"]) == 1
):
if rm_obj.consumed_qty == 0:
self.__set_consumed_qty(rm_obj, qty)
self.__set_batch_no_as_per_qty(item_row, rm_obj, batch_no, qty) return available_batches
self.available_materials[key]["batch_no"][batch_no] -= qty
return
elif qty > 0 and batch_qty > 0: def __get_serial_nos_for_bundle(self, qty, key):
qty -= batch_qty available_sns = sorted(self.available_materials[key]["serial_no"])[0 : cint(qty)]
new_rm_obj = self.append(self.raw_material_table, bom_item) serial_nos = []
new_rm_obj.reference_name = item_row.name
self.__set_batch_no_as_per_qty(item_row, new_rm_obj, batch_no, batch_qty)
self.available_materials[key]["batch_no"][batch_no] = 0
if abs(qty) > 0 and not new_rm_obj: for serial_no in available_sns:
self.__set_consumed_qty(rm_obj, qty) serial_nos.append(serial_no)
else:
self.__set_consumed_qty(rm_obj, qty, bom_item.required_qty or qty) self.available_materials[key]["serial_no"].remove(serial_no)
self.__set_serial_nos(item_row, rm_obj)
return serial_nos
def __add_supplied_item(self, item_row, bom_item, qty): def __add_supplied_item(self, item_row, bom_item, qty):
bom_item.conversion_factor = item_row.conversion_factor bom_item.conversion_factor = item_row.conversion_factor
rm_obj = self.append(self.raw_material_table, bom_item) rm_obj = self.append(self.raw_material_table, bom_item)
rm_obj.reference_name = item_row.name rm_obj.reference_name = item_row.name
if self.doctype == self.subcontract_data.order_doctype:
rm_obj.required_qty = qty
rm_obj.amount = rm_obj.required_qty * rm_obj.rate
else:
rm_obj.consumed_qty = qty
rm_obj.required_qty = bom_item.required_qty or qty
setattr(
rm_obj, self.subcontract_data.order_field, item_row.get(self.subcontract_data.order_field)
)
if self.doctype == "Subcontracting Receipt": if self.doctype == "Subcontracting Receipt":
args = frappe._dict( args = frappe._dict(
{ {
@ -447,25 +532,23 @@ class SubcontractingController(StockController):
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": -1 * flt(rm_obj.consumed_qty), "qty": -1 * flt(rm_obj.consumed_qty),
"serial_no": rm_obj.serial_no, "actual_qty": -1 * flt(rm_obj.consumed_qty),
"batch_no": rm_obj.batch_no,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
"voucher_detail_no": item_row.name,
"company": self.company, "company": self.company,
"allow_zero_valuation": 1, "allow_zero_valuation": 1,
} }
) )
rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args)
if self.doctype == self.subcontract_data.order_doctype: rm_obj.serial_and_batch_bundle = self.__set_serial_and_batch_bundle(
rm_obj.required_qty = qty item_row, rm_obj, rm_obj.consumed_qty
rm_obj.amount = rm_obj.required_qty * rm_obj.rate
else:
rm_obj.consumed_qty = 0
setattr(
rm_obj, self.subcontract_data.order_field, item_row.get(self.subcontract_data.order_field)
) )
self.__set_batch_nos(bom_item, item_row, rm_obj, qty)
if rm_obj.serial_and_batch_bundle:
args["serial_and_batch_bundle"] = rm_obj.serial_and_batch_bundle
rm_obj.rate = bom_item.rate if self.backflush_based_on == "BOM" else get_incoming_rate(args)
def __get_qty_based_on_material_transfer(self, item_row, transfer_item): def __get_qty_based_on_material_transfer(self, item_row, transfer_item):
key = (item_row.item_code, item_row.get(self.subcontract_data.order_field)) key = (item_row.item_code, item_row.get(self.subcontract_data.order_field))
@ -520,6 +603,53 @@ class SubcontractingController(StockController):
(row.item_code, row.get(self.subcontract_data.order_field)) (row.item_code, row.get(self.subcontract_data.order_field))
] -= row.qty ] -= row.qty
def __modify_serial_and_batch_bundle(self):
if self.is_new():
return
if self.doctype != "Subcontracting Receipt":
return
for item_row in self.items:
if self.__changed_name and item_row.name in self.__changed_name:
continue
modified_data = self.__get_bundle_to_modify(item_row.name)
if modified_data:
serial_nos = []
batches = frappe._dict({})
key = (
modified_data.rm_item_code,
item_row.item_code,
item_row.get(self.subcontract_data.order_field),
)
if self.available_materials.get(key) and self.available_materials[key]["serial_no"]:
serial_nos = self.__get_serial_nos_for_bundle(modified_data.consumed_qty, key)
elif self.available_materials.get(key) and self.available_materials[key]["batch_no"]:
batches = self.__get_batch_nos_for_bundle(modified_data.consumed_qty, key)
SerialBatchCreation(
{
"item_code": modified_data.rm_item_code,
"warehouse": self.supplier_warehouse,
"serial_and_batch_bundle": modified_data.serial_and_batch_bundle,
"type_of_transaction": "Outward",
"serial_nos": serial_nos,
"batches": batches,
"qty": modified_data.consumed_qty * -1,
}
).update_serial_and_batch_entries()
def __get_bundle_to_modify(self, name):
for row in self.get("supplied_items"):
if row.reference_name == name and row.serial_and_batch_bundle:
if row.consumed_qty != abs(
frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")
):
return row
def __prepare_supplied_items(self): def __prepare_supplied_items(self):
self.initialized_fields() self.initialized_fields()
self.__get_subcontract_orders() self.__get_subcontract_orders()
@ -527,6 +657,7 @@ class SubcontractingController(StockController):
self.get_available_materials() self.get_available_materials()
self.__remove_changed_rows() self.__remove_changed_rows()
self.__set_supplied_items() self.__set_supplied_items()
self.__modify_serial_and_batch_bundle()
def __validate_batch_no(self, row, key): def __validate_batch_no(self, row, key):
if row.get("batch_no") and row.get("batch_no") not in self.__transferred_items.get(key).get( if row.get("batch_no") and row.get("batch_no") not in self.__transferred_items.get(key).get(
@ -539,8 +670,8 @@ class SubcontractingController(StockController):
frappe.throw(_(msg), title=_("Incorrect Batch Consumed")) frappe.throw(_(msg), title=_("Incorrect Batch Consumed"))
def __validate_serial_no(self, row, key): def __validate_serial_no(self, row, key):
if row.get("serial_no"): if row.get("serial_and_batch_bundle") and self.__transferred_items.get(key).get("serial_no"):
serial_nos = get_serial_nos(row.get("serial_no")) serial_nos = get_serial_nos_from_bundle(row.get("serial_and_batch_bundle"))
incorrect_sn = set(serial_nos).difference(self.__transferred_items.get(key).get("serial_no")) incorrect_sn = set(serial_nos).difference(self.__transferred_items.get(key).get("serial_no"))
if incorrect_sn: if incorrect_sn:
@ -667,9 +798,7 @@ class SubcontractingController(StockController):
scr_qty = flt(item.qty) * flt(item.conversion_factor) scr_qty = flt(item.qty) * flt(item.conversion_factor)
if scr_qty: if scr_qty:
sle = self.get_sl_entries( sle = self.get_sl_entries(item, {"actual_qty": flt(scr_qty)})
item, {"actual_qty": flt(scr_qty), "serial_no": cstr(item.serial_no).strip()}
)
rate_db_precision = 6 if cint(self.precision("rate", item)) <= 6 else 9 rate_db_precision = 6 if cint(self.precision("rate", item)) <= 6 else 9
incoming_rate = flt(item.rate, rate_db_precision) incoming_rate = flt(item.rate, rate_db_precision)
sle.update( sle.update(
@ -687,7 +816,6 @@ class SubcontractingController(StockController):
{ {
"warehouse": item.rejected_warehouse, "warehouse": item.rejected_warehouse,
"actual_qty": flt(item.rejected_qty) * flt(item.conversion_factor), "actual_qty": flt(item.rejected_qty) * flt(item.conversion_factor),
"serial_no": cstr(item.rejected_serial_no).strip(),
"incoming_rate": 0.0, "incoming_rate": 0.0,
}, },
) )
@ -716,8 +844,7 @@ class SubcontractingController(StockController):
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": -1 * item.consumed_qty, "qty": -1 * item.consumed_qty,
"serial_no": item.serial_no, "serial_and_batch_bundle": item.serial_and_batch_bundle,
"batch_no": item.batch_no,
} }
) )
@ -865,7 +992,6 @@ def make_rm_stock_entry(
if rm_item.get("main_item_code") == fg_item_code or rm_item.get("item_code") == fg_item_code: if rm_item.get("main_item_code") == fg_item_code or rm_item.get("item_code") == fg_item_code:
rm_item_code = rm_item.get("rm_item_code") rm_item_code = rm_item.get("rm_item_code")
items_dict = { items_dict = {
rm_item_code: { rm_item_code: {
rm_detail_field: rm_item.get("name"), rm_detail_field: rm_item.get("name"),
@ -877,8 +1003,7 @@ def make_rm_stock_entry(
"from_warehouse": rm_item.get("warehouse") or rm_item.get("reserve_warehouse"), "from_warehouse": rm_item.get("warehouse") or rm_item.get("reserve_warehouse"),
"to_warehouse": subcontract_order.supplier_warehouse, "to_warehouse": subcontract_order.supplier_warehouse,
"stock_uom": rm_item.get("stock_uom"), "stock_uom": rm_item.get("stock_uom"),
"serial_no": rm_item.get("serial_no"), "serial_and_batch_bundle": rm_item.get("serial_and_batch_bundle"),
"batch_no": rm_item.get("batch_no"),
"main_item_code": fg_item_code, "main_item_code": fg_item_code,
"allow_alternative_item": item_wh.get(rm_item_code, {}).get("allow_alternative_item"), "allow_alternative_item": item_wh.get(rm_item_code, {}).get("allow_alternative_item"),
} }
@ -953,7 +1078,6 @@ def make_return_stock_entry_for_subcontract(
add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field) add_items_in_ste(ste_doc, value, value.qty, rm_details, rm_detail_field)
ste_doc.set_stock_entry_type() ste_doc.set_stock_entry_type()
ste_doc.calculate_rate_and_amount()
return ste_doc return ste_doc

View File

@ -15,6 +15,11 @@ from erpnext.controllers.subcontracting_controller import (
) )
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import ( from erpnext.subcontracting.doctype.subcontracting_order.subcontracting_order import (
@ -311,9 +316,6 @@ class TestSubcontractingController(FrappeTestCase):
scr1 = make_subcontracting_receipt(sco.name) scr1 = make_subcontracting_receipt(sco.name)
scr1.save() scr1.save()
scr1.supplied_items[0].consumed_qty = 5 scr1.supplied_items[0].consumed_qty = 5
scr1.supplied_items[0].serial_no = "\n".join(
sorted(itemwise_details.get("Subcontracted SRM Item 2").get("serial_no")[0:5])
)
scr1.submit() scr1.submit()
for key, value in get_supplied_items(scr1).items(): for key, value in get_supplied_items(scr1).items():
@ -341,6 +343,7 @@ class TestSubcontractingController(FrappeTestCase):
- Create the 3 SCR against the SCO and split Subcontracted Items into two batches. - Create the 3 SCR against the SCO and split Subcontracted Items into two batches.
- Keep the qty as 2 for Subcontracted Item in the SCR. - Keep the qty as 2 for Subcontracted Item in the SCR.
""" """
from erpnext.stock.serial_batch_bundle import get_batch_nos
set_backflush_based_on("BOM") set_backflush_based_on("BOM")
service_items = [ service_items = [
@ -426,6 +429,7 @@ class TestSubcontractingController(FrappeTestCase):
for key, value in get_supplied_items(scr1).items(): for key, value in get_supplied_items(scr1).items():
self.assertEqual(value.qty, 4) self.assertEqual(value.qty, 4)
frappe.flags.add_debugger = True
scr2 = make_subcontracting_receipt(sco.name) scr2 = make_subcontracting_receipt(sco.name)
scr2.items[0].qty = 2 scr2.items[0].qty = 2
add_second_row_in_scr(scr2) add_second_row_in_scr(scr2)
@ -612,9 +616,6 @@ class TestSubcontractingController(FrappeTestCase):
scr1.load_from_db() scr1.load_from_db()
scr1.supplied_items[0].consumed_qty = 5 scr1.supplied_items[0].consumed_qty = 5
scr1.supplied_items[0].serial_no = "\n".join(
itemwise_details[scr1.supplied_items[0].rm_item_code]["serial_no"]
)
scr1.save() scr1.save()
scr1.submit() scr1.submit()
@ -651,6 +652,16 @@ class TestSubcontractingController(FrappeTestCase):
- System should throw the error and not allowed to save the SCR. - System should throw the error and not allowed to save the SCR.
""" """
serial_no = "ABC"
if not frappe.db.exists("Serial No", serial_no):
frappe.get_doc(
{
"doctype": "Serial No",
"item_code": "Subcontracted SRM Item 2",
"serial_no": serial_no,
}
).insert()
set_backflush_based_on("Material Transferred for Subcontract") set_backflush_based_on("Material Transferred for Subcontract")
service_items = [ service_items = [
{ {
@ -677,10 +688,39 @@ class TestSubcontractingController(FrappeTestCase):
scr1 = make_subcontracting_receipt(sco.name) scr1 = make_subcontracting_receipt(sco.name)
scr1.save() scr1.save()
scr1.supplied_items[0].serial_no = "ABCD" bundle = frappe.get_doc(
"Serial and Batch Bundle", scr1.supplied_items[0].serial_and_batch_bundle
)
original_serial_no = ""
for row in bundle.entries:
if row.idx == 1:
original_serial_no = row.serial_no
row.serial_no = "ABC"
break
bundle.save()
self.assertRaises(frappe.ValidationError, scr1.save) self.assertRaises(frappe.ValidationError, scr1.save)
bundle.load_from_db()
for row in bundle.entries:
if row.idx == 1:
row.serial_no = original_serial_no
break
bundle.save()
scr1.load_from_db()
scr1.save()
self.delete_bundle_from_scr(scr1)
scr1.delete() scr1.delete()
@staticmethod
def delete_bundle_from_scr(scr):
for row in scr.supplied_items:
if not row.serial_and_batch_bundle:
continue
frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
def test_partial_transfer_batch_based_on_material_transfer(self): def test_partial_transfer_batch_based_on_material_transfer(self):
""" """
- Set backflush based on Material Transferred for Subcontract. - Set backflush based on Material Transferred for Subcontract.
@ -724,12 +764,9 @@ class TestSubcontractingController(FrappeTestCase):
for key, value in get_supplied_items(scr1).items(): for key, value in get_supplied_items(scr1).items():
details = itemwise_details.get(key) details = itemwise_details.get(key)
self.assertEqual(value.qty, 3) self.assertEqual(value.qty, 3)
transferred_batch_no = details.batch_no
self.assertEqual(value.batch_no, details.batch_no)
scr1.load_from_db() scr1.load_from_db()
scr1.supplied_items[0].consumed_qty = 5 scr1.supplied_items[0].consumed_qty = 5
scr1.supplied_items[0].batch_no = list(transferred_batch_no.keys())[0]
scr1.save() scr1.save()
scr1.submit() scr1.submit()
@ -883,6 +920,15 @@ def update_item_details(child_row, details):
if child_row.batch_no: if child_row.batch_no:
details.batch_no[child_row.batch_no] += child_row.get("qty") or child_row.get("consumed_qty") details.batch_no[child_row.batch_no] += child_row.get("qty") or child_row.get("consumed_qty")
if child_row.serial_and_batch_bundle:
doc = frappe.get_doc("Serial and Batch Bundle", child_row.serial_and_batch_bundle)
for row in doc.get("entries"):
if row.serial_no:
details.serial_no.append(row.serial_no)
if row.batch_no:
details.batch_no[row.batch_no] += row.qty * (-1 if doc.type_of_transaction == "Outward" else 1)
def make_stock_transfer_entry(**args): def make_stock_transfer_entry(**args):
args = frappe._dict(args) args = frappe._dict(args)
@ -903,18 +949,35 @@ def make_stock_transfer_entry(**args):
item_details = args.itemwise_details.get(row.item_code) item_details = args.itemwise_details.get(row.item_code)
serial_nos = []
batches = defaultdict(float)
if item_details and item_details.serial_no: if item_details and item_details.serial_no:
serial_nos = item_details.serial_no[0 : cint(row.qty)] serial_nos = item_details.serial_no[0 : cint(row.qty)]
item["serial_no"] = "\n".join(serial_nos)
item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos)) item_details.serial_no = list(set(item_details.serial_no) - set(serial_nos))
if item_details and item_details.batch_no: if item_details and item_details.batch_no:
for batch_no, batch_qty in item_details.batch_no.items(): for batch_no, batch_qty in item_details.batch_no.items():
if batch_qty >= row.qty: if batch_qty >= row.qty:
item["batch_no"] = batch_no batches[batch_no] = row.qty
item_details.batch_no[batch_no] -= row.qty item_details.batch_no[batch_no] -= row.qty
break break
if serial_nos or batches:
item["serial_and_batch_bundle"] = make_serial_batch_bundle(
frappe._dict(
{
"item_code": row.item_code,
"warehouse": row.warehouse or "_Test Warehouse - _TC",
"qty": (row.qty or 1) * -1,
"batches": batches,
"serial_nos": serial_nos,
"voucher_type": "Delivery Note",
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
).name
items.append(item) items.append(item)
ste_dict = make_rm_stock_entry(args.sco_no, items) ste_dict = make_rm_stock_entry(args.sco_no, items)
@ -956,7 +1019,7 @@ def make_raw_materials():
"batch_number_series": "BAT.####", "batch_number_series": "BAT.####",
}, },
"Subcontracted SRM Item 4": {"has_serial_no": 1, "serial_no_series": "SRII.####"}, "Subcontracted SRM Item 4": {"has_serial_no": 1, "serial_no_series": "SRII.####"},
"Subcontracted SRM Item 5": {"has_serial_no": 1, "serial_no_series": "SRII.####"}, "Subcontracted SRM Item 5": {"has_serial_no": 1, "serial_no_series": "SRIID.####"},
} }
for item, properties in raw_materials.items(): for item, properties in raw_materials.items():

View File

@ -67,6 +67,12 @@ treeviews = [
"Department", "Department",
] ]
jinja = {
"methods": [
"erpnext.stock.serial_batch_bundle.get_serial_or_batch_nos",
],
}
# website # website
update_website_context = [ update_website_context = [
"erpnext.e_commerce.shopping_cart.utils.update_website_context", "erpnext.e_commerce.shopping_cart.utils.update_website_context",

View File

@ -7,6 +7,19 @@ frappe.ui.form.on('Maintenance Schedule', {
frm.set_query('contact_person', erpnext.queries.contact_query); frm.set_query('contact_person', erpnext.queries.contact_query);
frm.set_query('customer_address', erpnext.queries.address_query); frm.set_query('customer_address', erpnext.queries.address_query);
frm.set_query('customer', erpnext.queries.customer); frm.set_query('customer', erpnext.queries.customer);
frm.set_query('serial_and_batch_bundle', 'items', (doc, cdt, cdn) => {
let item = locals[cdt][cdn];
return {
filters: {
'item_code': item.item_code,
'voucher_type': 'Maintenance Schedule',
'type_of_transaction': 'Maintenance',
'company': doc.company,
}
}
});
}, },
onload: function (frm) { onload: function (frm) {
if (!frm.doc.status) { if (!frm.doc.status) {

View File

@ -7,7 +7,6 @@ from frappe.utils import add_days, cint, cstr, date_diff, formatdate, getdate
from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_valid_serial_nos
from erpnext.utilities.transaction_base import TransactionBase, delete_events from erpnext.utilities.transaction_base import TransactionBase, delete_events
@ -74,10 +73,14 @@ class MaintenanceSchedule(TransactionBase):
email_map = {} email_map = {}
for d in self.get("items"): for d in self.get("items"):
if d.serial_no: if d.serial_and_batch_bundle:
serial_nos = get_valid_serial_nos(d.serial_no) serial_nos = frappe.get_doc(
self.validate_serial_no(d.item_code, serial_nos, d.start_date) "Serial and Batch Bundle", d.serial_and_batch_bundle
self.update_amc_date(serial_nos, d.end_date) ).get_serial_nos()
if serial_nos:
self.validate_serial_no(d.item_code, serial_nos, d.start_date)
self.update_amc_date(serial_nos, d.end_date)
no_email_sp = [] no_email_sp = []
if d.sales_person not in email_map: if d.sales_person not in email_map:
@ -241,9 +244,27 @@ class MaintenanceSchedule(TransactionBase):
self.validate_maintenance_detail() self.validate_maintenance_detail()
self.validate_dates_with_periodicity() self.validate_dates_with_periodicity()
self.validate_sales_order() self.validate_sales_order()
self.validate_serial_no_bundle()
if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits(): if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits():
self.generate_schedule() self.generate_schedule()
def validate_serial_no_bundle(self):
ids = [d.serial_and_batch_bundle for d in self.items if d.serial_and_batch_bundle]
if not ids:
return
voucher_nos = frappe.get_all(
"Serial and Batch Bundle", fields=["name", "voucher_type"], filters={"name": ("in", ids)}
)
for row in voucher_nos:
if row.voucher_type != "Maintenance Schedule":
msg = f"""Serial and Batch Bundle {row.name}
should have voucher type as 'Maintenance Schedule'"""
frappe.throw(_(msg))
def on_update(self): def on_update(self):
self.db_set("status", "Draft") self.db_set("status", "Draft")
@ -341,9 +362,14 @@ class MaintenanceSchedule(TransactionBase):
def on_cancel(self): def on_cancel(self):
for d in self.get("items"): for d in self.get("items"):
if d.serial_no: if d.serial_and_batch_bundle:
serial_nos = get_valid_serial_nos(d.serial_no) serial_nos = frappe.get_doc(
self.update_amc_date(serial_nos) "Serial and Batch Bundle", d.serial_and_batch_bundle
).get_serial_nos()
if serial_nos:
self.update_amc_date(serial_nos)
self.db_set("status", "Cancelled") self.db_set("status", "Cancelled")
delete_events(self.doctype, self.name) delete_events(self.doctype, self.name)
@ -397,11 +423,15 @@ def make_maintenance_visit(source_name, target_doc=None, item_name=None, s_id=No
target.maintenance_schedule_detail = s_id target.maintenance_schedule_detail = s_id
def update_serial(source, target, parent): def update_serial(source, target, parent):
serial_nos = get_serial_nos(target.serial_no) if source.serial_and_batch_bundle:
if len(serial_nos) == 1: serial_nos = frappe.get_doc(
target.serial_no = serial_nos[0] "Serial and Batch Bundle", source.serial_and_batch_bundle
else: ).get_serial_nos()
target.serial_no = ""
if len(serial_nos) == 1:
target.serial_no = serial_nos[0]
else:
target.serial_no = ""
doclist = get_mapped_doc( doclist = get_mapped_doc(
"Maintenance Schedule", "Maintenance Schedule",

View File

@ -20,7 +20,9 @@
"sales_person", "sales_person",
"reference", "reference",
"serial_no", "serial_no",
"sales_order" "sales_order",
"column_break_ugqr",
"serial_and_batch_bundle"
], ],
"fields": [ "fields": [
{ {
@ -121,7 +123,8 @@
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Serial No", "label": "Serial No",
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
"oldfieldtype": "Small Text" "oldfieldtype": "Small Text",
"read_only": 1
}, },
{ {
"fieldname": "sales_order", "fieldname": "sales_order",
@ -144,17 +147,31 @@
{ {
"fieldname": "column_break_10", "fieldname": "column_break_10",
"fieldtype": "Column Break" "fieldtype": "Column Break"
},
{
"fieldname": "column_break_ugqr",
"fieldtype": "Column Break"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2021-04-15 16:09:47.311994", "modified": "2023-03-22 18:44:36.816037",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Maintenance", "module": "Maintenance",
"name": "Maintenance Schedule Item", "name": "Maintenance Schedule Item",
"naming_rule": "Random",
"owner": "Administrator", "owner": "Administrator",
"permissions": [], "permissions": [],
"sort_field": "modified", "sort_field": "modified",
"sort_order": "DESC" "sort_order": "DESC",
"states": []
} }

View File

@ -16,6 +16,7 @@
"production_item", "production_item",
"item_name", "item_name",
"for_quantity", "for_quantity",
"serial_and_batch_bundle",
"serial_no", "serial_no",
"column_break_12", "column_break_12",
"wip_warehouse", "wip_warehouse",
@ -391,13 +392,17 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Serial No" "hidden": 1,
"label": "Serial No",
"read_only": 1
}, },
{ {
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"hidden": 1,
"label": "Batch No", "label": "Batch No",
"options": "Batch" "options": "Batch",
"read_only": 1
}, },
{ {
"collapsible": 1, "collapsible": 1,
@ -435,6 +440,14 @@
"fieldname": "expected_end_date", "fieldname": "expected_end_date",
"fieldtype": "Datetime", "fieldtype": "Datetime",
"label": "Expected End Date" "label": "Expected End Date"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"is_submittable": 1, "is_submittable": 1,

View File

@ -22,6 +22,11 @@ from erpnext.manufacturing.doctype.work_order.work_order import (
) )
from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_order
from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry import test_stock_entry from erpnext.stock.doctype.stock_entry import test_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
@ -672,8 +677,11 @@ class TestWorkOrder(FrappeTestCase):
if row.is_finished_item: if row.is_finished_item:
self.assertEqual(row.item_code, fg_item) self.assertEqual(row.item_code, fg_item)
self.assertEqual(row.qty, 10) self.assertEqual(row.qty, 10)
self.assertTrue(row.batch_no in batches)
batches.remove(row.batch_no) bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for bundle_row in bundle_id.get("entries"):
self.assertTrue(bundle_row.batch_no in batches)
batches.remove(bundle_row.batch_no)
ste1.submit() ste1.submit()
@ -682,8 +690,12 @@ class TestWorkOrder(FrappeTestCase):
for row in ste1.get("items"): for row in ste1.get("items"):
if row.is_finished_item: if row.is_finished_item:
self.assertEqual(row.item_code, fg_item) self.assertEqual(row.item_code, fg_item)
self.assertEqual(row.qty, 10) self.assertEqual(row.qty, 20)
remaining_batches.append(row.batch_no)
bundle_id = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for bundle_row in bundle_id.get("entries"):
self.assertTrue(bundle_row.batch_no in batches)
remaining_batches.append(bundle_row.batch_no)
self.assertEqual(sorted(remaining_batches), sorted(batches)) self.assertEqual(sorted(remaining_batches), sorted(batches))
@ -1168,18 +1180,28 @@ class TestWorkOrder(FrappeTestCase):
try: try:
wo_order = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True) wo_order = make_wo_order_test_record(item=fg_item, qty=2, skip_transfer=True)
serial_nos = wo_order.serial_no serial_nos = self.get_serial_nos_for_fg(wo_order.name)
stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10)) stock_entry = frappe.get_doc(make_stock_entry(wo_order.name, "Manufacture", 10))
stock_entry.set_work_order_details() stock_entry.set_work_order_details()
stock_entry.set_serial_no_batch_for_finished_good() stock_entry.set_serial_no_batch_for_finished_good()
for row in stock_entry.items: for row in stock_entry.items:
if row.item_code == fg_item: if row.item_code == fg_item:
self.assertTrue(row.serial_no) self.assertTrue(row.serial_and_batch_bundle)
self.assertEqual(sorted(get_serial_nos(row.serial_no)), sorted(get_serial_nos(serial_nos))) self.assertEqual(
sorted(get_serial_nos_from_bundle(row.serial_and_batch_bundle)), sorted(serial_nos)
)
except frappe.MandatoryError: except frappe.MandatoryError:
self.fail("Batch generation causing failing in Work Order") self.fail("Batch generation causing failing in Work Order")
def get_serial_nos_for_fg(self, work_order):
serial_nos = []
for row in frappe.get_all("Serial No", filters={"work_order": work_order}):
serial_nos.append(row.name)
return serial_nos
@change_settings( @change_settings(
"Manufacturing Settings", "Manufacturing Settings",
{"backflush_raw_materials_based_on": "Material Transferred for Manufacture"}, {"backflush_raw_materials_based_on": "Material Transferred for Manufacture"},
@ -1272,63 +1294,66 @@ class TestWorkOrder(FrappeTestCase):
fg_item = "Test FG Item with Batch Raw Materials" fg_item = "Test FG Item with Batch Raw Materials"
ste_doc = test_stock_entry.make_stock_entry( ste_doc = test_stock_entry.make_stock_entry(
item_code=batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True item_code=batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
)
ste_doc.append(
"items",
{
"item_code": batch_item,
"item_name": batch_item,
"description": batch_item,
"basic_rate": 100,
"t_warehouse": "Stores - _TC",
"qty": 2,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
},
) )
# Inward raw materials in Stores warehouse # Inward raw materials in Stores warehouse
ste_doc.insert() ste_doc.insert()
ste_doc.submit() ste_doc.submit()
ste_doc.load_from_db()
batch_list = sorted([row.batch_no for row in ste_doc.items]) batch_no = get_batch_from_bundle(ste_doc.items[0].serial_and_batch_bundle)
wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
transferred_ste_doc = frappe.get_doc( transferred_ste_doc = frappe.get_doc(
make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
) )
transferred_ste_doc.items[0].qty = 2 transferred_ste_doc.items[0].qty = 4
transferred_ste_doc.items[0].batch_no = batch_list[0] transferred_ste_doc.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": batch_item,
"warehouse": "Stores - _TC",
"company": transferred_ste_doc.company,
"qty": 4,
"voucher_type": "Stock Entry",
"batches": frappe._dict({batch_no: 4}),
"posting_date": transferred_ste_doc.posting_date,
"posting_time": transferred_ste_doc.posting_time,
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
).name
new_row = copy.deepcopy(transferred_ste_doc.items[0])
new_row.name = ""
new_row.batch_no = batch_list[1]
# Transferred two batches from Stores to WIP Warehouse
transferred_ste_doc.append("items", new_row)
transferred_ste_doc.submit() transferred_ste_doc.submit()
transferred_ste_doc.load_from_db()
# First Manufacture stock entry # First Manufacture stock entry
manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
manufacture_ste_doc1.submit()
manufacture_ste_doc1.load_from_db()
# Batch no should be same as transferred Batch no # Batch no should be same as transferred Batch no
self.assertEqual(manufacture_ste_doc1.items[0].batch_no, batch_list[0]) self.assertEqual(
get_batch_from_bundle(manufacture_ste_doc1.items[0].serial_and_batch_bundle), batch_no
)
self.assertEqual(manufacture_ste_doc1.items[0].qty, 1) self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)
manufacture_ste_doc1.submit()
# Second Manufacture stock entry # Second Manufacture stock entry
manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2))
manufacture_ste_doc2.submit()
manufacture_ste_doc2.load_from_db()
# Batch no should be same as transferred Batch no self.assertTrue(manufacture_ste_doc2.items[0].serial_and_batch_bundle)
self.assertEqual(manufacture_ste_doc2.items[0].batch_no, batch_list[0]) bundle_doc = frappe.get_doc(
self.assertEqual(manufacture_ste_doc2.items[0].qty, 1) "Serial and Batch Bundle", manufacture_ste_doc2.items[0].serial_and_batch_bundle
self.assertEqual(manufacture_ste_doc2.items[1].batch_no, batch_list[1]) )
self.assertEqual(manufacture_ste_doc2.items[1].qty, 1)
for d in bundle_doc.entries:
self.assertEqual(d.batch_no, batch_no)
self.assertEqual(abs(d.qty), 2)
def test_backflushed_serial_no_raw_materials_based_on_transferred(self): def test_backflushed_serial_no_raw_materials_based_on_transferred(self):
frappe.db.set_value( frappe.db.set_value(
@ -1386,76 +1411,79 @@ class TestWorkOrder(FrappeTestCase):
fg_item = "Test FG Item with Serial & Batch No Raw Materials" fg_item = "Test FG Item with Serial & Batch No Raw Materials"
ste_doc = test_stock_entry.make_stock_entry( ste_doc = test_stock_entry.make_stock_entry(
item_code=sn_batch_item, target="Stores - _TC", qty=2, basic_rate=100, do_not_save=True item_code=sn_batch_item, target="Stores - _TC", qty=4, basic_rate=100, do_not_save=True
)
ste_doc.append(
"items",
{
"item_code": sn_batch_item,
"item_name": sn_batch_item,
"description": sn_batch_item,
"basic_rate": 100,
"t_warehouse": "Stores - _TC",
"qty": 2,
"uom": "Nos",
"stock_uom": "Nos",
"conversion_factor": 1,
},
) )
# Inward raw materials in Stores warehouse # Inward raw materials in Stores warehouse
ste_doc.insert() ste_doc.insert()
ste_doc.submit() ste_doc.submit()
ste_doc.load_from_db()
batch_dict = {row.batch_no: get_serial_nos(row.serial_no) for row in ste_doc.items} serial_nos = []
batches = list(batch_dict.keys()) for row in ste_doc.items:
bundle_doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
for d in bundle_doc.entries:
serial_nos.append(d.serial_no)
wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4) wo_doc = make_wo_order_test_record(production_item=fg_item, qty=4)
transferred_ste_doc = frappe.get_doc( transferred_ste_doc = frappe.get_doc(
make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4) make_stock_entry(wo_doc.name, "Material Transfer for Manufacture", 4)
) )
transferred_ste_doc.items[0].qty = 2 transferred_ste_doc.items[0].qty = 4
transferred_ste_doc.items[0].batch_no = batches[0] transferred_ste_doc.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
transferred_ste_doc.items[0].serial_no = "\n".join(batch_dict.get(batches[0])) frappe._dict(
{
"item_code": transferred_ste_doc.get("items")[0].item_code,
"warehouse": transferred_ste_doc.get("items")[0].s_warehouse,
"company": transferred_ste_doc.company,
"qty": 4,
"type_of_transaction": "Outward",
"voucher_type": "Stock Entry",
"serial_nos": serial_nos,
"posting_date": transferred_ste_doc.posting_date,
"posting_time": transferred_ste_doc.posting_time,
"do_not_submit": True,
}
)
).name
new_row = copy.deepcopy(transferred_ste_doc.items[0])
new_row.name = ""
new_row.batch_no = batches[1]
new_row.serial_no = "\n".join(batch_dict.get(batches[1]))
# Transferred two batches from Stores to WIP Warehouse
transferred_ste_doc.append("items", new_row)
transferred_ste_doc.submit() transferred_ste_doc.submit()
transferred_ste_doc.load_from_db()
# First Manufacture stock entry # First Manufacture stock entry
manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1)) manufacture_ste_doc1 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 1))
manufacture_ste_doc1.submit()
manufacture_ste_doc1.load_from_db()
# Batch no & Serial Nos should be same as transferred Batch no & Serial Nos # Batch no & Serial Nos should be same as transferred Batch no & Serial Nos
batch_no = manufacture_ste_doc1.items[0].batch_no bundle = manufacture_ste_doc1.items[0].serial_and_batch_bundle
self.assertEqual( self.assertTrue(bundle)
get_serial_nos(manufacture_ste_doc1.items[0].serial_no)[0], batch_dict.get(batch_no)[0]
)
self.assertEqual(manufacture_ste_doc1.items[0].qty, 1)
manufacture_ste_doc1.submit() bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle)
for d in bundle_doc.entries:
self.assertTrue(d.serial_no)
self.assertTrue(d.batch_no)
batch_no = frappe.get_cached_value("Serial No", d.serial_no, "batch_no")
self.assertEqual(d.batch_no, batch_no)
serial_nos.remove(d.serial_no)
# Second Manufacture stock entry # Second Manufacture stock entry
manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 2)) manufacture_ste_doc2 = frappe.get_doc(make_stock_entry(wo_doc.name, "Manufacture", 3))
manufacture_ste_doc2.submit()
manufacture_ste_doc2.load_from_db()
# Batch no & Serial Nos should be same as transferred Batch no & Serial Nos bundle = manufacture_ste_doc2.items[0].serial_and_batch_bundle
batch_no = manufacture_ste_doc2.items[0].batch_no self.assertTrue(bundle)
self.assertEqual(
get_serial_nos(manufacture_ste_doc2.items[0].serial_no)[0], batch_dict.get(batch_no)[1]
)
self.assertEqual(manufacture_ste_doc2.items[0].qty, 1)
batch_no = manufacture_ste_doc2.items[1].batch_no bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle)
self.assertEqual( for d in bundle_doc.entries:
get_serial_nos(manufacture_ste_doc2.items[1].serial_no)[0], batch_dict.get(batch_no)[0] self.assertTrue(d.serial_no)
) self.assertTrue(d.batch_no)
self.assertEqual(manufacture_ste_doc2.items[1].qty, 1) serial_nos.remove(d.serial_no)
self.assertFalse(serial_nos)
def test_non_consumed_material_return_against_work_order(self): def test_non_consumed_material_return_against_work_order(self):
frappe.db.set_value( frappe.db.set_value(
@ -1490,13 +1518,10 @@ class TestWorkOrder(FrappeTestCase):
for row in ste_doc.items: for row in ste_doc.items:
row.qty += 2 row.qty += 2
row.transfer_qty += 2 row.transfer_qty += 2
nste_doc = test_stock_entry.make_stock_entry( test_stock_entry.make_stock_entry(
item_code=row.item_code, target="Stores - _TC", qty=row.qty, basic_rate=100 item_code=row.item_code, target="Stores - _TC", qty=row.qty, basic_rate=100
) )
row.batch_no = nste_doc.items[0].batch_no
row.serial_no = nste_doc.items[0].serial_no
ste_doc.save() ste_doc.save()
ste_doc.submit() ste_doc.submit()
ste_doc.load_from_db() ste_doc.load_from_db()
@ -1508,9 +1533,19 @@ class TestWorkOrder(FrappeTestCase):
row.qty -= 2 row.qty -= 2
row.transfer_qty -= 2 row.transfer_qty -= 2
if row.serial_no: if not row.serial_and_batch_bundle:
serial_nos = get_serial_nos(row.serial_no) continue
row.serial_no = "\n".join(serial_nos[0:5])
bundle_id = row.serial_and_batch_bundle
bundle_doc = frappe.get_doc("Serial and Batch Bundle", bundle_id)
if bundle_doc.has_serial_no:
bundle_doc.set("entries", bundle_doc.entries[0:5])
else:
for bundle_row in bundle_doc.entries:
bundle_row.qty += 2
bundle_doc.save()
bundle_doc.load_from_db()
ste_doc.save() ste_doc.save()
ste_doc.submit() ste_doc.submit()

View File

@ -42,7 +42,6 @@
"has_serial_no", "has_serial_no",
"has_batch_no", "has_batch_no",
"column_break_18", "column_break_18",
"serial_no",
"batch_size", "batch_size",
"required_items_section", "required_items_section",
"materials_and_operations_tab", "materials_and_operations_tab",
@ -532,13 +531,6 @@
"label": "Has Batch No", "label": "Has Batch No",
"read_only": 1 "read_only": 1
}, },
{
"depends_on": "has_serial_no",
"fieldname": "serial_no",
"fieldtype": "Small Text",
"label": "Serial Nos",
"no_copy": 1
},
{ {
"default": "0", "default": "0",
"depends_on": "has_batch_no", "depends_on": "has_batch_no",

View File

@ -17,6 +17,7 @@ from frappe.utils import (
get_datetime, get_datetime,
get_link_to_form, get_link_to_form,
getdate, getdate,
now,
nowdate, nowdate,
time_diff_in_hours, time_diff_in_hours,
) )
@ -32,12 +33,7 @@ from erpnext.manufacturing.doctype.manufacturing_settings.manufacturing_settings
) )
from erpnext.stock.doctype.batch.batch import make_batch from erpnext.stock.doctype.batch.batch import make_batch
from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life from erpnext.stock.doctype.item.item import get_item_defaults, validate_end_of_life
from erpnext.stock.doctype.serial_no.serial_no import ( from erpnext.stock.doctype.serial_no.serial_no import get_available_serial_nos, get_serial_nos
auto_make_serial_nos,
clean_serial_no_string,
get_auto_serial_nos,
get_serial_nos,
)
from erpnext.stock.stock_balance import get_planned_qty, update_bin_qty 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.stock.utils import get_bin, get_latest_stock_qty, validate_warehouse_company
from erpnext.utilities.transaction_base import validate_uom_is_integer from erpnext.utilities.transaction_base import validate_uom_is_integer
@ -448,24 +444,53 @@ class WorkOrder(Document):
frappe.delete_doc("Batch", row.name) frappe.delete_doc("Batch", row.name)
def make_serial_nos(self, args): def make_serial_nos(self, args):
self.serial_no = clean_serial_no_string(self.serial_no) item_details = frappe.get_cached_value(
serial_no_series = frappe.get_cached_value("Item", self.production_item, "serial_no_series") "Item", self.production_item, ["serial_no_series", "item_name", "description"], as_dict=1
if serial_no_series: )
self.serial_no = get_auto_serial_nos(serial_no_series, self.qty)
if self.serial_no: serial_nos = []
args.update({"serial_no": self.serial_no, "actual_qty": self.qty}) if item_details.serial_no_series:
auto_make_serial_nos(args) serial_nos = get_available_serial_nos(item_details.serial_no_series, self.qty)
serial_nos_length = len(get_serial_nos(self.serial_no)) if not serial_nos:
if serial_nos_length != self.qty: return
frappe.throw(
_("{0} Serial Numbers required for Item {1}. You have provided {2}.").format( fields = [
self.qty, self.production_item, serial_nos_length "name",
), "serial_no",
SerialNoQtyError, "creation",
"modified",
"owner",
"modified_by",
"company",
"item_code",
"item_name",
"description",
"status",
"work_order",
]
serial_nos_details = []
for serial_no in serial_nos:
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.company,
self.production_item,
item_details.item_name,
item_details.description,
"Inactive",
self.name,
)
) )
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
def create_job_card(self): def create_job_card(self):
manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings") manufacturing_settings_doc = frappe.get_doc("Manufacturing Settings")
@ -1042,24 +1067,6 @@ class WorkOrder(Document):
bom.set_bom_material_details() bom.set_bom_material_details()
return bom return bom
def update_batch_produced_qty(self, stock_entry_doc):
if not cint(
frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")
):
return
for row in stock_entry_doc.items:
if row.batch_no and (row.is_finished_item or row.is_scrap_item):
qty = frappe.get_all(
"Stock Entry Detail",
filters={"batch_no": row.batch_no, "docstatus": 1},
or_filters={"is_finished_item": 1, "is_scrap_item": 1},
fields=["sum(qty)"],
as_list=1,
)[0][0]
frappe.db.set_value("Batch", row.batch_no, "produced_qty", flt(qty))
@frappe.whitelist() @frappe.whitelist()
@frappe.validate_and_sanitize_search_inputs @frappe.validate_and_sanitize_search_inputs
@ -1357,10 +1364,10 @@ def split_qty_based_on_batch_size(wo_doc, row, qty):
def get_serial_nos_for_job_card(row, wo_doc): def get_serial_nos_for_job_card(row, wo_doc):
if not wo_doc.serial_no: if not wo_doc.has_serial_no:
return return
serial_nos = get_serial_nos(wo_doc.serial_no) serial_nos = get_serial_nos_for_work_order(wo_doc.name, wo_doc.production_item)
used_serial_nos = [] used_serial_nos = []
for d in frappe.get_all( for d in frappe.get_all(
"Job Card", "Job Card",
@ -1373,6 +1380,21 @@ def get_serial_nos_for_job_card(row, wo_doc):
row.serial_no = "\n".join(serial_nos[0 : cint(row.job_card_qty)]) row.serial_no = "\n".join(serial_nos[0 : cint(row.job_card_qty)])
def get_serial_nos_for_work_order(work_order, production_item):
serial_nos = []
for d in frappe.get_all(
"Serial No",
fields=["name"],
filters={
"work_order": work_order,
"item_code": production_item,
},
):
serial_nos.append(d.name)
return serial_nos
def validate_operation_data(row): def validate_operation_data(row):
if row.get("qty") <= 0: if row.get("qty") <= 0:
frappe.throw( frappe.throw(

View File

@ -61,7 +61,6 @@ def execute():
doc.load_items_from_bom() doc.load_items_from_bom()
doc.calculate_rate_and_amount() doc.calculate_rate_and_amount()
set_expense_account(doc) set_expense_account(doc)
doc.make_batches("t_warehouse")
if doc.docstatus == 0: if doc.docstatus == 0:
doc.save() doc.save()

View File

@ -341,10 +341,68 @@ erpnext.buying.BuyingController = class BuyingController extends erpnext.Transac
} }
frappe.throw(msg); frappe.throw(msg);
} }
}); }
);
}
} }
}
add_serial_batch_bundle(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;
let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
.then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward";
item.is_rejected = false;
frappe.require(path, function() {
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
frappe.model.set_value(item.doctype, item.name, {
"serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
});
}
}
);
});
}
});
}
add_serial_batch_for_rejected_qty(doc, cdt, cdn) {
let item = locals[cdt][cdn];
let me = this;
let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
.then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Inward" : "Outward";
item.is_rejected = true;
frappe.require(path, function() {
new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
frappe.model.set_value(item.doctype, item.name, {
"rejected_serial_and_batch_bundle": r.name,
"rejected_qty": Math.abs(r.total_qty)
});
}
}
);
});
}
});
}
}; };
cur_frm.add_fetch('project', 'cost_center', 'cost_center'); cur_frm.add_fetch('project', 'cost_center', 'cost_center');

View File

@ -6,6 +6,9 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
setup() { setup() {
super.setup(); super.setup();
let me = this; let me = this;
this.frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle'];
frappe.flags.hide_serial_batch_dialog = true; frappe.flags.hide_serial_batch_dialog = true;
frappe.ui.form.on(this.frm.doctype + " Item", "rate", function(frm, cdt, cdn) { frappe.ui.form.on(this.frm.doctype + " Item", "rate", function(frm, cdt, cdn) {
var item = frappe.get_doc(cdt, cdn); var item = frappe.get_doc(cdt, cdn);
@ -119,9 +122,16 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
}); });
if(this.frm.fields_dict["items"].grid.get_field('batch_no')) { if(this.frm.fields_dict["items"].grid.get_field('serial_and_batch_bundle')) {
this.frm.set_query("batch_no", "items", function(doc, cdt, cdn) { this.frm.set_query("serial_and_batch_bundle", "items", function(doc, cdt, cdn) {
return me.set_query_for_batch(doc, cdt, cdn); let item_row = locals[cdt][cdn];
return {
filters: {
'item_code': item_row.item_code,
'voucher_type': doc.doctype,
'voucher_no': ["in", [doc.name, ""]],
}
}
}); });
} }
@ -422,7 +432,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
update_stock = cint(me.frm.doc.update_stock); update_stock = cint(me.frm.doc.update_stock);
show_batch_dialog = update_stock; show_batch_dialog = update_stock;
} else if((this.frm.doc.doctype === 'Purchase Receipt' && me.frm.doc.is_return) || } else if((this.frm.doc.doctype === 'Purchase Receipt') ||
this.frm.doc.doctype === 'Delivery Note') { this.frm.doc.doctype === 'Delivery Note') {
show_batch_dialog = 1; show_batch_dialog = 1;
} }
@ -514,6 +524,8 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
if (r.message && if (r.message &&
(r.message.has_batch_no || r.message.has_serial_no)) { (r.message.has_batch_no || r.message.has_serial_no)) {
frappe.flags.hide_serial_batch_dialog = false; frappe.flags.hide_serial_batch_dialog = false;
} else {
show_batch_dialog = false;
} }
}); });
}, },
@ -528,7 +540,7 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
}); });
}, },
() => { () => {
if(show_batch_dialog && !frappe.flags.hide_serial_batch_dialog) { if(show_batch_dialog && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) {
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
$.each(r.message, function(k, v) { $.each(r.message, function(k, v) {
if(!d[k]) d[k] = v; if(!d[k]) d[k] = v;
@ -538,12 +550,15 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
d.batch_no = undefined; d.batch_no = undefined;
} }
frappe.flags.dialog_set = true;
erpnext.show_serial_batch_selector(me.frm, d, (item) => { erpnext.show_serial_batch_selector(me.frm, d, (item) => {
me.frm.script_manager.trigger('qty', item.doctype, item.name); me.frm.script_manager.trigger('qty', item.doctype, item.name);
if (!me.frm.doc.set_warehouse) if (!me.frm.doc.set_warehouse)
me.frm.script_manager.trigger('warehouse', item.doctype, item.name); me.frm.script_manager.trigger('warehouse', item.doctype, item.name);
me.apply_price_list(item, true); me.apply_price_list(item, true);
}, undefined, !frappe.flags.hide_serial_batch_dialog); }, undefined, !frappe.flags.hide_serial_batch_dialog);
} else {
frappe.flags.dialog_set = false;
} }
}, },
() => me.conversion_factor(doc, cdt, cdn, true), () => me.conversion_factor(doc, cdt, cdn, true),
@ -672,6 +687,10 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
} }
on_submit() {
refresh_field("items");
}
update_qty(cdt, cdn) { update_qty(cdt, cdn) {
var valid_serial_nos = []; var valid_serial_nos = [];
var serialnos = []; var serialnos = [];
@ -2272,12 +2291,13 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe
} }
}; };
erpnext.show_serial_batch_selector = function (frm, d, callback, on_close, show_dialog) { erpnext.show_serial_batch_selector = function (frm, item_row, callback, on_close, show_dialog) {
debugger
let warehouse, receiving_stock, existing_stock; let warehouse, receiving_stock, existing_stock;
if (frm.doc.is_return) { if (frm.doc.is_return) {
if (["Purchase Receipt", "Purchase Invoice"].includes(frm.doc.doctype)) { if (["Purchase Receipt", "Purchase Invoice"].includes(frm.doc.doctype)) {
existing_stock = true; existing_stock = true;
warehouse = d.warehouse; warehouse = item_row.warehouse;
} else if (["Delivery Note", "Sales Invoice"].includes(frm.doc.doctype)) { } else if (["Delivery Note", "Sales Invoice"].includes(frm.doc.doctype)) {
receiving_stock = true; receiving_stock = true;
} }
@ -2287,11 +2307,11 @@ erpnext.show_serial_batch_selector = function (frm, d, callback, on_close, show_
receiving_stock = true; receiving_stock = true;
} else { } else {
existing_stock = true; existing_stock = true;
warehouse = d.s_warehouse; warehouse = item_row.s_warehouse;
} }
} else { } else {
existing_stock = true; existing_stock = true;
warehouse = d.warehouse; warehouse = item_row.warehouse;
} }
} }
@ -2304,16 +2324,23 @@ erpnext.show_serial_batch_selector = function (frm, d, callback, on_close, show_
} }
frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function() { frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function() {
new erpnext.SerialNoBatchSelector({ if (in_list(["Sales Invoice", "Delivery Note"], frm.doc.doctype)) {
frm: frm, item_row.outward = frm.doc.is_return ? 0 : 1;
item: d, } else {
warehouse_details: { item_row.outward = frm.doc.is_return ? 1 : 0;
type: "Warehouse", }
name: warehouse
}, item_row.type_of_transaction = (item_row.outward === 1
callback: callback, ? "Outward":"Inward");
on_close: on_close
}, show_dialog); new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
if (r) {
frappe.model.set_value(item_row.doctype, item_row.name, {
"serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
});
}
});
}); });
} }

View File

@ -1,618 +1,402 @@
erpnext.SerialBatchPackageSelector = class SerialNoBatchBundleUpdate {
constructor(frm, item, callback) {
this.frm = frm;
this.item = item;
this.qty = item.qty;
this.callback = callback;
this.bundle = this.item?.is_rejected ?
this.item.rejected_serial_and_batch_bundle : this.item.serial_and_batch_bundle;
erpnext.SerialNoBatchSelector = class SerialNoBatchSelector { this.make();
constructor(opts, show_dialog) { this.render_data();
$.extend(this, opts);
this.show_dialog = show_dialog;
// frm, item, warehouse_details, has_batch, oldest
let d = this.item;
this.has_batch = 0; this.has_serial_no = 0;
if (d && d.has_batch_no && (!d.batch_no || this.show_dialog)) this.has_batch = 1;
// !(this.show_dialog == false) ensures that show_dialog is implictly true, even when undefined
if(d && d.has_serial_no && !(this.show_dialog == false)) this.has_serial_no = 1;
this.setup();
} }
setup() { make() {
this.item_code = this.item.item_code; let label = this.item?.has_serial_no ? __('Serial Nos') : __('Batch Nos');
this.qty = this.item.qty; let primary_label = this.bundle
this.make_dialog(); ? __('Update') : __('Add');
this.on_close_dialog();
}
make_dialog() { if (this.item?.has_serial_no && this.item?.batch_no) {
var me = this; label = __('Serial Nos / Batch Nos');
this.data = this.oldest ? this.oldest : [];
let title = "";
let fields = [
{
fieldname: 'item_code',
read_only: 1,
fieldtype:'Link',
options: 'Item',
label: __('Item Code'),
default: me.item_code
},
{
fieldname: 'warehouse',
fieldtype:'Link',
options: 'Warehouse',
reqd: me.has_batch && !me.has_serial_no ? 0 : 1,
label: __(me.warehouse_details.type),
default: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '',
onchange: function(e) {
me.warehouse_details.name = this.get_value();
if(me.has_batch && !me.has_serial_no) {
fields = fields.concat(me.get_batch_fields());
} else {
fields = fields.concat(me.get_serial_no_fields());
}
var batches = this.layout.fields_dict.batches;
if(batches) {
batches.grid.df.data = [];
batches.grid.refresh();
batches.grid.add_new_row(null, null, null);
}
},
get_query: function() {
return {
query: "erpnext.controllers.queries.warehouse_query",
filters: [
["Bin", "item_code", "=", me.item_code],
["Warehouse", "is_group", "=", 0],
["Warehouse", "company", "=", me.frm.doc.company]
]
}
}
},
{fieldtype:'Column Break'},
{
fieldname: 'qty',
fieldtype:'Float',
read_only: me.has_batch && !me.has_serial_no,
label: __(me.has_batch && !me.has_serial_no ? 'Selected Qty' : 'Qty'),
default: flt(me.item.stock_qty) || flt(me.item.transfer_qty),
},
...get_pending_qty_fields(me),
{
fieldname: 'uom',
read_only: 1,
fieldtype: 'Link',
options: 'UOM',
label: __('UOM'),
default: me.item.uom
},
{
fieldname: 'auto_fetch_button',
fieldtype:'Button',
hidden: me.has_batch && !me.has_serial_no,
label: __('Auto Fetch'),
description: __('Fetch Serial Numbers based on FIFO'),
click: () => {
let qty = this.dialog.fields_dict.qty.get_value();
let already_selected_serial_nos = get_selected_serial_nos(me);
let numbers = frappe.call({
method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number",
args: {
qty: qty,
item_code: me.item_code,
warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '',
batch_nos: me.item.batch_no || null,
posting_date: me.frm.doc.posting_date || me.frm.doc.transaction_date,
exclude_sr_nos: already_selected_serial_nos
}
});
numbers.then((data) => {
let auto_fetched_serial_numbers = data.message;
let records_length = auto_fetched_serial_numbers.length;
if (!records_length) {
const warehouse = me.dialog.fields_dict.warehouse.get_value().bold();
frappe.msgprint(
__('Serial numbers unavailable for Item {0} under warehouse {1}. Please try changing warehouse.', [me.item.item_code.bold(), warehouse])
);
}
if (records_length < qty) {
frappe.msgprint(__('Fetched only {0} available serial numbers.', [records_length]));
}
let serial_no_list_field = this.dialog.fields_dict.serial_no;
numbers = auto_fetched_serial_numbers.join('\n');
serial_no_list_field.set_value(numbers);
});
}
}
];
if (this.has_batch && !this.has_serial_no) {
title = __("Select Batch Numbers");
fields = fields.concat(this.get_batch_fields());
} else {
// if only serial no OR
// if both batch_no & serial_no then only select serial_no and auto set batches nos
title = __("Select Serial Numbers");
fields = fields.concat(this.get_serial_no_fields());
} }
primary_label += ' ' + label;
this.dialog = new frappe.ui.Dialog({ this.dialog = new frappe.ui.Dialog({
title: title, title: this.item?.title || primary_label,
fields: fields fields: this.get_dialog_fields(),
primary_action_label: primary_label,
primary_action: () => this.update_ledgers(),
secondary_action_label: __('Edit Full Form'),
secondary_action: () => this.edit_full_form(),
}); });
this.dialog.set_primary_action(__('Insert'), function() { this.dialog.set_value("qty", this.item.qty);
me.values = me.dialog.get_values();
if(me.validate()) {
frappe.run_serially([
() => me.update_batch_items(),
() => me.update_serial_no_item(),
() => me.update_batch_serial_no_items(),
() => {
refresh_field("items");
refresh_field("packed_items");
if (me.callback) {
return me.callback(me.item);
}
},
() => me.dialog.hide()
])
}
});
if(this.show_dialog) {
let d = this.item;
if (this.item.serial_no) {
this.dialog.fields_dict.serial_no.set_value(this.item.serial_no);
}
if (this.has_batch && !this.has_serial_no && d.batch_no) {
this.frm.doc.items.forEach(data => {
if(data.item_code == d.item_code) {
this.dialog.fields_dict.batches.df.data.push({
'batch_no': data.batch_no,
'actual_qty': data.actual_qty,
'selected_qty': data.qty,
'available_qty': data.actual_batch_qty
});
}
});
this.dialog.fields_dict.batches.grid.refresh();
}
}
if (this.has_batch && !this.has_serial_no) {
this.update_total_qty();
this.update_pending_qtys();
}
this.dialog.show(); this.dialog.show();
} }
on_close_dialog() { get_serial_no_filters() {
this.dialog.get_close_btn().on('click', () => { let warehouse = this.item?.outward ?
this.on_close && this.on_close(this.item); (this.item.warehouse || this.item.s_warehouse) : "";
});
return {
'item_code': this.item.item_code,
'warehouse': ["=", warehouse]
};
} }
validate() { get_dialog_fields() {
let values = this.values; let fields = [];
if(!values.warehouse) {
frappe.throw(__("Please select a warehouse"));
return false;
}
if(this.has_batch && !this.has_serial_no) {
if(values.batches.length === 0 || !values.batches) {
frappe.throw(__("Please select batches for batched item {0}", [values.item_code]));
}
values.batches.map((batch, i) => {
if(!batch.selected_qty || batch.selected_qty === 0 ) {
if (!this.show_dialog) {
frappe.throw(__("Please select quantity on row {0}", [i+1]));
}
}
});
return true;
} else { if (this.item.has_serial_no) {
let serial_nos = values.serial_no || ''; fields.push({
if (!serial_nos || !serial_nos.replace(/\s/g, '').length) { fieldtype: 'Data',
frappe.throw(__("Please enter serial numbers for serialized item {0}", [values.item_code])); fieldname: 'scan_serial_no',
} label: __('Scan Serial No'),
return true; get_query: () => {
}
}
update_batch_items() {
// clones an items if muliple batches are selected.
if(this.has_batch && !this.has_serial_no) {
this.values.batches.map((batch, i) => {
let batch_no = batch.batch_no;
let row = '';
if (i !== 0 && !this.batch_exists(batch_no)) {
row = this.frm.add_child("items", { ...this.item });
} else {
row = this.frm.doc.items.find(i => i.batch_no === batch_no);
}
if (!row) {
row = this.item;
}
// this ensures that qty & batch no is set
this.map_row_values(row, batch, 'batch_no',
'selected_qty', this.values.warehouse);
});
}
}
update_serial_no_item() {
// just updates serial no for the item
if(this.has_serial_no && !this.has_batch) {
this.map_row_values(this.item, this.values, 'serial_no', 'qty');
}
}
update_batch_serial_no_items() {
// if serial no selected is from different batches, adds new rows for each batch.
if(this.has_batch && this.has_serial_no) {
const selected_serial_nos = this.values.serial_no.split(/\n/g).filter(s => s);
return frappe.db.get_list("Serial No", {
filters: { 'name': ["in", selected_serial_nos]},
fields: ["batch_no", "name"]
}).then((data) => {
// data = [{batch_no: 'batch-1', name: "SR-001"},
// {batch_no: 'batch-2', name: "SR-003"}, {batch_no: 'batch-2', name: "SR-004"}]
const batch_serial_map = data.reduce((acc, d) => {
if (!acc[d['batch_no']]) acc[d['batch_no']] = [];
acc[d['batch_no']].push(d['name'])
return acc
}, {})
// batch_serial_map = { "batch-1": ['SR-001'], "batch-2": ["SR-003", "SR-004"]}
Object.keys(batch_serial_map).map((batch_no, i) => {
let row = '';
const serial_no = batch_serial_map[batch_no];
if (i == 0) {
row = this.item;
this.map_row_values(row, {qty: serial_no.length, batch_no: batch_no}, 'batch_no',
'qty', this.values.warehouse);
} else if (!this.batch_exists(batch_no)) {
row = this.frm.add_child("items", { ...this.item });
row.batch_no = batch_no;
} else {
row = this.frm.doc.items.find(i => i.batch_no === batch_no);
}
const values = {
'qty': serial_no.length,
'serial_no': serial_no.join('\n')
}
this.map_row_values(row, values, 'serial_no',
'qty', this.values.warehouse);
});
})
}
}
batch_exists(batch) {
const batches = this.frm.doc.items.map(data => data.batch_no);
return (batches && in_list(batches, batch)) ? true : false;
}
map_row_values(row, values, number, qty_field, warehouse) {
row.qty = values[qty_field];
row.transfer_qty = flt(values[qty_field]) * flt(row.conversion_factor);
row[number] = values[number];
if(this.warehouse_details.type === 'Source Warehouse') {
row.s_warehouse = values.warehouse || warehouse;
} else if(this.warehouse_details.type === 'Target Warehouse') {
row.t_warehouse = values.warehouse || warehouse;
} else {
row.warehouse = values.warehouse || warehouse;
}
this.frm.dirty();
}
update_total_qty() {
let qty_field = this.dialog.fields_dict.qty;
let total_qty = 0;
this.dialog.fields_dict.batches.df.data.forEach(data => {
total_qty += flt(data.selected_qty);
});
qty_field.set_input(total_qty);
}
update_pending_qtys() {
const pending_qty_field = this.dialog.fields_dict.pending_qty;
const total_selected_qty_field = this.dialog.fields_dict.total_selected_qty;
if (!pending_qty_field || !total_selected_qty_field) return;
const me = this;
const required_qty = this.dialog.fields_dict.required_qty.value;
const selected_qty = this.dialog.fields_dict.qty.value;
const total_selected_qty = selected_qty + calc_total_selected_qty(me);
const pending_qty = required_qty - total_selected_qty;
pending_qty_field.set_input(pending_qty);
total_selected_qty_field.set_input(total_selected_qty);
}
get_batch_fields() {
var me = this;
return [
{fieldtype:'Section Break', label: __('Batches')},
{fieldname: 'batches', fieldtype: 'Table', label: __('Batch Entries'),
fields: [
{
'fieldtype': 'Link',
'read_only': 0,
'fieldname': 'batch_no',
'options': 'Batch',
'label': __('Select Batch'),
'in_list_view': 1,
get_query: function () {
return {
filters: {
item_code: me.item_code,
warehouse: me.warehouse || typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : ''
},
query: 'erpnext.controllers.queries.get_batch_no'
};
},
change: function () {
const batch_no = this.get_value();
if (!batch_no) {
this.grid_row.on_grid_fields_dict
.available_qty.set_value(0);
return;
}
let selected_batches = this.grid.grid_rows.map((row) => {
if (row === this.grid_row) {
return "";
}
if (row.on_grid_fields_dict.batch_no) {
return row.on_grid_fields_dict.batch_no.get_value();
}
});
if (selected_batches.includes(batch_no)) {
this.set_value("");
frappe.throw(__('Batch {0} already selected.', [batch_no]));
}
if (me.warehouse_details.name) {
frappe.call({
method: 'erpnext.stock.doctype.batch.batch.get_batch_qty',
args: {
batch_no,
warehouse: me.warehouse_details.name,
item_code: me.item_code
},
callback: (r) => {
this.grid_row.on_grid_fields_dict
.available_qty.set_value(r.message || 0);
}
});
} else {
this.set_value("");
frappe.throw(__('Please select a warehouse to get available quantities'));
}
// e.stopImmediatePropagation();
}
},
{
'fieldtype': 'Float',
'read_only': 1,
'fieldname': 'available_qty',
'label': __('Available'),
'in_list_view': 1,
'default': 0,
change: function () {
this.grid_row.on_grid_fields_dict.selected_qty.set_value('0');
}
},
{
'fieldtype': 'Float',
'read_only': 0,
'fieldname': 'selected_qty',
'label': __('Qty'),
'in_list_view': 1,
'default': 0,
change: function () {
var batch_no = this.grid_row.on_grid_fields_dict.batch_no.get_value();
var available_qty = this.grid_row.on_grid_fields_dict.available_qty.get_value();
var selected_qty = this.grid_row.on_grid_fields_dict.selected_qty.get_value();
if (batch_no.length === 0 && parseInt(selected_qty) !== 0) {
frappe.throw(__("Please select a batch"));
}
if (me.warehouse_details.type === 'Source Warehouse' &&
parseFloat(available_qty) < parseFloat(selected_qty)) {
this.set_value('0');
frappe.throw(__('For transfer from source, selected quantity cannot be greater than available quantity'));
} else {
this.grid.refresh();
}
me.update_total_qty();
me.update_pending_qtys();
}
},
],
in_place_edit: true,
data: this.data,
get_data: function () {
return this.data;
},
}
];
}
get_serial_no_fields() {
var me = this;
this.serial_list = [];
let serial_no_filters = {
item_code: me.item_code,
delivery_document_no: ""
}
if (this.item.batch_no) {
serial_no_filters["batch_no"] = this.item.batch_no;
}
if (me.warehouse_details.name) {
serial_no_filters['warehouse'] = me.warehouse_details.name;
}
if (me.frm.doc.doctype === 'POS Invoice' && !this.showing_reserved_serial_nos_error) {
frappe.call({
method: "erpnext.stock.doctype.serial_no.serial_no.get_pos_reserved_serial_nos",
args: {
filters: {
item_code: me.item_code,
warehouse: typeof me.warehouse_details.name == "string" ? me.warehouse_details.name : '',
}
}
}).then((data) => {
serial_no_filters['name'] = ["not in", data.message[0]]
})
}
return [
{fieldtype: 'Section Break', label: __('Serial Numbers')},
{
fieldtype: 'Link', fieldname: 'serial_no_select', options: 'Serial No',
label: __('Select to add Serial Number.'),
get_query: function() {
return { return {
filters: serial_no_filters filters: this.get_serial_no_filters()
}; };
}, },
onchange: function(e) { onchange: () => this.update_serial_batch_no()
if(this.in_local_change) return; });
this.in_local_change = 1; }
let serial_no_list_field = this.layout.fields_dict.serial_no; if (this.item.has_batch_no && this.item.has_serial_no) {
let qty_field = this.layout.fields_dict.qty; fields.push({
fieldtype: 'Column Break',
});
}
let new_number = this.get_value(); if (this.item.has_batch_no) {
let list_value = serial_no_list_field.get_value(); fields.push({
let new_line = '\n'; fieldtype: 'Data',
if(!list_value) { fieldname: 'scan_batch_no',
new_line = ''; label: __('Scan Batch No'),
} else { get_query: () => {
me.serial_list = list_value.replace(/\n/g, ' ').match(/\S+/g) || []; return {
} filters: {
'item': this.item.item_code
}
};
},
onchange: () => this.update_serial_batch_no()
});
}
if(!me.serial_list.includes(new_number)) { if (this.frm.doc.doctype === 'Stock Entry'
this.set_new_description(''); && this.frm.doc.purpose === 'Manufacture') {
serial_no_list_field.set_value(me.serial_list.join('\n') + new_line + new_number); fields.push({
me.serial_list = serial_no_list_field.get_value().replace(/\n/g, ' ').match(/\S+/g) || []; fieldtype: 'Column Break',
} else { });
this.set_new_description(new_number + ' is already selected.');
}
qty_field.set_input(me.serial_list.length); fields.push({
this.$input.val(""); fieldtype: 'Link',
this.in_local_change = 0; fieldname: 'work_order',
} label: __('For Work Order'),
}, options: 'Work Order',
{fieldtype: 'Column Break'}, read_only: 1,
default: this.frm.doc.work_order,
});
}
if (this.item?.outward) {
fields = [...this.get_filter_fields(), ...fields];
} else {
fields = [...fields, ...this.get_attach_field()];
}
fields.push({
fieldtype: 'Section Break',
});
fields.push({
fieldname: 'entries',
fieldtype: 'Table',
allow_bulk_edit: true,
data: [],
fields: this.get_dialog_table_fields(),
});
return fields;
}
get_attach_field() {
let label = this.item?.has_serial_no ? __('Serial Nos') : __('Batch Nos');
let primary_label = this.bundle
? __('Update') : __('Add');
if (this.item?.has_serial_no && this.item?.has_batch_no) {
label = __('Serial Nos / Batch Nos');
}
return [
{ {
fieldname: 'serial_no', fieldtype: 'Section Break',
fieldtype: 'Small Text', label: __('{0} {1} via CSV File', [primary_label, label])
label: __(me.has_batch && !me.has_serial_no ? 'Selected Batch Numbers' : 'Selected Serial Numbers'), },
onchange: function() { {
me.serial_list = this.get_value() fieldtype: 'Button',
.replace(/\n/g, ' ').match(/\S+/g) || []; fieldname: 'download_csv',
this.layout.fields_dict.qty.set_input(me.serial_list.length); label: __('Download CSV Template'),
click: () => this.download_csv_file()
},
{
fieldtype: 'Column Break',
},
{
fieldtype: 'Attach',
fieldname: 'attach_serial_batch_csv',
label: __('Attach CSV File'),
onchange: () => this.upload_csv_file()
}
]
}
download_csv_file() {
let csvFileData = ['Serial No'];
if (this.item.has_serial_no && this.item.has_batch_no) {
csvFileData = ['Serial No', 'Batch No', 'Quantity'];
} else if (this.item.has_batch_no) {
csvFileData = ['Batch No', 'Quantity'];
}
const method = `/api/method/erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.download_blank_csv_template?content=${encodeURIComponent(JSON.stringify(csvFileData))}`;
const w = window.open(frappe.urllib.get_full_url(method));
if (!w) {
frappe.msgprint(__("Please enable pop-ups"));
}
}
upload_csv_file() {
const file_path = this.dialog.get_value("attach_serial_batch_csv")
frappe.call({
method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.upload_csv_file',
args: {
item_code: this.item.item_code,
file_path: file_path
},
callback: (r) => {
if (r.message.serial_nos && r.message.serial_nos.length) {
this.set_data(r.message.serial_nos);
} else if (r.message.batch_nos && r.message.batch_nos.length) {
this.set_data(r.message.batch_nos);
} }
} }
]; });
} }
};
function get_pending_qty_fields(me) { get_filter_fields() {
if (!check_can_calculate_pending_qty(me)) return []; return [
const { frm: { doc: { fg_completed_qty }}, item: { item_code, stock_qty }} = me; {
const { qty_consumed_per_unit } = erpnext.stock.bom.items[item_code]; fieldtype: 'Section Break',
label: __('Auto Fetch')
},
{
fieldtype: 'Float',
fieldname: 'qty',
label: __('Qty to Fetch'),
onchange: () => this.get_auto_data()
},
{
fieldtype: 'Column Break',
},
{
fieldtype: 'Select',
options: ['FIFO', 'LIFO', 'Expiry'],
default: 'FIFO',
fieldname: 'based_on',
label: __('Fetch Based On'),
onchange: () => this.get_auto_data()
},
{
fieldtype: 'Section Break',
},
]
const total_selected_qty = calc_total_selected_qty(me); }
const required_qty = flt(fg_completed_qty) * flt(qty_consumed_per_unit);
const pending_qty = required_qty - (flt(stock_qty) + total_selected_qty);
const pending_qty_fields = [ get_dialog_table_fields() {
{ fieldtype: 'Section Break', label: __('Pending Quantity') }, let fields = []
{
fieldname: 'required_qty',
read_only: 1,
fieldtype: 'Float',
label: __('Required Qty'),
default: required_qty
},
{ fieldtype: 'Column Break' },
{
fieldname: 'total_selected_qty',
read_only: 1,
fieldtype: 'Float',
label: __('Total Selected Qty'),
default: total_selected_qty
},
{ fieldtype: 'Column Break' },
{
fieldname: 'pending_qty',
read_only: 1,
fieldtype: 'Float',
label: __('Pending Qty'),
default: pending_qty
},
];
return pending_qty_fields;
}
// get all items with same item code except row for which selector is open. if (this.item.has_serial_no) {
function get_rows_with_same_item_code(me) { fields.push({
const { frm: { doc: { items }}, item: { name, item_code }} = me; fieldtype: 'Link',
return items.filter(item => (item.name !== name) && (item.item_code === item_code)) options: 'Serial No',
} fieldname: 'serial_no',
label: __('Serial No'),
in_list_view: 1,
get_query: () => {
return {
filters: this.get_serial_no_filters()
}
}
})
}
function calc_total_selected_qty(me) { let batch_fields = []
const totalSelectedQty = get_rows_with_same_item_code(me) if (this.item.has_batch_no) {
.map(item => flt(item.qty)) batch_fields = [
.reduce((i, j) => i + j, 0); {
return totalSelectedQty; fieldtype: 'Link',
} options: 'Batch',
fieldname: 'batch_no',
label: __('Batch No'),
in_list_view: 1,
get_query: () => {
return {
filters: {
'item': this.item.item_code
}
};
},
}
]
function get_selected_serial_nos(me) { if (!this.item.has_serial_no) {
const selected_serial_nos = get_rows_with_same_item_code(me) batch_fields.push({
.map(item => item.serial_no) fieldtype: 'Float',
.filter(serial => serial) fieldname: 'qty',
.map(sr_no_string => sr_no_string.split('\n')) label: __('Quantity'),
.reduce((acc, arr) => acc.concat(arr), []) in_list_view: 1,
.filter(serial => serial); })
return selected_serial_nos; }
}; }
function check_can_calculate_pending_qty(me) { fields = [...fields, ...batch_fields];
const { frm: { doc }, item } = me;
const docChecks = doc.bom_no
&& doc.fg_completed_qty
&& erpnext.stock.bom
&& erpnext.stock.bom.name === doc.bom_no;
const itemChecks = !!item
&& !item.original_item
&& erpnext.stock.bom && erpnext.stock.bom.items
&& (item.item_code in erpnext.stock.bom.items);
return docChecks && itemChecks;
}
//# sourceURL=serial_no_batch_selector.js fields.push({
fieldtype: 'Data',
fieldname: 'name',
label: __('Name'),
hidden: 1,
});
return fields;
}
get_auto_data() {
const { qty, based_on } = this.dialog.get_values();
if (!based_on) {
based_on = 'FIFO';
}
frappe.call({
method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_auto_data',
args: {
item_code: this.item.item_code,
warehouse: this.item.warehouse || this.item.s_warehouse,
has_serial_no: this.item.has_serial_no,
has_batch_no: this.item.has_batch_no,
qty: qty,
based_on: based_on
},
callback: (r) => {
if (r.message) {
this.dialog.fields_dict.entries.df.data = r.message;
this.dialog.fields_dict.entries.grid.refresh();
}
}
});
}
update_serial_batch_no() {
const { scan_serial_no, scan_batch_no } = this.dialog.get_values();
if (scan_serial_no) {
this.dialog.fields_dict.entries.df.data.push({
serial_no: scan_serial_no
});
this.dialog.fields_dict.scan_serial_no.set_value('');
} else if (scan_batch_no) {
this.dialog.fields_dict.entries.df.data.push({
batch_no: scan_batch_no
});
this.dialog.fields_dict.scan_batch_no.set_value('');
}
this.dialog.fields_dict.entries.grid.refresh();
}
update_ledgers() {
let entries = this.dialog.get_values().entries;
if (entries && !entries.length || !entries) {
frappe.throw(__('Please add atleast one Serial No / Batch No'));
}
frappe.call({
method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.add_serial_batch_ledgers',
args: {
entries: entries,
child_row: this.item,
doc: this.frm.doc,
}
}).then(r => {
this.callback && this.callback(r.message);
this.frm.save();
this.dialog.hide();
})
}
edit_full_form() {
let bundle_id = this.item.serial_and_batch_bundle
if (!bundle_id) {
_new = frappe.model.get_new_doc(
"Serial and Batch Bundle", null, null, true
);
_new.item_code = this.item.item_code;
_new.warehouse = this.get_warehouse();
_new.has_serial_no = this.item.has_serial_no;
_new.has_batch_no = this.item.has_batch_no;
_new.type_of_transaction = this.get_type_of_transaction();
_new.company = this.frm.doc.company;
_new.voucher_type = this.frm.doc.doctype;
bundle_id = _new.name;
}
frappe.set_route("Form", "Serial and Batch Bundle", bundle_id);
this.dialog.hide();
}
get_warehouse() {
return (this.item?.outward ?
(this.item.warehouse || this.item.s_warehouse)
: (this.item.warehouse || this.item.t_warehouse));
}
get_type_of_transaction() {
return (this.item?.outward ? 'Outward' : 'Inward');
}
render_data() {
if (!this.frm.is_new() && this.bundle) {
frappe.call({
method: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.get_serial_batch_ledgers',
args: {
item_code: this.item.item_code,
name: this.bundle,
voucher_no: this.item.parent,
}
}).then(r => {
if (r.message) {
this.set_data(r.message);
}
})
}
}
set_data(data) {
data.forEach(d => {
this.dialog.fields_dict.entries.df.data.push(d);
});
this.dialog.fields_dict.entries.grid.refresh();
}
}

View File

@ -1,260 +1,126 @@
{ {
"allow_copy": 0, "actions": [],
"allow_import": 0, "autoname": "hash",
"allow_rename": 0, "creation": "2013-02-22 01:27:51",
"autoname": "hash", "doctype": "DocType",
"beta": 0, "editable_grid": 1,
"creation": "2013-02-22 01:27:51", "engine": "InnoDB",
"custom": 0, "field_order": [
"docstatus": 0, "item_code",
"doctype": "DocType", "serial_and_batch_bundle",
"editable_grid": 1, "serial_no",
"engine": "InnoDB", "qty",
"description",
"prevdoc_detail_docname",
"prevdoc_docname",
"prevdoc_doctype"
],
"fields": [ "fields": [
{ {
"allow_on_submit": 0, "fieldname": "item_code",
"bold": 0, "fieldtype": "Link",
"collapsible": 0, "in_global_search": 1,
"columns": 0, "in_list_view": 1,
"fieldname": "item_code", "label": "Item Code",
"fieldtype": "Link", "oldfieldname": "item_code",
"hidden": 0, "oldfieldtype": "Link",
"ignore_user_permissions": 0, "options": "Item",
"ignore_xss_filter": 0, "reqd": 1
"in_filter": 0, },
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Item Code",
"length": 0,
"no_copy": 0,
"oldfieldname": "item_code",
"oldfieldtype": "Link",
"options": "Item",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "serial_no",
"bold": 0, "fieldtype": "Small Text",
"collapsible": 0, "label": "Serial No",
"columns": 0, "no_copy": 1,
"fieldname": "serial_no", "oldfieldname": "serial_no",
"fieldtype": "Small Text", "oldfieldtype": "Small Text",
"hidden": 0, "print_hide": 1,
"ignore_user_permissions": 0, "print_width": "180px",
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Serial No",
"length": 0,
"no_copy": 0,
"oldfieldname": "serial_no",
"oldfieldtype": "Small Text",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "180px",
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"width": "180px" "width": "180px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "qty",
"bold": 0, "fieldtype": "Float",
"collapsible": 0, "in_list_view": 1,
"columns": 0, "label": "Installed Qty",
"fieldname": "qty", "oldfieldname": "qty",
"fieldtype": "Float", "oldfieldtype": "Currency",
"hidden": 0, "reqd": 1
"ignore_user_permissions": 0, },
"ignore_xss_filter": 0,
"in_filter": 0,
"in_global_search": 0,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Installed Qty",
"length": 0,
"no_copy": 0,
"oldfieldname": "qty",
"oldfieldtype": "Currency",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"read_only": 0,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 1,
"search_index": 0,
"set_only_once": 0,
"unique": 0
},
{ {
"allow_on_submit": 0, "fieldname": "description",
"bold": 0, "fieldtype": "Text Editor",
"collapsible": 0, "in_global_search": 1,
"columns": 0, "in_list_view": 1,
"fieldname": "description", "label": "Description",
"fieldtype": "Text Editor", "oldfieldname": "description",
"hidden": 0, "oldfieldtype": "Data",
"ignore_user_permissions": 0, "print_width": "300px",
"ignore_xss_filter": 0, "read_only": 1,
"in_filter": 0,
"in_global_search": 1,
"in_list_view": 1,
"in_standard_filter": 0,
"label": "Description",
"length": 0,
"no_copy": 0,
"oldfieldname": "description",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 0,
"print_hide_if_no_value": 0,
"print_width": "300px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"width": "300px" "width": "300px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "prevdoc_detail_docname",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "hidden": 1,
"columns": 0, "label": "Against Document Detail No",
"fieldname": "prevdoc_detail_docname", "no_copy": 1,
"fieldtype": "Data", "oldfieldname": "prevdoc_detail_docname",
"hidden": 1, "oldfieldtype": "Data",
"ignore_user_permissions": 0, "print_hide": 1,
"ignore_xss_filter": 0, "print_width": "150px",
"in_filter": 0, "read_only": 1,
"in_global_search": 0,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Against Document Detail No",
"length": 0,
"no_copy": 1,
"oldfieldname": "prevdoc_detail_docname",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": "150px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 0,
"set_only_once": 0,
"unique": 0,
"width": "150px" "width": "150px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "prevdoc_docname",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "hidden": 1,
"columns": 0, "label": "Against Document No",
"fieldname": "prevdoc_docname", "no_copy": 1,
"fieldtype": "Data", "oldfieldname": "prevdoc_docname",
"hidden": 1, "oldfieldtype": "Data",
"ignore_user_permissions": 0, "print_hide": 1,
"ignore_xss_filter": 0, "print_width": "150px",
"in_filter": 0, "read_only": 1,
"in_global_search": 0, "search_index": 1,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Against Document No",
"length": 0,
"no_copy": 1,
"oldfieldname": "prevdoc_docname",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": "150px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 1,
"set_only_once": 0,
"unique": 0,
"width": "150px" "width": "150px"
}, },
{ {
"allow_on_submit": 0, "fieldname": "prevdoc_doctype",
"bold": 0, "fieldtype": "Data",
"collapsible": 0, "hidden": 1,
"columns": 0, "label": "Document Type",
"fieldname": "prevdoc_doctype", "no_copy": 1,
"fieldtype": "Data", "oldfieldname": "prevdoc_doctype",
"hidden": 1, "oldfieldtype": "Data",
"ignore_user_permissions": 0, "print_hide": 1,
"ignore_xss_filter": 0, "print_width": "150px",
"in_filter": 0, "read_only": 1,
"in_global_search": 0, "search_index": 1,
"in_list_view": 0,
"in_standard_filter": 0,
"label": "Document Type",
"length": 0,
"no_copy": 1,
"oldfieldname": "prevdoc_doctype",
"oldfieldtype": "Data",
"permlevel": 0,
"print_hide": 1,
"print_hide_if_no_value": 0,
"print_width": "150px",
"read_only": 1,
"remember_last_selected_value": 0,
"report_hide": 0,
"reqd": 0,
"search_index": 1,
"set_only_once": 0,
"unique": 0,
"width": "150px" "width": "150px"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"hide_heading": 0, "idx": 1,
"hide_toolbar": 0, "istable": 1,
"idx": 1, "links": [],
"image_view": 0, "modified": "2023-03-12 13:47:08.257955",
"in_create": 0, "modified_by": "Administrator",
"module": "Selling",
"is_submittable": 0, "name": "Installation Note Item",
"issingle": 0, "naming_rule": "Random",
"istable": 1, "owner": "Administrator",
"max_attachments": 0, "permissions": [],
"menu_index": 0, "sort_field": "modified",
"modified": "2017-02-20 13:24:18.142419", "sort_order": "ASC",
"modified_by": "Administrator", "states": [],
"module": "Selling", "track_changes": 1
"name": "Installation Note Item",
"owner": "Administrator",
"permissions": [],
"quick_entry": 0,
"read_only": 0,
"read_only_onload": 0,
"show_name_in_global_search": 0,
"sort_order": "ASC",
"track_changes": 1,
"track_seen": 0
} }

View File

@ -1254,112 +1254,6 @@ class TestSalesOrder(FrappeTestCase):
) )
self.assertEqual(wo_qty[0][0], so_item_name.get(item)) self.assertEqual(wo_qty[0][0], so_item_name.get(item))
def test_serial_no_based_delivery(self):
frappe.set_value("Stock Settings", None, "automatically_set_serial_nos_based_on_fifo", 1)
item = make_item(
"_Reserved_Serialized_Item",
{
"is_stock_item": 1,
"maintain_stock": 1,
"has_serial_no": 1,
"serial_no_series": "SI.####",
"valuation_rate": 500,
"item_defaults": [{"default_warehouse": "_Test Warehouse - _TC", "company": "_Test Company"}],
},
)
frappe.db.sql("""delete from `tabSerial No` where item_code=%s""", (item.item_code))
make_item(
"_Test Item A",
{
"maintain_stock": 1,
"valuation_rate": 100,
"item_defaults": [{"default_warehouse": "_Test Warehouse - _TC", "company": "_Test Company"}],
},
)
make_item(
"_Test Item B",
{
"maintain_stock": 1,
"valuation_rate": 200,
"item_defaults": [{"default_warehouse": "_Test Warehouse - _TC", "company": "_Test Company"}],
},
)
from erpnext.manufacturing.doctype.production_plan.test_production_plan import make_bom
make_bom(item=item.item_code, rate=1000, raw_materials=["_Test Item A", "_Test Item B"])
so = make_sales_order(
**{
"item_list": [
{
"item_code": item.item_code,
"ensure_delivery_based_on_produced_serial_no": 1,
"qty": 1,
"rate": 1000,
}
]
}
)
so.submit()
from erpnext.manufacturing.doctype.work_order.test_work_order import make_wo_order_test_record
work_order = make_wo_order_test_record(item=item.item_code, qty=1, do_not_save=True)
work_order.fg_warehouse = "_Test Warehouse - _TC"
work_order.sales_order = so.name
work_order.submit()
make_stock_entry(item_code=item.item_code, target="_Test Warehouse - _TC", qty=1)
item_serial_no = frappe.get_doc("Serial No", {"item_code": item.item_code})
from erpnext.manufacturing.doctype.work_order.work_order import (
make_stock_entry as make_production_stock_entry,
)
se = frappe.get_doc(make_production_stock_entry(work_order.name, "Manufacture", 1))
se.submit()
reserved_serial_no = se.get("items")[2].serial_no
serial_no_so = frappe.get_value("Serial No", reserved_serial_no, "sales_order")
self.assertEqual(serial_no_so, so.name)
dn = make_delivery_note(so.name)
dn.save()
self.assertEqual(reserved_serial_no, dn.get("items")[0].serial_no)
item_line = dn.get("items")[0]
item_line.serial_no = item_serial_no.name
item_line = dn.get("items")[0]
item_line.serial_no = reserved_serial_no
dn.submit()
dn.load_from_db()
dn.cancel()
si = make_sales_invoice(so.name)
si.update_stock = 1
si.save()
self.assertEqual(si.get("items")[0].serial_no, reserved_serial_no)
item_line = si.get("items")[0]
item_line.serial_no = item_serial_no.name
self.assertRaises(frappe.ValidationError, dn.submit)
item_line = si.get("items")[0]
item_line.serial_no = reserved_serial_no
self.assertTrue(si.submit)
si.submit()
si.load_from_db()
si.cancel()
si = make_sales_invoice(so.name)
si.update_stock = 0
si.submit()
from erpnext.accounts.doctype.sales_invoice.sales_invoice import (
make_delivery_note as make_delivery_note_from_invoice,
)
dn = make_delivery_note_from_invoice(si.name)
dn.save()
dn.submit()
self.assertEqual(dn.get("items")[0].serial_no, reserved_serial_no)
dn.load_from_db()
dn.cancel()
si.load_from_db()
si.cancel()
se.load_from_db()
se.cancel()
self.assertFalse(frappe.db.exists("Serial No", {"sales_order": so.name}))
def test_advance_payment_entry_unlink_against_sales_order(self): def test_advance_payment_entry_unlink_against_sales_order(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry from erpnext.accounts.doctype.payment_entry.test_payment_entry import get_payment_entry

View File

@ -44,7 +44,8 @@ erpnext.PointOfSale.ItemDetails = class {
<div class="item-image"></div> <div class="item-image"></div>
</div> </div>
<div class="discount-section"></div> <div class="discount-section"></div>
<div class="form-container"></div>` <div class="form-container"></div>
<div class="serial-batch-container"></div>`
) )
this.$item_name = this.$component.find('.item-name'); this.$item_name = this.$component.find('.item-name');
@ -53,6 +54,7 @@ erpnext.PointOfSale.ItemDetails = class {
this.$item_image = this.$component.find('.item-image'); this.$item_image = this.$component.find('.item-image');
this.$form_container = this.$component.find('.form-container'); this.$form_container = this.$component.find('.form-container');
this.$dicount_section = this.$component.find('.discount-section'); this.$dicount_section = this.$component.find('.discount-section');
this.$serial_batch_container = this.$component.find('.serial-batch-container');
} }
compare_with_current_item(item) { compare_with_current_item(item) {
@ -101,12 +103,9 @@ erpnext.PointOfSale.ItemDetails = class {
const serialized = item_row.has_serial_no; const serialized = item_row.has_serial_no;
const batched = item_row.has_batch_no; const batched = item_row.has_batch_no;
const no_serial_selected = !item_row.serial_no; const no_bundle_selected = !item_row.serial_and_batch_bundle;
const no_batch_selected = !item_row.batch_no;
if ((serialized && no_serial_selected) || (batched && no_batch_selected) ||
(serialized && batched && (no_batch_selected || no_serial_selected))) {
if ((serialized && no_bundle_selected) || (batched && no_bundle_selected)) {
frappe.show_alert({ frappe.show_alert({
message: __("Item is removed since no serial / batch no selected."), message: __("Item is removed since no serial / batch no selected."),
indicator: 'orange' indicator: 'orange'
@ -200,13 +199,8 @@ erpnext.PointOfSale.ItemDetails = class {
} }
make_auto_serial_selection_btn(item) { make_auto_serial_selection_btn(item) {
if (item.has_serial_no) { if (item.has_serial_no || item.has_batch_no) {
if (!item.has_batch_no) { const label = item.has_serial_no ? __('Select Serial No') : __('Select Batch No');
this.$form_container.append(
`<div class="grid-filler no-select"></div>`
);
}
const label = __('Auto Fetch Serial Numbers');
this.$form_container.append( this.$form_container.append(
`<div class="btn btn-sm btn-secondary auto-fetch-btn">${label}</div>` `<div class="btn btn-sm btn-secondary auto-fetch-btn">${label}</div>`
); );
@ -382,40 +376,20 @@ erpnext.PointOfSale.ItemDetails = class {
bind_auto_serial_fetch_event() { bind_auto_serial_fetch_event() {
this.$form_container.on('click', '.auto-fetch-btn', () => { this.$form_container.on('click', '.auto-fetch-btn', () => {
this.batch_no_control && this.batch_no_control.set_value(''); frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", () => {
let qty = this.qty_control.get_value(); let frm = this.events.get_frm();
let conversion_factor = this.conversion_factor_control.get_value(); let item_row = this.item_row;
let expiry_date = this.item_row.has_batch_no ? this.events.get_frm().doc.posting_date : ""; item_row.outward = 1;
item_row.type_of_transaction = "Outward";
let numbers = frappe.call({ new erpnext.SerialBatchPackageSelector(frm, item_row, (r) => {
method: "erpnext.stock.doctype.serial_no.serial_no.auto_fetch_serial_number", if (r) {
args: { frappe.model.set_value(item_row.doctype, item_row.name, {
qty: qty * conversion_factor, "serial_and_batch_bundle": r.name,
item_code: this.current_item.item_code, "qty": Math.abs(r.total_qty)
warehouse: this.warehouse_control.get_value() || '', });
batch_nos: this.current_item.batch_no || '', }
posting_date: expiry_date, });
for_doctype: 'POS Invoice'
}
});
numbers.then((data) => {
let auto_fetched_serial_numbers = data.message;
let records_length = auto_fetched_serial_numbers.length;
if (!records_length) {
const warehouse = this.warehouse_control.get_value().bold();
const item_code = this.current_item.item_code.bold();
frappe.msgprint(
__('Serial numbers unavailable for Item {0} under warehouse {1}. Please try changing warehouse.', [item_code, warehouse])
);
} else if (records_length < qty) {
frappe.msgprint(
__('Fetched only {0} available serial numbers.', [records_length])
);
this.qty_control.set_value(records_length);
}
numbers = auto_fetched_serial_numbers.join(`\n`);
this.serial_no_control.set_value(numbers);
}); });
}) })
} }

View File

@ -196,48 +196,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran
refresh_field("incentives",row.name,row.parentfield); refresh_field("incentives",row.name,row.parentfield);
} }
warehouse(doc, cdt, cdn) {
var me = this;
var item = frappe.get_doc(cdt, cdn);
// check if serial nos entered are as much as qty in row
if (item.serial_no) {
let serial_nos = item.serial_no.split(`\n`).filter(sn => sn.trim()); // filter out whitespaces
if (item.qty === serial_nos.length) return;
}
if (item.serial_no && !item.batch_no) {
item.serial_no = null;
}
var has_batch_no;
frappe.db.get_value('Item', {'item_code': item.item_code}, 'has_batch_no', (r) => {
has_batch_no = r && r.has_batch_no;
if(item.item_code && item.warehouse) {
return this.frm.call({
method: "erpnext.stock.get_item_details.get_bin_details_and_serial_nos",
child: item,
args: {
item_code: item.item_code,
warehouse: item.warehouse,
has_batch_no: has_batch_no || 0,
stock_qty: item.stock_qty,
serial_no: item.serial_no || "",
},
callback:function(r){
if (in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
if (has_batch_no) {
me.set_batch_number(cdt, cdn);
me.batch_no(doc, cdt, cdn);
}
}
}
});
}
})
}
toggle_editable_price_list_rate() { toggle_editable_price_list_rate() {
var df = frappe.meta.get_docfield(this.frm.doc.doctype + " Item", "price_list_rate", this.frm.doc.name); var df = frappe.meta.get_docfield(this.frm.doc.doctype + " Item", "price_list_rate", this.frm.doc.name);
var editable_price_list_rate = cint(frappe.defaults.get_default("editable_price_list_rate")); var editable_price_list_rate = cint(frappe.defaults.get_default("editable_price_list_rate"));
@ -298,36 +256,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran
} }
} }
batch_no(doc, cdt, cdn) {
super.batch_no(doc, cdt, cdn);
var item = frappe.get_doc(cdt, cdn);
if (item.serial_no) {
return;
}
item.serial_no = null;
var has_serial_no;
frappe.db.get_value('Item', {'item_code': item.item_code}, 'has_serial_no', (r) => {
has_serial_no = r && r.has_serial_no;
if(item.warehouse && item.item_code && item.batch_no) {
return this.frm.call({
method: "erpnext.stock.get_item_details.get_batch_qty_and_serial_no",
child: item,
args: {
"batch_no": item.batch_no,
"stock_qty": item.stock_qty || item.qty, //if stock_qty field is not available fetch qty (in case of Packed Items table)
"warehouse": item.warehouse,
"item_code": item.item_code,
"has_serial_no": has_serial_no
},
"fieldname": "actual_batch_qty"
});
}
})
}
set_dynamic_labels() { set_dynamic_labels() {
super.set_dynamic_labels(); super.set_dynamic_labels();
this.set_product_bundle_help(this.frm.doc); this.set_product_bundle_help(this.frm.doc);
@ -372,52 +300,46 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran
conversion_factor(doc, cdt, cdn, dont_fetch_price_list_rate) { conversion_factor(doc, cdt, cdn, dont_fetch_price_list_rate) {
super.conversion_factor(doc, cdt, cdn, dont_fetch_price_list_rate); super.conversion_factor(doc, cdt, cdn, dont_fetch_price_list_rate);
if(frappe.meta.get_docfield(cdt, "stock_qty", cdn) &&
in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
this.set_batch_number(cdt, cdn);
}
} }
qty(doc, cdt, cdn) { qty(doc, cdt, cdn) {
super.qty(doc, cdt, cdn); super.qty(doc, cdt, cdn);
if(in_list(['Delivery Note', 'Sales Invoice'], doc.doctype)) {
if (doc.doctype === 'Sales Invoice' && (!doc.update_stock)) return;
this.set_batch_number(cdt, cdn);
}
} }
/* Determine appropriate batch number and set it in the form. pick_serial_and_batch(doc, cdt, cdn) {
* @param {string} cdt - Document Doctype let item = locals[cdt][cdn];
* @param {string} cdn - Document name let me = this;
*/ let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
set_batch_number(cdt, cdn) {
const doc = frappe.get_doc(cdt, cdn);
if (doc && doc.has_batch_no && doc.warehouse) {
this._set_batch_number(doc);
}
}
_set_batch_number(doc) { frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
if (doc.batch_no) { .then((r) => {
return if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
} item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.type_of_transaction = item.qty > 0 ? "Outward":"Inward";
item.outward = item.qty > 0 ? 1 : 0;
let args = {'item_code': doc.item_code, 'warehouse': doc.warehouse, 'qty': flt(doc.qty) * flt(doc.conversion_factor)}; item.title = item.has_serial_no ?
if (doc.has_serial_no && doc.serial_no) { __("Select Serial No") : __("Select Batch No");
args['serial_no'] = doc.serial_no
}
return frappe.call({ if (item.has_serial_no && item.has_batch_no) {
method: 'erpnext.stock.doctype.batch.batch.get_batch_no', item.title = __("Select Serial and Batch");
args: args, }
callback: function(r) {
if(r.message) { frappe.require(path, function() {
frappe.model.set_value(doc.doctype, doc.name, 'batch_no', r.message); new erpnext.SerialBatchPackageSelector(
me.frm, item, (r) => {
if (r) {
frappe.model.set_value(item.doctype, item.name, {
"serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
});
}
}
);
});
} }
} });
});
} }
update_auto_repeat_reference(doc) { update_auto_repeat_reference(doc) {

View File

@ -36,7 +36,6 @@ def set_default_settings(args):
stock_settings.stock_uom = _("Nos") stock_settings.stock_uom = _("Nos")
stock_settings.auto_indent = 1 stock_settings.auto_indent = 1
stock_settings.auto_insert_price_list_rate_if_missing = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1
stock_settings.automatically_set_serial_nos_based_on_fifo = 1
stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1
stock_settings.save() stock_settings.save()

View File

@ -486,7 +486,6 @@ def update_stock_settings():
stock_settings.stock_uom = _("Nos") stock_settings.stock_uom = _("Nos")
stock_settings.auto_indent = 1 stock_settings.auto_indent = 1
stock_settings.auto_insert_price_list_rate_if_missing = 1 stock_settings.auto_insert_price_list_rate_if_missing = 1
stock_settings.automatically_set_serial_nos_based_on_fifo = 1
stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1 stock_settings.set_qty_in_transactions_based_on_serial_no_input = 1
stock_settings.save() stock_settings.save()

View File

@ -0,0 +1,237 @@
import frappe
from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import flt
from frappe.utils.deprecations import deprecated
from pypika import Order
class DeprecatedSerialNoValuation:
@deprecated
def calculate_stock_value_from_deprecarated_ledgers(self):
serial_nos = list(
filter(lambda x: x not in self.serial_no_incoming_rate and x, self.get_serial_nos())
)
actual_qty = flt(self.sle.actual_qty)
stock_value_change = 0
if actual_qty < 0:
if not self.sle.is_cancelled:
outgoing_value = self.get_incoming_value_for_serial_nos(serial_nos)
stock_value_change = -1 * outgoing_value
self.stock_value_change += stock_value_change
@deprecated
def get_incoming_value_for_serial_nos(self, serial_nos):
# get rate from serial nos within same company
all_serial_nos = frappe.get_all(
"Serial No", fields=["purchase_rate", "name", "company"], filters={"name": ("in", serial_nos)}
)
incoming_values = 0.0
for d in all_serial_nos:
if d.company == self.sle.company:
self.serial_no_incoming_rate[d.name] += flt(d.purchase_rate)
incoming_values += flt(d.purchase_rate)
# Get rate for serial nos which has been transferred to other company
invalid_serial_nos = [d.name for d in all_serial_nos if d.company != self.sle.company]
for serial_no in invalid_serial_nos:
table = frappe.qb.DocType("Stock Ledger Entry")
incoming_rate = (
frappe.qb.from_(table)
.select(table.incoming_rate)
.where(
(
(table.serial_no == serial_no)
| (table.serial_no.like(serial_no + "\n%"))
| (table.serial_no.like("%\n" + serial_no))
| (table.serial_no.like("%\n" + serial_no + "\n%"))
)
& (table.company == self.sle.company)
& (table.serial_and_batch_bundle.isnull())
& (table.actual_qty > 0)
& (table.is_cancelled == 0)
)
.orderby(table.posting_date, order=Order.desc)
.limit(1)
).run()
self.serial_no_incoming_rate[serial_no] += flt(incoming_rate[0][0]) if incoming_rate else 0
incoming_values += self.serial_no_incoming_rate[serial_no]
return incoming_values
class DeprecatedBatchNoValuation:
@deprecated
def calculate_avg_rate_from_deprecarated_ledgers(self):
entries = self.get_sle_for_batches()
for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.batch_value)
self.available_qty[ledger.batch_no] += flt(ledger.batch_qty)
@deprecated
def get_sle_for_batches(self):
if not self.batchwise_valuation_batches:
return []
sle = frappe.qb.DocType("Stock Ledger Entry")
timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime(
self.sle.posting_date, self.sle.posting_time
)
if self.sle.creation:
timestamp_condition |= (
CombineDatetime(sle.posting_date, sle.posting_time)
== CombineDatetime(self.sle.posting_date, self.sle.posting_time)
) & (sle.creation < self.sle.creation)
query = (
frappe.qb.from_(sle)
.select(
sle.batch_no,
Sum(sle.stock_value_difference).as_("batch_value"),
Sum(sle.actual_qty).as_("batch_qty"),
)
.where(
(sle.item_code == self.sle.item_code)
& (sle.warehouse == self.sle.warehouse)
& (sle.batch_no.isin(self.batchwise_valuation_batches))
& (sle.batch_no.isnotnull())
& (sle.is_cancelled == 0)
)
.where(timestamp_condition)
.groupby(sle.batch_no)
)
if self.sle.name:
query = query.where(sle.name != self.sle.name)
return query.run(as_dict=True)
@deprecated
def calculate_avg_rate_for_non_batchwise_valuation(self):
if not self.non_batchwise_valuation_batches:
return
self.non_batchwise_balance_value = 0.0
self.non_batchwise_balance_qty = 0.0
self.set_balance_value_for_non_batchwise_valuation_batches()
for batch_no, ledger in self.batch_nos.items():
if batch_no not in self.non_batchwise_valuation_batches:
continue
self.batch_avg_rate[batch_no] = (
self.non_batchwise_balance_value / self.non_batchwise_balance_qty
)
stock_value_change = self.batch_avg_rate[batch_no] * ledger.qty
self.stock_value_change += stock_value_change
frappe.db.set_value(
"Serial and Batch Entry",
ledger.name,
{
"stock_value_difference": stock_value_change,
"incoming_rate": self.batch_avg_rate[batch_no],
},
)
@deprecated
def set_balance_value_for_non_batchwise_valuation_batches(self):
self.set_balance_value_from_sl_entries()
self.set_balance_value_from_bundle()
@deprecated
def set_balance_value_from_sl_entries(self) -> None:
sle = frappe.qb.DocType("Stock Ledger Entry")
batch = frappe.qb.DocType("Batch")
timestamp_condition = CombineDatetime(sle.posting_date, sle.posting_time) < CombineDatetime(
self.sle.posting_date, self.sle.posting_time
)
if self.sle.creation:
timestamp_condition |= (
CombineDatetime(sle.posting_date, sle.posting_time)
== CombineDatetime(self.sle.posting_date, self.sle.posting_time)
) & (sle.creation < self.sle.creation)
query = (
frappe.qb.from_(sle)
.inner_join(batch)
.on(sle.batch_no == batch.name)
.select(
sle.batch_no,
Sum(sle.actual_qty).as_("batch_qty"),
Sum(sle.stock_value_difference).as_("batch_value"),
)
.where(
(sle.item_code == self.sle.item_code)
& (sle.warehouse == self.sle.warehouse)
& (sle.batch_no.isnotnull())
& (batch.use_batchwise_valuation == 0)
& (sle.is_cancelled == 0)
)
.where(timestamp_condition)
.groupby(sle.batch_no)
)
if self.sle.name:
query = query.where(sle.name != self.sle.name)
for d in query.run(as_dict=True):
self.non_batchwise_balance_value += flt(d.batch_value)
self.non_batchwise_balance_qty += flt(d.batch_qty)
self.available_qty[d.batch_no] += flt(d.batch_qty)
@deprecated
def set_balance_value_from_bundle(self) -> None:
bundle = frappe.qb.DocType("Serial and Batch Bundle")
bundle_child = frappe.qb.DocType("Serial and Batch Entry")
batch = frappe.qb.DocType("Batch")
timestamp_condition = CombineDatetime(
bundle.posting_date, bundle.posting_time
) < CombineDatetime(self.sle.posting_date, self.sle.posting_time)
if self.sle.creation:
timestamp_condition |= (
CombineDatetime(bundle.posting_date, bundle.posting_time)
== CombineDatetime(self.sle.posting_date, self.sle.posting_time)
) & (bundle.creation < self.sle.creation)
query = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)
.on(bundle.name == bundle_child.parent)
.inner_join(batch)
.on(bundle_child.batch_no == batch.name)
.select(
bundle_child.batch_no,
Sum(bundle_child.qty).as_("batch_qty"),
Sum(bundle_child.stock_value_difference).as_("batch_value"),
)
.where(
(bundle.item_code == self.sle.item_code)
& (bundle.warehouse == self.sle.warehouse)
& (bundle_child.batch_no.isnotnull())
& (batch.use_batchwise_valuation == 0)
& (bundle.is_cancelled == 0)
& (bundle.docstatus == 1)
& (bundle.type_of_transaction.isin(["Inward", "Outward"]))
)
.where(timestamp_condition)
.groupby(bundle_child.batch_no)
)
if self.sle.serial_and_batch_bundle:
query = query.where(bundle.name != self.sle.serial_and_batch_bundle)
for d in query.run(as_dict=True):
self.non_batchwise_balance_value += flt(d.batch_value)
self.non_batchwise_balance_qty += flt(d.batch_qty)
self.available_qty[d.batch_no] += flt(d.batch_qty)

View File

@ -47,6 +47,8 @@ frappe.ui.form.on('Batch', {
return; return;
} }
debugger
const section = frm.dashboard.add_section('', __("Stock Levels")); const section = frm.dashboard.add_section('', __("Stock Levels"));
// sort by qty // sort by qty

View File

@ -207,7 +207,7 @@
"image_field": "image", "image_field": "image",
"links": [], "links": [],
"max_attachments": 5, "max_attachments": 5,
"modified": "2022-02-21 08:08:23.999236", "modified": "2023-03-12 15:56:09.516586",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Batch", "name": "Batch",

View File

@ -2,12 +2,14 @@
# License: GNU General Public License v3. See license.txt # License: GNU General Public License v3. See license.txt
from collections import defaultdict
import frappe import frappe
from frappe import _ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.model.naming import make_autoname, revert_series_if_last from frappe.model.naming import make_autoname, revert_series_if_last
from frappe.query_builder.functions import CombineDatetime, CurDate, Sum from frappe.query_builder.functions import CurDate, Sum
from frappe.utils import cint, flt, get_link_to_form, nowtime from frappe.utils import cint, flt, get_link_to_form, nowtime, today
from frappe.utils.data import add_days from frappe.utils.data import add_days
from frappe.utils.jinja import render_template from frappe.utils.jinja import render_template
@ -128,9 +130,7 @@ class Batch(Document):
frappe.throw(_("The selected item cannot have Batch")) frappe.throw(_("The selected item cannot have Batch"))
def set_batchwise_valuation(self): def set_batchwise_valuation(self):
from erpnext.stock.stock_ledger import get_valuation_method if self.is_new():
if self.is_new() and get_valuation_method(self.item) != "Moving Average":
self.use_batchwise_valuation = 1 self.use_batchwise_valuation = 1
def before_save(self): def before_save(self):
@ -166,7 +166,12 @@ class Batch(Document):
@frappe.whitelist() @frappe.whitelist()
def get_batch_qty( def get_batch_qty(
batch_no=None, warehouse=None, item_code=None, posting_date=None, posting_time=None batch_no=None,
warehouse=None,
item_code=None,
posting_date=None,
posting_time=None,
ignore_voucher_nos=None,
): ):
"""Returns batch actual qty if warehouse is passed, """Returns batch actual qty if warehouse is passed,
or returns dict of qty by warehouse if warehouse is None or returns dict of qty by warehouse if warehouse is None
@ -177,44 +182,31 @@ def get_batch_qty(
:param warehouse: Optional - give qty for this warehouse :param warehouse: Optional - give qty for this warehouse
:param item_code: Optional - give qty for this item""" :param item_code: Optional - give qty for this item"""
sle = frappe.qb.DocType("Stock Ledger Entry") from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
out = 0 batchwise_qty = defaultdict(float)
if batch_no and warehouse: kwargs = frappe._dict(
query = ( {
frappe.qb.from_(sle) "item_code": item_code,
.select(Sum(sle.actual_qty)) "warehouse": warehouse,
.where((sle.is_cancelled == 0) & (sle.warehouse == warehouse) & (sle.batch_no == batch_no)) "posting_date": posting_date,
) "posting_time": posting_time,
"batch_no": batch_no,
"ignore_voucher_nos": ignore_voucher_nos,
}
)
if posting_date: batches = get_auto_batch_nos(kwargs)
if posting_time is None:
posting_time = nowtime()
query = query.where( if not (batch_no and warehouse):
CombineDatetime(sle.posting_date, sle.posting_time) return batches
<= CombineDatetime(posting_date, posting_time)
)
out = query.run(as_list=True)[0][0] or 0 for batch in batches:
batchwise_qty[batch.get("batch_no")] += batch.get("qty")
if batch_no and not warehouse: return batchwise_qty[batch_no]
out = (
frappe.qb.from_(sle)
.select(sle.warehouse, Sum(sle.actual_qty).as_("qty"))
.where((sle.is_cancelled == 0) & (sle.batch_no == batch_no))
.groupby(sle.warehouse)
).run(as_dict=True)
if not batch_no and item_code and warehouse:
out = (
frappe.qb.from_(sle)
.select(sle.batch_no, Sum(sle.actual_qty).as_("qty"))
.where((sle.is_cancelled == 0) & (sle.item_code == item_code) & (sle.warehouse == warehouse))
.groupby(sle.batch_no)
).run(as_dict=True)
return out
@frappe.whitelist() @frappe.whitelist()
@ -230,13 +222,37 @@ def get_batches_by_oldest(item_code, warehouse):
@frappe.whitelist() @frappe.whitelist()
def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None): def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None):
"""Split the batch into a new batch""" """Split the batch into a new batch"""
batch = frappe.get_doc(dict(doctype="Batch", item=item_code, batch_id=new_batch_id)).insert() batch = frappe.get_doc(dict(doctype="Batch", item=item_code, batch_id=new_batch_id)).insert()
qty = flt(qty)
company = frappe.db.get_value( company = frappe.db.get_value("Warehouse", warehouse, "company")
"Stock Ledger Entry",
dict(item_code=item_code, batch_no=batch_no, warehouse=warehouse), from_bundle_id = make_batch_bundle(
["company"], frappe._dict(
{
"item_code": item_code,
"warehouse": warehouse,
"batches": frappe._dict({batch_no: qty}),
"company": company,
"type_of_transaction": "Outward",
"qty": qty,
}
)
)
to_bundle_id = make_batch_bundle(
frappe._dict(
{
"item_code": item_code,
"warehouse": warehouse,
"batches": frappe._dict({batch.name: qty}),
"company": company,
"type_of_transaction": "Inward",
"qty": qty,
}
)
) )
stock_entry = frappe.get_doc( stock_entry = frappe.get_doc(
@ -245,8 +261,12 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None):
purpose="Repack", purpose="Repack",
company=company, company=company,
items=[ items=[
dict(item_code=item_code, qty=float(qty or 0), s_warehouse=warehouse, batch_no=batch_no), dict(
dict(item_code=item_code, qty=float(qty or 0), t_warehouse=warehouse, batch_no=batch.name), item_code=item_code, qty=qty, s_warehouse=warehouse, serial_and_batch_bundle=from_bundle_id
),
dict(
item_code=item_code, qty=qty, t_warehouse=warehouse, serial_and_batch_bundle=to_bundle_id
),
], ],
) )
) )
@ -257,52 +277,27 @@ def split_batch(batch_no, item_code, warehouse, qty, new_batch_id=None):
return batch.name return batch.name
def set_batch_nos(doc, warehouse_field, throw=False, child_table="items"): def make_batch_bundle(kwargs):
"""Automatically select `batch_no` for outgoing items in item table""" from erpnext.stock.serial_batch_bundle import SerialBatchCreation
for d in doc.get(child_table):
qty = d.get("stock_qty") or d.get("transfer_qty") or d.get("qty") or 0
warehouse = d.get(warehouse_field, None)
if warehouse and qty > 0 and frappe.db.get_value("Item", d.item_code, "has_batch_no"):
if not d.batch_no:
d.batch_no = get_batch_no(d.item_code, warehouse, qty, throw, d.serial_no)
else:
batch_qty = get_batch_qty(batch_no=d.batch_no, warehouse=warehouse)
if flt(batch_qty, d.precision("qty")) < flt(qty, d.precision("qty")):
frappe.throw(
_(
"Row #{0}: The batch {1} has only {2} qty. Please select another batch which has {3} qty available or split the row into multiple rows, to deliver/issue from multiple batches"
).format(d.idx, d.batch_no, batch_qty, qty)
)
return (
@frappe.whitelist() SerialBatchCreation(
def get_batch_no(item_code, warehouse, qty=1, throw=False, serial_no=None): {
""" "item_code": kwargs.item_code,
Get batch number using First Expiring First Out method. "warehouse": kwargs.warehouse,
:param item_code: `item_code` of Item Document "posting_date": today(),
:param warehouse: name of Warehouse to check "posting_time": nowtime(),
:param qty: quantity of Items "voucher_type": "Stock Entry",
:return: String represent batch number of batch with sufficient quantity else an empty String "qty": flt(kwargs.qty),
""" "type_of_transaction": kwargs.type_of_transaction,
"company": kwargs.company,
batch_no = None "batches": kwargs.batches,
batches = get_batches(item_code, warehouse, qty, throw, serial_no) "do_not_submit": True,
}
for batch in batches:
if flt(qty) <= flt(batch.qty):
batch_no = batch.batch_id
break
if not batch_no:
frappe.msgprint(
_(
"Please select a Batch for Item {0}. Unable to find a single batch that fulfills this requirement"
).format(frappe.bold(item_code))
) )
if throw: .make_serial_and_batch_bundle()
raise UnableToSelectBatchError .name
)
return batch_no
def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None): def get_batches(item_code, warehouse, qty=1, throw=False, serial_no=None):
@ -362,10 +357,10 @@ def validate_serial_no_with_batch(serial_nos, item_code):
frappe.throw(_("There is no batch found against the {0}: {1}").format(message, serial_no_link)) frappe.throw(_("There is no batch found against the {0}: {1}").format(message, serial_no_link))
def make_batch(args): def make_batch(kwargs):
if frappe.db.get_value("Item", args.item, "has_batch_no"): if frappe.db.get_value("Item", kwargs.item, "has_batch_no"):
args.doctype = "Batch" kwargs.doctype = "Batch"
frappe.get_doc(args).insert().name return frappe.get_doc(kwargs).insert().name
@frappe.whitelist() @frappe.whitelist()
@ -398,3 +393,28 @@ def get_pos_reserved_batch_qty(filters):
flt_reserved_batch_qty = flt(reserved_batch_qty[0][0]) flt_reserved_batch_qty = flt(reserved_batch_qty[0][0])
return flt_reserved_batch_qty return flt_reserved_batch_qty
def get_available_batches(kwargs):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
batchwise_qty = defaultdict(float)
batches = get_auto_batch_nos(kwargs)
for batch in batches:
batchwise_qty[batch.get("batch_no")] += batch.get("qty")
return batchwise_qty
def get_batch_no(bundle_id):
from erpnext.stock.serial_batch_bundle import get_batch_nos
batches = defaultdict(float)
for batch_id, d in get_batch_nos(bundle_id).items():
batches[batch_id] += abs(d.get("qty"))
return batches

View File

@ -7,7 +7,7 @@ def get_data():
"transactions": [ "transactions": [
{"label": _("Buy"), "items": ["Purchase Invoice", "Purchase Receipt"]}, {"label": _("Buy"), "items": ["Purchase Invoice", "Purchase Receipt"]},
{"label": _("Sell"), "items": ["Sales Invoice", "Delivery Note"]}, {"label": _("Sell"), "items": ["Sales Invoice", "Delivery Note"]},
{"label": _("Move"), "items": ["Stock Entry"]}, {"label": _("Move"), "items": ["Serial and Batch Bundle"]},
{"label": _("Quality"), "items": ["Quality Inspection"]}, {"label": _("Quality"), "items": ["Quality Inspection"]},
], ],
} }

View File

@ -10,15 +10,18 @@ from frappe.utils import cint, flt
from frappe.utils.data import add_to_date, getdate from frappe.utils.data import add_to_date, getdate
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
from erpnext.stock.doctype.batch.batch import UnableToSelectBatchError, get_batch_no, get_batch_qty from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( BatchNegativeStockError,
create_stock_reconciliation,
) )
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.get_item_details import get_item_details from erpnext.stock.get_item_details import get_item_details
from erpnext.stock.stock_ledger import get_valuation_rate from erpnext.stock.serial_batch_bundle import SerialBatchCreation
class TestBatch(FrappeTestCase): class TestBatch(FrappeTestCase):
@ -49,8 +52,10 @@ class TestBatch(FrappeTestCase):
).insert() ).insert()
receipt.submit() receipt.submit()
self.assertTrue(receipt.items[0].batch_no) receipt.load_from_db()
self.assertEqual(get_batch_qty(receipt.items[0].batch_no, receipt.items[0].warehouse), batch_qty) self.assertTrue(receipt.items[0].serial_and_batch_bundle)
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
self.assertEqual(get_batch_qty(batch_no, receipt.items[0].warehouse), batch_qty)
return receipt return receipt
@ -80,9 +85,12 @@ class TestBatch(FrappeTestCase):
stock_entry.insert() stock_entry.insert()
stock_entry.submit() stock_entry.submit()
self.assertTrue(stock_entry.items[0].batch_no) stock_entry.load_from_db()
bundle = stock_entry.items[0].serial_and_batch_bundle
self.assertTrue(bundle)
self.assertEqual( self.assertEqual(
get_batch_qty(stock_entry.items[0].batch_no, stock_entry.items[0].t_warehouse), 90 get_batch_qty(get_batch_from_bundle(bundle), stock_entry.items[0].t_warehouse), 90
) )
def test_delivery_note(self): def test_delivery_note(self):
@ -91,37 +99,71 @@ class TestBatch(FrappeTestCase):
receipt = self.test_purchase_receipt(batch_qty) receipt = self.test_purchase_receipt(batch_qty)
item_code = "ITEM-BATCH-1" item_code = "ITEM-BATCH-1"
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
bundle_id = (
SerialBatchCreation(
{
"item_code": item_code,
"warehouse": receipt.items[0].warehouse,
"actual_qty": batch_qty,
"voucher_type": "Stock Entry",
"batches": frappe._dict({batch_no: batch_qty}),
"type_of_transaction": "Outward",
"company": receipt.company,
}
)
.make_serial_and_batch_bundle()
.name
)
delivery_note = frappe.get_doc( delivery_note = frappe.get_doc(
dict( dict(
doctype="Delivery Note", doctype="Delivery Note",
customer="_Test Customer", customer="_Test Customer",
company=receipt.company, company=receipt.company,
items=[ items=[
dict(item_code=item_code, qty=batch_qty, rate=10, warehouse=receipt.items[0].warehouse) dict(
item_code=item_code,
qty=batch_qty,
rate=10,
warehouse=receipt.items[0].warehouse,
serial_and_batch_bundle=bundle_id,
)
], ],
) )
).insert() ).insert()
delivery_note.submit() delivery_note.submit()
receipt.load_from_db()
delivery_note.load_from_db()
# shipped from FEFO batch # shipped from FEFO batch
self.assertEqual( self.assertEqual(
delivery_note.items[0].batch_no, get_batch_no(item_code, receipt.items[0].warehouse, batch_qty) get_batch_from_bundle(delivery_note.items[0].serial_and_batch_bundle),
batch_no,
) )
def test_delivery_note_fail(self): def test_batch_negative_stock_error(self):
"""Test automatic batch selection for outgoing items""" """Test automatic batch selection for outgoing items"""
receipt = self.test_purchase_receipt(100) receipt = self.test_purchase_receipt(100)
delivery_note = frappe.get_doc(
dict( receipt.load_from_db()
doctype="Delivery Note", batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
customer="_Test Customer", sn_doc = SerialBatchCreation(
company=receipt.company, {
items=[ "item_code": "ITEM-BATCH-1",
dict(item_code="ITEM-BATCH-1", qty=5000, rate=10, warehouse=receipt.items[0].warehouse) "warehouse": receipt.items[0].warehouse,
], "voucher_type": "Delivery Note",
) "qty": 5000,
"avg_rate": 10,
"batches": frappe._dict({batch_no: 5000}),
"type_of_transaction": "Outward",
"company": receipt.company,
}
) )
self.assertRaises(UnableToSelectBatchError, delivery_note.insert)
self.assertRaises(BatchNegativeStockError, sn_doc.make_serial_and_batch_bundle)
def test_stock_entry_outgoing(self): def test_stock_entry_outgoing(self):
"""Test automatic batch selection for outgoing stock entry""" """Test automatic batch selection for outgoing stock entry"""
@ -130,6 +172,24 @@ class TestBatch(FrappeTestCase):
receipt = self.test_purchase_receipt(batch_qty) receipt = self.test_purchase_receipt(batch_qty)
item_code = "ITEM-BATCH-1" item_code = "ITEM-BATCH-1"
batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
bundle_id = (
SerialBatchCreation(
{
"item_code": item_code,
"warehouse": receipt.items[0].warehouse,
"actual_qty": batch_qty,
"voucher_type": "Stock Entry",
"batches": frappe._dict({batch_no: batch_qty}),
"type_of_transaction": "Outward",
"company": receipt.company,
}
)
.make_serial_and_batch_bundle()
.name
)
stock_entry = frappe.get_doc( stock_entry = frappe.get_doc(
dict( dict(
doctype="Stock Entry", doctype="Stock Entry",
@ -140,6 +200,7 @@ class TestBatch(FrappeTestCase):
item_code=item_code, item_code=item_code,
qty=batch_qty, qty=batch_qty,
s_warehouse=receipt.items[0].warehouse, s_warehouse=receipt.items[0].warehouse,
serial_and_batch_bundle=bundle_id,
) )
], ],
) )
@ -148,10 +209,11 @@ class TestBatch(FrappeTestCase):
stock_entry.set_stock_entry_type() stock_entry.set_stock_entry_type()
stock_entry.insert() stock_entry.insert()
stock_entry.submit() stock_entry.submit()
stock_entry.load_from_db()
# assert same batch is selected
self.assertEqual( self.assertEqual(
stock_entry.items[0].batch_no, get_batch_no(item_code, receipt.items[0].warehouse, batch_qty) get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle),
get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle),
) )
def test_batch_split(self): def test_batch_split(self):
@ -159,11 +221,11 @@ class TestBatch(FrappeTestCase):
receipt = self.test_purchase_receipt() receipt = self.test_purchase_receipt()
from erpnext.stock.doctype.batch.batch import split_batch from erpnext.stock.doctype.batch.batch import split_batch
new_batch = split_batch( batch_no = get_batch_from_bundle(receipt.items[0].serial_and_batch_bundle)
receipt.items[0].batch_no, "ITEM-BATCH-1", receipt.items[0].warehouse, 22
)
self.assertEqual(get_batch_qty(receipt.items[0].batch_no, receipt.items[0].warehouse), 78) new_batch = split_batch(batch_no, "ITEM-BATCH-1", receipt.items[0].warehouse, 22)
self.assertEqual(get_batch_qty(batch_no, receipt.items[0].warehouse), 78)
self.assertEqual(get_batch_qty(new_batch, receipt.items[0].warehouse), 22) self.assertEqual(get_batch_qty(new_batch, receipt.items[0].warehouse), 22)
def test_get_batch_qty(self): def test_get_batch_qty(self):
@ -174,7 +236,10 @@ class TestBatch(FrappeTestCase):
self.assertEqual( self.assertEqual(
get_batch_qty(item_code="ITEM-BATCH-2", warehouse="_Test Warehouse - _TC"), get_batch_qty(item_code="ITEM-BATCH-2", warehouse="_Test Warehouse - _TC"),
[{"batch_no": "batch a", "qty": 90.0}, {"batch_no": "batch b", "qty": 90.0}], [
{"batch_no": "batch a", "qty": 90.0, "warehouse": "_Test Warehouse - _TC"},
{"batch_no": "batch b", "qty": 90.0, "warehouse": "_Test Warehouse - _TC"},
],
) )
self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90) self.assertEqual(get_batch_qty("batch a", "_Test Warehouse - _TC"), 90)
@ -201,6 +266,19 @@ class TestBatch(FrappeTestCase):
) )
batch.save() batch.save()
sn_doc = SerialBatchCreation(
{
"item_code": item_name,
"warehouse": warehouse,
"voucher_type": "Stock Entry",
"qty": 90,
"avg_rate": 10,
"batches": frappe._dict({batch_name: 90}),
"type_of_transaction": "Inward",
"company": "_Test Company",
}
).make_serial_and_batch_bundle()
stock_entry = frappe.get_doc( stock_entry = frappe.get_doc(
dict( dict(
doctype="Stock Entry", doctype="Stock Entry",
@ -210,10 +288,10 @@ class TestBatch(FrappeTestCase):
dict( dict(
item_code=item_name, item_code=item_name,
qty=90, qty=90,
serial_and_batch_bundle=sn_doc.name,
t_warehouse=warehouse, t_warehouse=warehouse,
cost_center="Main - _TC", cost_center="Main - _TC",
rate=10, rate=10,
batch_no=batch_name,
allow_zero_valuation_rate=1, allow_zero_valuation_rate=1,
) )
], ],
@ -320,7 +398,8 @@ class TestBatch(FrappeTestCase):
batches = {} batches = {}
for rate in rates: for rate in rates:
se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse) se = make_stock_entry(item_code=item_code, qty=10, rate=rate, target=warehouse)
batches[se.items[0].batch_no] = rate batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
batches[batch_no] = rate
LOW, HIGH = list(batches.keys()) LOW, HIGH = list(batches.keys())
@ -341,7 +420,9 @@ class TestBatch(FrappeTestCase):
sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name}) sle = frappe.get_last_doc("Stock Ledger Entry", {"is_cancelled": 0, "voucher_no": se.name})
stock_value_difference = sle.actual_qty * batches[sle.batch_no] stock_value_difference = (
sle.actual_qty * batches[get_batch_from_bundle(sle.serial_and_batch_bundle)]
)
self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference) self.assertAlmostEqual(sle.stock_value_difference, stock_value_difference)
stock_value += stock_value_difference stock_value += stock_value_difference
@ -353,51 +434,12 @@ class TestBatch(FrappeTestCase):
self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items self.assertEqual(json.loads(sle.stock_queue), []) # queues don't apply on batched items
def test_moving_batch_valuation_rates(self):
item_code = "_TestBatchWiseVal"
warehouse = "_Test Warehouse - _TC"
self.make_batch_item(item_code)
def assertValuation(expected):
actual = get_valuation_rate(
item_code, warehouse, "voucher_type", "voucher_no", batch_no=batch_no
)
self.assertAlmostEqual(actual, expected)
se = make_stock_entry(item_code=item_code, qty=100, rate=10, target=warehouse)
batch_no = se.items[0].batch_no
assertValuation(10)
# consumption should never affect current valuation rate
make_stock_entry(item_code=item_code, qty=20, source=warehouse)
assertValuation(10)
make_stock_entry(item_code=item_code, qty=30, source=warehouse)
assertValuation(10)
# 50 * 10 = 500 current value, add more item with higher valuation
make_stock_entry(item_code=item_code, qty=50, rate=20, target=warehouse, batch_no=batch_no)
assertValuation(15)
# consuming again shouldn't do anything
make_stock_entry(item_code=item_code, qty=20, source=warehouse)
assertValuation(15)
# reset rate with stock reconiliation
create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=10, rate=25, batch_no=batch_no
)
assertValuation(25)
make_stock_entry(item_code=item_code, qty=20, rate=20, target=warehouse, batch_no=batch_no)
assertValuation((20 * 20 + 10 * 25) / (10 + 20))
def test_update_batch_properties(self): def test_update_batch_properties(self):
item_code = "_TestBatchWiseVal" item_code = "_TestBatchWiseVal"
self.make_batch_item(item_code) self.make_batch_item(item_code)
se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC") se = make_stock_entry(item_code=item_code, qty=100, rate=10, target="_Test Warehouse - _TC")
batch_no = se.items[0].batch_no batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
batch = frappe.get_doc("Batch", batch_no) batch = frappe.get_doc("Batch", batch_no)
expiry_date = add_to_date(batch.manufacturing_date, days=30) expiry_date = add_to_date(batch.manufacturing_date, days=30)
@ -426,8 +468,17 @@ class TestBatch(FrappeTestCase):
pr_1 = make_purchase_receipt(item_code=item_code, qty=1, batch_no=manually_created_batch) pr_1 = make_purchase_receipt(item_code=item_code, qty=1, batch_no=manually_created_batch)
pr_2 = make_purchase_receipt(item_code=item_code, qty=1) pr_2 = make_purchase_receipt(item_code=item_code, qty=1)
self.assertNotEqual(pr_1.items[0].batch_no, pr_2.items[0].batch_no) pr_1.load_from_db()
self.assertEqual("BATCHEXISTING002", pr_2.items[0].batch_no) pr_2.load_from_db()
self.assertNotEqual(
get_batch_from_bundle(pr_1.items[0].serial_and_batch_bundle),
get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle),
)
self.assertEqual(
"BATCHEXISTING002", get_batch_from_bundle(pr_2.items[0].serial_and_batch_bundle)
)
def create_batch(item_code, rate, create_item_price_for_batch): def create_batch(item_code, rate, create_item_price_for_batch):

View File

@ -12,7 +12,6 @@ from frappe.utils import cint, flt
from erpnext.controllers.accounts_controller import get_taxes_and_charges from erpnext.controllers.accounts_controller import get_taxes_and_charges
from erpnext.controllers.selling_controller import SellingController from erpnext.controllers.selling_controller import SellingController
from erpnext.stock.doctype.batch.batch import set_batch_nos
from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no
form_grid_templates = {"items": "templates/form_grid/item_grid.html"} form_grid_templates = {"items": "templates/form_grid/item_grid.html"}
@ -138,15 +137,11 @@ class DeliveryNote(SellingController):
self.validate_uom_is_integer("stock_uom", "stock_qty") self.validate_uom_is_integer("stock_uom", "stock_qty")
self.validate_uom_is_integer("uom", "qty") self.validate_uom_is_integer("uom", "qty")
self.validate_with_previous_doc() self.validate_with_previous_doc()
self.set_serial_and_batch_bundle_from_pick_list()
from erpnext.stock.doctype.packed_item.packed_item import make_packing_list from erpnext.stock.doctype.packed_item.packed_item import make_packing_list
make_packing_list(self) make_packing_list(self)
if self._action != "submit" and not self.is_return:
set_batch_nos(self, "warehouse", throw=True)
set_batch_nos(self, "warehouse", throw=True, child_table="packed_items")
self.update_current_stock() self.update_current_stock()
if not self.installation_status: if not self.installation_status:
@ -193,6 +188,24 @@ class DeliveryNote(SellingController):
] ]
) )
def set_serial_and_batch_bundle_from_pick_list(self):
if not self.pick_list:
return
for item in self.items:
if item.pick_list_item:
filters = {
"item_code": item.item_code,
"voucher_type": "Pick List",
"voucher_no": self.pick_list,
"voucher_detail_no": item.pick_list_item,
}
bundle_id = frappe.db.get_value("Serial and Batch Bundle", filters, "name")
if bundle_id:
item.serial_and_batch_bundle = bundle_id
def validate_proj_cust(self): def validate_proj_cust(self):
"""check for does customer belong to same project as entered..""" """check for does customer belong to same project as entered.."""
if self.project and self.customer: if self.project and self.customer:
@ -274,7 +287,12 @@ class DeliveryNote(SellingController):
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Serial and Batch Bundle",
)
def update_stock_reservation_entries(self) -> None: def update_stock_reservation_entries(self) -> None:
"""Updates Delivered Qty in Stock Reservation Entries.""" """Updates Delivered Qty in Stock Reservation Entries."""
@ -1045,8 +1063,6 @@ def make_inter_company_transaction(doctype, source_name, target_doc=None):
"field_map": { "field_map": {
source_document_warehouse_field: target_document_warehouse_field, source_document_warehouse_field: target_document_warehouse_field,
"name": "delivery_note_item", "name": "delivery_note_item",
"batch_no": "batch_no",
"serial_no": "serial_no",
"purchase_order": "purchase_order", "purchase_order": "purchase_order",
"purchase_order_item": "purchase_order_item", "purchase_order_item": "purchase_order_item",
"material_request": "material_request", "material_request": "material_request",

View File

@ -23,7 +23,11 @@ from erpnext.stock.doctype.delivery_note.delivery_note import (
) )
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import get_gl_entries
from erpnext.stock.doctype.serial_no.serial_no import SerialNoWarehouseError, get_serial_nos from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import ( from erpnext.stock.doctype.stock_entry.test_stock_entry import (
get_qty_after_transaction, get_qty_after_transaction,
make_serialized_item, make_serialized_item,
@ -135,42 +139,6 @@ class TestDeliveryNote(FrappeTestCase):
dn.cancel() dn.cancel()
def test_serialized(self):
se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0]
dn = create_delivery_note(item_code="_Test Serialized Item With Series", serial_no=serial_no)
self.check_serial_no_values(serial_no, {"warehouse": "", "delivery_document_no": dn.name})
si = make_sales_invoice(dn.name)
si.insert(ignore_permissions=True)
self.assertEqual(dn.items[0].serial_no, si.items[0].serial_no)
dn.cancel()
self.check_serial_no_values(
serial_no, {"warehouse": "_Test Warehouse - _TC", "delivery_document_no": ""}
)
def test_serialized_partial_sales_invoice(self):
se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)
serial_no = "\n".join(serial_no)
dn = create_delivery_note(
item_code="_Test Serialized Item With Series", qty=2, serial_no=serial_no
)
si = make_sales_invoice(dn.name)
si.items[0].qty = 1
si.submit()
self.assertEqual(si.items[0].qty, 1)
si = make_sales_invoice(dn.name)
si.submit()
self.assertEqual(si.items[0].qty, len(get_serial_nos(si.items[0].serial_no)))
def test_serialize_status(self): def test_serialize_status(self):
from frappe.model.naming import make_autoname from frappe.model.naming import make_autoname
@ -178,16 +146,28 @@ class TestDeliveryNote(FrappeTestCase):
{ {
"doctype": "Serial No", "doctype": "Serial No",
"item_code": "_Test Serialized Item With Series", "item_code": "_Test Serialized Item With Series",
"serial_no": make_autoname("SR", "Serial No"), "serial_no": make_autoname("SRDD", "Serial No"),
} }
) )
serial_no.save() serial_no.save()
dn = create_delivery_note( bundle_id = make_serial_batch_bundle(
item_code="_Test Serialized Item With Series", serial_no=serial_no.name, do_not_submit=True frappe._dict(
{
"item_code": "_Test Serialized Item With Series",
"warehouse": "_Test Warehouse - _TC",
"qty": -1,
"voucher_type": "Delivery Note",
"serial_nos": [serial_no.name],
"posting_date": today(),
"posting_time": nowtime(),
"type_of_transaction": "Outward",
"do_not_save": True,
}
)
) )
self.assertRaises(SerialNoWarehouseError, dn.submit) self.assertRaises(frappe.ValidationError, bundle_id.make_serial_and_batch_bundle)
def check_serial_no_values(self, serial_no, field_values): def check_serial_no_values(self, serial_no, field_values):
serial_no = frappe.get_doc("Serial No", serial_no) serial_no = frappe.get_doc("Serial No", serial_no)
@ -532,13 +512,14 @@ class TestDeliveryNote(FrappeTestCase):
def test_return_for_serialized_items(self): def test_return_for_serialized_items(self):
se = make_serialized_item() se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0]
serial_no = [get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]]
dn = create_delivery_note( dn = create_delivery_note(
item_code="_Test Serialized Item With Series", rate=500, serial_no=serial_no item_code="_Test Serialized Item With Series", rate=500, serial_no=serial_no
) )
self.check_serial_no_values(serial_no, {"warehouse": "", "delivery_document_no": dn.name}) self.check_serial_no_values(serial_no, {"warehouse": ""})
# return entry # return entry
dn1 = create_delivery_note( dn1 = create_delivery_note(
@ -550,23 +531,17 @@ class TestDeliveryNote(FrappeTestCase):
serial_no=serial_no, serial_no=serial_no,
) )
self.check_serial_no_values( self.check_serial_no_values(serial_no, {"warehouse": "_Test Warehouse - _TC"})
serial_no, {"warehouse": "_Test Warehouse - _TC", "delivery_document_no": ""}
)
dn1.cancel() dn1.cancel()
self.check_serial_no_values(serial_no, {"warehouse": "", "delivery_document_no": dn.name}) self.check_serial_no_values(serial_no, {"warehouse": ""})
dn.cancel() dn.cancel()
self.check_serial_no_values( self.check_serial_no_values(
serial_no, serial_no,
{ {"warehouse": "_Test Warehouse - _TC"},
"warehouse": "_Test Warehouse - _TC",
"delivery_document_no": "",
"purchase_document_no": se.name,
},
) )
def test_delivery_of_bundled_items_to_target_warehouse(self): def test_delivery_of_bundled_items_to_target_warehouse(self):
@ -956,7 +931,7 @@ class TestDeliveryNote(FrappeTestCase):
"is_stock_item": 1, "is_stock_item": 1,
"has_batch_no": 1, "has_batch_no": 1,
"create_new_batch": 1, "create_new_batch": 1,
"batch_number_series": "TESTBATCH.#####", "batch_number_series": "TESTBATCHIUU.#####",
}, },
) )
make_product_bundle(parent=batched_bundle.name, items=[batched_item.name]) make_product_bundle(parent=batched_bundle.name, items=[batched_item.name])
@ -964,16 +939,11 @@ class TestDeliveryNote(FrappeTestCase):
item_code=batched_item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=42 item_code=batched_item.name, target="_Test Warehouse - _TC", qty=10, basic_rate=42
) )
try: dn = create_delivery_note(item_code=batched_bundle.name, qty=1)
dn = create_delivery_note(item_code=batched_bundle.name, qty=1) dn.load_from_db()
except frappe.ValidationError as e:
if "batch" in str(e).lower():
self.fail("Batch numbers not getting added to bundled items in DN.")
raise e
self.assertTrue( batch_no = get_batch_from_bundle(dn.packed_items[0].serial_and_batch_bundle)
"TESTBATCH" in dn.packed_items[0].batch_no, "Batch number not added in packed item" self.assertTrue(batch_no)
)
def test_payment_terms_are_fetched_when_creating_sales_invoice(self): def test_payment_terms_are_fetched_when_creating_sales_invoice(self):
from erpnext.accounts.doctype.payment_entry.test_payment_entry import ( from erpnext.accounts.doctype.payment_entry.test_payment_entry import (
@ -1167,10 +1137,11 @@ class TestDeliveryNote(FrappeTestCase):
pi = make_purchase_receipt(qty=1, item_code=item.name) pi = make_purchase_receipt(qty=1, item_code=item.name)
dn = create_delivery_note(qty=1, item_code=item.name, batch_no=pi.items[0].batch_no) pr_batch_no = get_batch_from_bundle(pi.items[0].serial_and_batch_bundle)
dn = create_delivery_note(qty=1, item_code=item.name, batch_no=pr_batch_no)
dn.load_from_db() dn.load_from_db()
batch_no = dn.items[0].batch_no batch_no = get_batch_from_bundle(dn.items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1)) frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1))
@ -1241,6 +1212,36 @@ def create_delivery_note(**args):
dn.is_return = args.is_return dn.is_return = args.is_return
dn.return_against = args.return_against dn.return_against = args.return_against
bundle_id = None
if args.get("batch_no") or args.get("serial_no"):
type_of_transaction = args.type_of_transaction or "Outward"
if dn.is_return:
type_of_transaction = "Inward"
qty = args.get("qty") or 1
qty *= -1 if type_of_transaction == "Outward" else 1
batches = {}
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": args.item or args.item_code or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Delivery Note",
"serial_nos": args.serial_no,
"posting_date": dn.posting_date,
"posting_time": dn.posting_time,
"type_of_transaction": type_of_transaction,
"do_not_submit": True,
}
)
).name
dn.append( dn.append(
"items", "items",
{ {
@ -1249,11 +1250,10 @@ def create_delivery_note(**args):
"qty": args.qty or 1, "qty": args.qty or 1,
"rate": args.rate if args.get("rate") is not None else 100, "rate": args.rate if args.get("rate") is not None else 100,
"conversion_factor": 1.0, "conversion_factor": 1.0,
"serial_and_batch_bundle": bundle_id,
"allow_zero_valuation_rate": args.allow_zero_valuation_rate or 1, "allow_zero_valuation_rate": args.allow_zero_valuation_rate or 1,
"expense_account": args.expense_account or "Cost of Goods Sold - _TC", "expense_account": args.expense_account or "Cost of Goods Sold - _TC",
"cost_center": args.cost_center or "_Test Cost Center - _TC", "cost_center": args.cost_center or "_Test Cost Center - _TC",
"serial_no": args.serial_no,
"batch_no": args.batch_no or None,
"target_warehouse": args.target_warehouse, "target_warehouse": args.target_warehouse,
}, },
) )
@ -1262,6 +1262,9 @@ def create_delivery_note(**args):
dn.insert() dn.insert()
if not args.do_not_submit: if not args.do_not_submit:
dn.submit() dn.submit()
dn.load_from_db()
return dn return dn

View File

@ -70,6 +70,7 @@
"target_warehouse", "target_warehouse",
"quality_inspection", "quality_inspection",
"col_break4", "col_break4",
"allow_zero_valuation_rate",
"against_sales_order", "against_sales_order",
"so_detail", "so_detail",
"against_sales_invoice", "against_sales_invoice",
@ -77,8 +78,12 @@
"dn_detail", "dn_detail",
"pick_list_item", "pick_list_item",
"section_break_40", "section_break_40",
"batch_no", "pick_serial_and_batch",
"serial_and_batch_bundle",
"column_break_eaoe",
"serial_no", "serial_no",
"batch_no",
"available_qty_section",
"actual_batch_qty", "actual_batch_qty",
"actual_qty", "actual_qty",
"installed_qty", "installed_qty",
@ -88,7 +93,6 @@
"received_qty", "received_qty",
"accounting_details_section", "accounting_details_section",
"expense_account", "expense_account",
"allow_zero_valuation_rate",
"column_break_71", "column_break_71",
"internal_transfer_section", "internal_transfer_section",
"material_request", "material_request",
@ -505,17 +509,8 @@
}, },
{ {
"fieldname": "section_break_40", "fieldname": "section_break_40",
"fieldtype": "Section Break" "fieldtype": "Section Break",
}, "label": "Serial and Batch No"
{
"fieldname": "batch_no",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Batch No",
"oldfieldname": "batch_no",
"oldfieldtype": "Link",
"options": "Batch",
"print_hide": 1
}, },
{ {
"allow_on_submit": 1, "allow_on_submit": 1,
@ -542,15 +537,6 @@
"read_only": 1, "read_only": 1,
"width": "150px" "width": "150px"
}, },
{
"fieldname": "serial_no",
"fieldtype": "Text",
"in_list_view": 1,
"label": "Serial No",
"no_copy": 1,
"oldfieldname": "serial_no",
"oldfieldtype": "Text"
},
{ {
"fieldname": "item_group", "fieldname": "item_group",
"fieldtype": "Link", "fieldtype": "Link",
@ -861,13 +847,51 @@
"no_copy": 1, "no_copy": 1,
"non_negative": 1, "non_negative": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"fieldname": "pick_serial_and_batch",
"fieldtype": "Button",
"label": "Pick Serial / Batch No"
},
{
"collapsible": 1,
"fieldname": "available_qty_section",
"fieldtype": "Section Break",
"label": "Available Qty"
},
{
"fieldname": "column_break_eaoe",
"fieldtype": "Column Break"
},
{
"fieldname": "serial_no",
"fieldtype": "Text",
"hidden": 1,
"label": "Serial No",
"read_only": 1
},
{
"fieldname": "batch_no",
"fieldtype": "Link",
"hidden": 1,
"label": "Batch No",
"options": "Batch",
"read_only": 1
} }
], ],
"idx": 1, "idx": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-05-01 21:05:14.175640", "modified": "2023-05-02 21:05:14.175640",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Delivery Note Item", "name": "Delivery Note Item",

View File

@ -4,7 +4,7 @@
import frappe import frappe
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, add_to_date, flt, now from frappe.utils import add_days, add_to_date, flt, now, nowtime, today
from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account from erpnext.accounts.doctype.account.test_account import create_account, get_inventory_account
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
@ -15,6 +15,12 @@ from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import (
get_gl_entries, get_gl_entries,
make_purchase_receipt, make_purchase_receipt,
) )
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.serial_batch_bundle import SerialNoValuation
class TestLandedCostVoucher(FrappeTestCase): class TestLandedCostVoucher(FrappeTestCase):
@ -297,9 +303,8 @@ class TestLandedCostVoucher(FrappeTestCase):
self.assertEqual(expected_values[gle.account][1], gle.credit) self.assertEqual(expected_values[gle.account][1], gle.credit)
def test_landed_cost_voucher_for_serialized_item(self): def test_landed_cost_voucher_for_serialized_item(self):
frappe.db.sql( frappe.db.set_value("Item", "_Test Serialized Item", "serial_no_series", "SNJJ.###")
"delete from `tabSerial No` where name in ('SN001', 'SN002', 'SN003', 'SN004', 'SN005')"
)
pr = make_purchase_receipt( pr = make_purchase_receipt(
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1", warehouse="Stores - TCP1",
@ -310,17 +315,42 @@ class TestLandedCostVoucher(FrappeTestCase):
) )
pr.items[0].item_code = "_Test Serialized Item" pr.items[0].item_code = "_Test Serialized Item"
pr.items[0].serial_no = "SN001\nSN002\nSN003\nSN004\nSN005"
pr.submit() pr.submit()
pr.load_from_db()
serial_no_rate = frappe.db.get_value("Serial No", "SN001", "purchase_rate") serial_no = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
sn_obj = SerialNoValuation(
sle=frappe._dict(
{
"posting_date": today(),
"posting_time": nowtime(),
"item_code": "_Test Serialized Item",
"warehouse": "Stores - TCP1",
"serial_nos": [serial_no],
}
)
)
serial_no_rate = sn_obj.get_incoming_rate_of_serial_no(serial_no)
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company) create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company)
serial_no = frappe.db.get_value("Serial No", "SN001", ["warehouse", "purchase_rate"], as_dict=1) sn_obj = SerialNoValuation(
sle=frappe._dict(
{
"posting_date": today(),
"posting_time": nowtime(),
"item_code": "_Test Serialized Item",
"warehouse": "Stores - TCP1",
"serial_nos": [serial_no],
}
)
)
self.assertEqual(serial_no.purchase_rate - serial_no_rate, 5.0) new_serial_no_rate = sn_obj.get_incoming_rate_of_serial_no(serial_no)
self.assertEqual(serial_no.warehouse, "Stores - TCP1")
self.assertEqual(new_serial_no_rate - serial_no_rate, 5.0)
def test_serialized_lcv_delivered(self): def test_serialized_lcv_delivered(self):
"""In some cases you'd want to deliver before you can know all the """In some cases you'd want to deliver before you can know all the
@ -337,23 +367,44 @@ class TestLandedCostVoucher(FrappeTestCase):
item_code = "_Test Serialized Item" item_code = "_Test Serialized Item"
warehouse = "Stores - TCP1" warehouse = "Stores - TCP1"
if not frappe.db.exists("Serial No", serial_no):
frappe.get_doc(
{
"doctype": "Serial No",
"item_code": item_code,
"serial_no": serial_no,
}
).insert()
pr = make_purchase_receipt( pr = make_purchase_receipt(
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",
warehouse=warehouse, warehouse=warehouse,
qty=1, qty=1,
rate=200, rate=200,
item_code=item_code, item_code=item_code,
serial_no=serial_no, serial_no=[serial_no],
) )
serial_no_rate = frappe.db.get_value("Serial No", serial_no, "purchase_rate") sn_obj = SerialNoValuation(
sle=frappe._dict(
{
"posting_date": today(),
"posting_time": nowtime(),
"item_code": "_Test Serialized Item",
"warehouse": "Stores - TCP1",
"serial_nos": [serial_no],
}
)
)
serial_no_rate = sn_obj.get_incoming_rate_of_serial_no(serial_no)
# deliver it before creating LCV # deliver it before creating LCV
dn = create_delivery_note( dn = create_delivery_note(
item_code=item_code, item_code=item_code,
company="_Test Company with perpetual inventory", company="_Test Company with perpetual inventory",
warehouse="Stores - TCP1", warehouse="Stores - TCP1",
serial_no=serial_no, serial_no=[serial_no],
qty=1, qty=1,
rate=500, rate=500,
cost_center="Main - TCP1", cost_center="Main - TCP1",
@ -362,14 +413,24 @@ class TestLandedCostVoucher(FrappeTestCase):
charges = 10 charges = 10
create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges) create_landed_cost_voucher("Purchase Receipt", pr.name, pr.company, charges=charges)
new_purchase_rate = serial_no_rate + charges new_purchase_rate = serial_no_rate + charges
serial_no = frappe.db.get_value( sn_obj = SerialNoValuation(
"Serial No", serial_no, ["warehouse", "purchase_rate"], as_dict=1 sle=frappe._dict(
{
"posting_date": today(),
"posting_time": nowtime(),
"item_code": "_Test Serialized Item",
"warehouse": "Stores - TCP1",
"serial_nos": [serial_no],
}
)
) )
self.assertEqual(serial_no.purchase_rate, new_purchase_rate) new_serial_no_rate = sn_obj.get_incoming_rate_of_serial_no(serial_no)
# Since the serial no is already delivered the rate must be zero
self.assertFalse(new_serial_no_rate)
stock_value_difference = frappe.db.get_value( stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry", "Stock Ledger Entry",

View File

@ -19,6 +19,8 @@
"rate", "rate",
"uom", "uom",
"section_break_9", "section_break_9",
"pick_serial_and_batch",
"serial_and_batch_bundle",
"serial_no", "serial_no",
"column_break_11", "column_break_11",
"batch_no", "batch_no",
@ -118,7 +120,8 @@
{ {
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Text", "fieldtype": "Text",
"label": "Serial No" "label": "Serial No",
"read_only": 1
}, },
{ {
"fieldname": "column_break_11", "fieldname": "column_break_11",
@ -128,7 +131,8 @@
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Batch No", "label": "Batch No",
"options": "Batch" "options": "Batch",
"read_only": 1
}, },
{ {
"fieldname": "section_break_13", "fieldname": "section_break_13",
@ -253,6 +257,19 @@
"no_copy": 1, "no_copy": 1,
"non_negative": 1, "non_negative": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"fieldname": "pick_serial_and_batch",
"fieldtype": "Button",
"label": "Pick Serial / Batch No"
} }
], ],
"idx": 1, "idx": 1,

View File

@ -3,6 +3,8 @@
frappe.ui.form.on('Pick List', { frappe.ui.form.on('Pick List', {
setup: (frm) => { setup: (frm) => {
frm.ignore_doctypes_on_cancel_all = ["Serial and Batch Bundle"];
frm.set_indicator_formatter('item_code', frm.set_indicator_formatter('item_code',
function(doc) { return (doc.stock_qty === 0) ? "red" : "green"; }); function(doc) { return (doc.stock_qty === 0) ? "red" : "green"; });

View File

@ -12,14 +12,18 @@ from frappe.model.document import Document
from frappe.model.mapper import map_child_doc from frappe.model.mapper import map_child_doc
from frappe.query_builder import Case from frappe.query_builder import Case
from frappe.query_builder.custom import GROUP_CONCAT from frappe.query_builder.custom import GROUP_CONCAT
from frappe.query_builder.functions import Coalesce, IfNull, Locate, Replace, Sum from frappe.query_builder.functions import Coalesce, Locate, Replace, Sum
from frappe.utils import cint, floor, flt, today from frappe.utils import cint, floor, flt
from frappe.utils.nestedset import get_descendants_of from frappe.utils.nestedset import get_descendants_of
from erpnext.selling.doctype.sales_order.sales_order import ( from erpnext.selling.doctype.sales_order.sales_order import (
make_delivery_note as create_delivery_note_from_sales_order, make_delivery_note as create_delivery_note_from_sales_order,
) )
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_auto_batch_nos,
)
from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.get_item_details import get_conversion_factor
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
# TODO: Prioritize SO or WO group warehouse # TODO: Prioritize SO or WO group warehouse
@ -59,38 +63,56 @@ class PickList(Document):
# if the user has not entered any picked qty, set it to stock_qty, before submit # if the user has not entered any picked qty, set it to stock_qty, before submit
item.picked_qty = item.stock_qty item.picked_qty = item.stock_qty
if not frappe.get_cached_value("Item", item.item_code, "has_serial_no"):
continue
if not item.serial_no:
frappe.throw(
_("Row #{0}: {1} does not have any available serial numbers in {2}").format(
frappe.bold(item.idx), frappe.bold(item.item_code), frappe.bold(item.warehouse)
),
title=_("Serial Nos Required"),
)
if len(item.serial_no.split("\n")) != item.picked_qty:
frappe.throw(
_(
"For item {0} at row {1}, count of serial numbers does not match with the picked quantity"
).format(frappe.bold(item.item_code), frappe.bold(item.idx)),
title=_("Quantity Mismatch"),
)
def on_submit(self): def on_submit(self):
self.validate_serial_and_batch_bundle()
self.update_status() self.update_status()
self.update_bundle_picked_qty() self.update_bundle_picked_qty()
self.update_reference_qty() self.update_reference_qty()
self.update_sales_order_picking_status() self.update_sales_order_picking_status()
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = "Serial and Batch Bundle"
self.update_status() self.update_status()
self.update_bundle_picked_qty() self.update_bundle_picked_qty()
self.update_reference_qty() self.update_reference_qty()
self.update_sales_order_picking_status() self.update_sales_order_picking_status()
self.delink_serial_and_batch_bundle()
def update_status(self, status=None): def delink_serial_and_batch_bundle(self):
for row in self.locations:
if row.serial_and_batch_bundle:
frappe.db.set_value(
"Serial and Batch Bundle",
row.serial_and_batch_bundle,
{"is_cancelled": 1, "voucher_no": ""},
)
row.db_set("serial_and_batch_bundle", None)
def on_update(self):
self.linked_serial_and_batch_bundle()
def linked_serial_and_batch_bundle(self):
for row in self.locations:
if row.serial_and_batch_bundle:
frappe.get_doc(
"Serial and Batch Bundle", row.serial_and_batch_bundle
).set_serial_and_batch_values(self, row)
def remove_serial_and_batch_bundle(self):
for row in self.locations:
if row.serial_and_batch_bundle:
frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
def validate_serial_and_batch_bundle(self):
for row in self.locations:
if row.serial_and_batch_bundle:
doc = frappe.get_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
if doc.docstatus == 0:
doc.submit()
def update_status(self, status=None, update_modified=True):
if not status: if not status:
if self.docstatus == 0: if self.docstatus == 0:
status = "Draft" status = "Draft"
@ -192,6 +214,7 @@ class PickList(Document):
locations_replica = self.get("locations") locations_replica = self.get("locations")
# reset # reset
self.remove_serial_and_batch_bundle()
self.delete_key("locations") self.delete_key("locations")
updated_locations = frappe._dict() updated_locations = frappe._dict()
for item_doc in items: for item_doc in items:
@ -347,6 +370,7 @@ class PickList(Document):
pi_item.item_code, pi_item.item_code,
pi_item.warehouse, pi_item.warehouse,
pi_item.batch_no, pi_item.batch_no,
pi_item.serial_and_batch_bundle,
Sum(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_( Sum(Case().when(pi_item.picked_qty > 0, pi_item.picked_qty).else_(pi_item.stock_qty)).as_(
"picked_qty" "picked_qty"
), ),
@ -476,18 +500,13 @@ def get_items_with_location_and_quantity(item_doc, item_location_map, docstatus)
if not stock_qty: if not stock_qty:
break break
serial_nos = None
if item_location.serial_no:
serial_nos = "\n".join(item_location.serial_no[0 : cint(stock_qty)])
locations.append( locations.append(
frappe._dict( frappe._dict(
{ {
"qty": qty, "qty": qty,
"stock_qty": stock_qty, "stock_qty": stock_qty,
"warehouse": item_location.warehouse, "warehouse": item_location.warehouse,
"serial_no": serial_nos, "serial_and_batch_bundle": item_location.serial_and_batch_bundle,
"batch_no": item_location.batch_no,
} }
) )
) )
@ -523,11 +542,7 @@ def get_available_item_locations(
has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no") has_serial_no = frappe.get_cached_value("Item", item_code, "has_serial_no")
has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no") has_batch_no = frappe.get_cached_value("Item", item_code, "has_batch_no")
if has_batch_no and has_serial_no: if has_serial_no:
locations = get_available_item_locations_for_serial_and_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty
)
elif has_serial_no:
locations = get_available_item_locations_for_serialized_item( locations = get_available_item_locations_for_serialized_item(
item_code, from_warehouses, required_qty, company, total_picked_qty item_code, from_warehouses, required_qty, company, total_picked_qty
) )
@ -553,23 +568,6 @@ def get_available_item_locations(
if picked_item_details: if picked_item_details:
for location in list(locations): for location in list(locations):
key = (
(location["warehouse"], location["batch_no"])
if location.get("batch_no")
else location["warehouse"]
)
if key in picked_item_details:
picked_detail = picked_item_details[key]
if picked_detail.get("serial_no") and location.get("serial_no"):
location["serial_no"] = list(
set(location["serial_no"]).difference(set(picked_detail["serial_no"]))
)
location["qty"] = len(location["serial_no"])
else:
location["qty"] -= picked_detail.get("picked_qty")
if location["qty"] < 1: if location["qty"] < 1:
locations.remove(location) locations.remove(location)
@ -595,7 +593,7 @@ def get_available_item_locations_for_serialized_item(
frappe.qb.from_(sn) frappe.qb.from_(sn)
.select(sn.name, sn.warehouse) .select(sn.name, sn.warehouse)
.where((sn.item_code == item_code) & (sn.company == company)) .where((sn.item_code == item_code) & (sn.company == company))
.orderby(sn.purchase_date) .orderby(sn.creation)
.limit(cint(required_qty + total_picked_qty)) .limit(cint(required_qty + total_picked_qty))
) )
@ -607,12 +605,39 @@ def get_available_item_locations_for_serialized_item(
serial_nos = query.run(as_list=True) serial_nos = query.run(as_list=True)
warehouse_serial_nos_map = frappe._dict() warehouse_serial_nos_map = frappe._dict()
picked_qty = required_qty
for serial_no, warehouse in serial_nos: for serial_no, warehouse in serial_nos:
if picked_qty <= 0:
break
warehouse_serial_nos_map.setdefault(warehouse, []).append(serial_no) warehouse_serial_nos_map.setdefault(warehouse, []).append(serial_no)
picked_qty -= 1
locations = [] locations = []
for warehouse, serial_nos in warehouse_serial_nos_map.items(): for warehouse, serial_nos in warehouse_serial_nos_map.items():
locations.append({"qty": len(serial_nos), "warehouse": warehouse, "serial_no": serial_nos}) qty = len(serial_nos)
bundle_doc = SerialBatchCreation(
{
"item_code": item_code,
"warehouse": warehouse,
"voucher_type": "Pick List",
"total_qty": qty * -1,
"serial_nos": serial_nos,
"type_of_transaction": "Outward",
"company": company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
locations.append(
{
"qty": qty,
"warehouse": warehouse,
"item_code": item_code,
"serial_and_batch_bundle": bundle_doc.name,
}
)
return locations return locations
@ -620,63 +645,48 @@ def get_available_item_locations_for_serialized_item(
def get_available_item_locations_for_batched_item( def get_available_item_locations_for_batched_item(
item_code, from_warehouses, required_qty, company, total_picked_qty=0 item_code, from_warehouses, required_qty, company, total_picked_qty=0
): ):
sle = frappe.qb.DocType("Stock Ledger Entry") locations = []
batch = frappe.qb.DocType("Batch") data = get_auto_batch_nos(
frappe._dict(
query = ( {
frappe.qb.from_(sle) "item_code": item_code,
.from_(batch) "warehouse": from_warehouses,
.select(sle.warehouse, sle.batch_no, Sum(sle.actual_qty).as_("qty")) "qty": required_qty + total_picked_qty,
.where( }
(sle.batch_no == batch.name)
& (sle.item_code == item_code)
& (sle.company == company)
& (batch.disabled == 0)
& (sle.is_cancelled == 0)
& (IfNull(batch.expiry_date, "2200-01-01") > today())
) )
.groupby(sle.warehouse, sle.batch_no, sle.item_code)
.having(Sum(sle.actual_qty) > 0)
.orderby(IfNull(batch.expiry_date, "2200-01-01"), batch.creation, sle.batch_no, sle.warehouse)
.limit(cint(required_qty + total_picked_qty))
) )
if from_warehouses: warehouse_wise_batches = frappe._dict()
query = query.where(sle.warehouse.isin(from_warehouses)) for d in data:
if d.warehouse not in warehouse_wise_batches:
warehouse_wise_batches.setdefault(d.warehouse, defaultdict(float))
return query.run(as_dict=True) warehouse_wise_batches[d.warehouse][d.batch_no] += d.qty
for warehouse, batches in warehouse_wise_batches.items():
qty = sum(batches.values())
def get_available_item_locations_for_serial_and_batched_item( bundle_doc = SerialBatchCreation(
item_code, from_warehouses, required_qty, company, total_picked_qty=0 {
): "item_code": item_code,
# Get batch nos by FIFO "warehouse": warehouse,
locations = get_available_item_locations_for_batched_item( "voucher_type": "Pick List",
item_code, from_warehouses, required_qty, company "total_qty": qty * -1,
) "batches": batches,
"type_of_transaction": "Outward",
"company": company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
if locations: locations.append(
sn = frappe.qb.DocType("Serial No") {
conditions = (sn.item_code == item_code) & (sn.company == company) "qty": qty,
"warehouse": warehouse,
for location in locations: "item_code": item_code,
location.qty = ( "serial_and_batch_bundle": bundle_doc.name,
required_qty if location.qty > required_qty else location.qty }
) # if extra qty in batch )
serial_nos = (
frappe.qb.from_(sn)
.select(sn.name)
.where(
(conditions) & (sn.batch_no == location.batch_no) & (sn.warehouse == location.warehouse)
)
.orderby(sn.purchase_date)
.limit(cint(location.qty + total_picked_qty))
).run(as_dict=True)
serial_nos = [sn.name for sn in serial_nos]
location.serial_no = serial_nos
location.qty = len(serial_nos)
return locations return locations

View File

@ -11,6 +11,11 @@ from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle from erpnext.stock.doctype.packed_item.test_packed_item import create_product_bundle
from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note from erpnext.stock.doctype.pick_list.pick_list import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
EmptyStockReconciliationItemsError, EmptyStockReconciliationItemsError,
@ -139,6 +144,18 @@ class TestPickList(FrappeTestCase):
self.assertEqual(pick_list.locations[1].qty, 10) self.assertEqual(pick_list.locations[1].qty, 10)
def test_pick_list_shows_serial_no_for_serialized_item(self): def test_pick_list_shows_serial_no_for_serialized_item(self):
serial_nos = ["SADD-0001", "SADD-0002", "SADD-0003", "SADD-0004", "SADD-0005"]
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
frappe.get_doc(
{
"doctype": "Serial No",
"company": "_Test Company",
"item_code": "_Test Serialized Item",
"serial_no": serial_no,
}
).insert()
stock_reconciliation = frappe.get_doc( stock_reconciliation = frappe.get_doc(
{ {
@ -151,7 +168,20 @@ class TestPickList(FrappeTestCase):
"warehouse": "_Test Warehouse - _TC", "warehouse": "_Test Warehouse - _TC",
"valuation_rate": 100, "valuation_rate": 100,
"qty": 5, "qty": 5,
"serial_no": "123450\n123451\n123452\n123453\n123454", "serial_and_batch_bundle": make_serial_batch_bundle(
frappe._dict(
{
"item_code": "_Test Serialized Item",
"warehouse": "_Test Warehouse - _TC",
"qty": 5,
"rate": 100,
"type_of_transaction": "Inward",
"do_not_submit": True,
"voucher_type": "Stock Reconciliation",
"serial_nos": serial_nos,
}
)
).name,
} }
], ],
} }
@ -162,6 +192,10 @@ class TestPickList(FrappeTestCase):
except EmptyStockReconciliationItemsError: except EmptyStockReconciliationItemsError:
pass pass
so = make_sales_order(
item_code="_Test Serialized Item", warehouse="_Test Warehouse - _TC", qty=5, rate=1000
)
pick_list = frappe.get_doc( pick_list = frappe.get_doc(
{ {
"doctype": "Pick List", "doctype": "Pick List",
@ -175,18 +209,20 @@ class TestPickList(FrappeTestCase):
"qty": 1000, "qty": 1000,
"stock_qty": 1000, "stock_qty": 1000,
"conversion_factor": 1, "conversion_factor": 1,
"sales_order": "_T-Sales Order-1", "sales_order": so.name,
"sales_order_item": "_T-Sales Order-1_item", "sales_order_item": so.items[0].name,
} }
], ],
} }
) )
pick_list.set_item_locations() pick_list.save()
self.assertEqual(pick_list.locations[0].item_code, "_Test Serialized Item") self.assertEqual(pick_list.locations[0].item_code, "_Test Serialized Item")
self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC") self.assertEqual(pick_list.locations[0].warehouse, "_Test Warehouse - _TC")
self.assertEqual(pick_list.locations[0].qty, 5) self.assertEqual(pick_list.locations[0].qty, 5)
self.assertEqual(pick_list.locations[0].serial_no, "123450\n123451\n123452\n123453\n123454") self.assertEqual(
get_serial_nos_from_bundle(pick_list.locations[0].serial_and_batch_bundle), serial_nos
)
def test_pick_list_shows_batch_no_for_batched_item(self): def test_pick_list_shows_batch_no_for_batched_item(self):
# check if oldest batch no is picked # check if oldest batch no is picked
@ -245,8 +281,8 @@ class TestPickList(FrappeTestCase):
pr1 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0) pr1 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
pr1.load_from_db() pr1.load_from_db()
oldest_batch_no = pr1.items[0].batch_no oldest_batch_no = get_batch_from_bundle(pr1.items[0].serial_and_batch_bundle)
oldest_serial_nos = pr1.items[0].serial_no oldest_serial_nos = get_serial_nos_from_bundle(pr1.items[0].serial_and_batch_bundle)
pr2 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0) pr2 = make_purchase_receipt(item_code="Batched and Serialised Item", qty=2, rate=100.0)
@ -267,8 +303,12 @@ class TestPickList(FrappeTestCase):
) )
pick_list.set_item_locations() pick_list.set_item_locations()
self.assertEqual(pick_list.locations[0].batch_no, oldest_batch_no) self.assertEqual(
self.assertEqual(pick_list.locations[0].serial_no, oldest_serial_nos) get_batch_from_bundle(pick_list.locations[0].serial_and_batch_bundle), oldest_batch_no
)
self.assertEqual(
get_serial_nos_from_bundle(pick_list.locations[0].serial_and_batch_bundle), oldest_serial_nos
)
pr1.cancel() pr1.cancel()
pr2.cancel() pr2.cancel()
@ -697,114 +737,3 @@ class TestPickList(FrappeTestCase):
pl.cancel() pl.cancel()
pl.reload() pl.reload()
self.assertEqual(pl.status, "Cancelled") self.assertEqual(pl.status, "Cancelled")
def test_consider_existing_pick_list(self):
def create_items(items_properties):
items = []
for properties in items_properties:
properties.update({"maintain_stock": 1})
item_code = make_item(properties=properties).name
properties.update({"item_code": item_code})
items.append(properties)
return items
def create_stock_entries(items):
warehouses = ["Stores - _TC", "Finished Goods - _TC"]
for item in items:
for warehouse in warehouses:
se = make_stock_entry(
item=item.get("item_code"),
to_warehouse=warehouse,
qty=5,
)
def get_item_list(items, qty, warehouse="All Warehouses - _TC"):
return [
{
"item_code": item.get("item_code"),
"qty": qty,
"warehouse": warehouse,
}
for item in items
]
def get_picked_items_details(pick_list_doc):
items_data = {}
for location in pick_list_doc.locations:
key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse
serial_no = [x for x in location.serial_no.split("\n") if x] if location.serial_no else None
data = {"picked_qty": location.picked_qty}
if serial_no:
data["serial_no"] = serial_no
if location.item_code not in items_data:
items_data[location.item_code] = {key: data}
else:
items_data[location.item_code][key] = data
return items_data
# Step - 1: Setup - Create Items and Stock Entries
items_properties = [
{
"valuation_rate": 100,
},
{
"valuation_rate": 200,
"has_batch_no": 1,
"create_new_batch": 1,
},
{
"valuation_rate": 300,
"has_serial_no": 1,
"serial_no_series": "SNO.###",
},
{
"valuation_rate": 400,
"has_batch_no": 1,
"create_new_batch": 1,
"has_serial_no": 1,
"serial_no_series": "SNO.###",
},
]
items = create_items(items_properties)
create_stock_entries(items)
# Step - 2: Create Sales Order [1]
so1 = make_sales_order(item_list=get_item_list(items, qty=6))
# Step - 3: Create and Submit Pick List [1] for Sales Order [1]
pl1 = create_pick_list(so1.name)
pl1.submit()
# Step - 4: Create Sales Order [2] with same Item(s) as Sales Order [1]
so2 = make_sales_order(item_list=get_item_list(items, qty=4))
# Step - 5: Create Pick List [2] for Sales Order [2]
pl2 = create_pick_list(so2.name)
pl2.save()
# Step - 6: Assert
picked_items_details = get_picked_items_details(pl1)
for location in pl2.locations:
key = (location.warehouse, location.batch_no) if location.batch_no else location.warehouse
item_data = picked_items_details.get(location.item_code, {}).get(key, {})
picked_qty = item_data.get("picked_qty", 0)
picked_serial_no = picked_items_details.get("serial_no", [])
bin_actual_qty = frappe.db.get_value(
"Bin", {"item_code": location.item_code, "warehouse": location.warehouse}, "actual_qty"
)
# Available Qty to pick should be equal to [Actual Qty - Picked Qty]
self.assertEqual(location.stock_qty, bin_actual_qty - picked_qty)
# Serial No should not be in the Picked Serial No list
if location.serial_no:
a = set(picked_serial_no)
b = set([x for x in location.serial_no.split("\n") if x])
self.assertSetEqual(b, b.difference(a))

View File

@ -21,6 +21,8 @@
"conversion_factor", "conversion_factor",
"stock_uom", "stock_uom",
"serial_no_and_batch_section", "serial_no_and_batch_section",
"pick_serial_and_batch",
"serial_and_batch_bundle",
"serial_no", "serial_no",
"column_break_20", "column_break_20",
"batch_no", "batch_no",
@ -72,14 +74,16 @@
"depends_on": "serial_no", "depends_on": "serial_no",
"fieldname": "serial_no", "fieldname": "serial_no",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Serial No" "label": "Serial No",
"read_only": 1
}, },
{ {
"depends_on": "batch_no", "depends_on": "batch_no",
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Batch No", "label": "Batch No",
"options": "Batch" "options": "Batch",
"read_only": 1
}, },
{ {
"fieldname": "column_break_2", "fieldname": "column_break_2",
@ -187,11 +191,24 @@
"hidden": 1, "hidden": 1,
"label": "Product Bundle Item", "label": "Product Bundle Item",
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"fieldname": "pick_serial_and_batch",
"fieldtype": "Button",
"label": "Pick Serial / Batch No"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2022-04-22 05:27:38.497997", "modified": "2023-03-12 13:50:22.258100",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Pick List Item", "name": "Pick List Item",
@ -202,4 +219,4 @@
"sort_order": "DESC", "sort_order": "DESC",
"states": [], "states": [],
"track_changes": 1 "track_changes": 1
} }

View File

@ -118,9 +118,7 @@ class PurchaseReceipt(BuyingController):
self.validate_posting_time() self.validate_posting_time()
super(PurchaseReceipt, self).validate() super(PurchaseReceipt, self).validate()
if self._action == "submit": if self._action != "submit":
self.make_batches("warehouse")
else:
self.set_status() self.set_status()
self.po_required() self.po_required()
@ -242,11 +240,6 @@ class PurchaseReceipt(BuyingController):
# because updating ordered qty, reserved_qty_for_subcontract in bin # because updating ordered qty, reserved_qty_for_subcontract in bin
# depends upon updated ordered qty in PO # depends upon updated ordered qty in PO
self.update_stock_ledger() self.update_stock_ledger()
from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit
update_serial_nos_after_submit(self, "items")
self.make_gl_entries() self.make_gl_entries()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.set_consumed_qty_in_subcontract_order() self.set_consumed_qty_in_subcontract_order()
@ -283,7 +276,12 @@ class PurchaseReceipt(BuyingController):
self.update_stock_ledger() self.update_stock_ledger()
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Serial and Batch Bundle",
)
self.delete_auto_created_batches() self.delete_auto_created_batches()
self.set_consumed_qty_in_subcontract_order() self.set_consumed_qty_in_subcontract_order()

View File

@ -3,7 +3,7 @@
import frappe import frappe
from frappe.tests.utils import FrappeTestCase, change_settings from frappe.tests.utils import FrappeTestCase, change_settings
from frappe.utils import add_days, cint, cstr, flt, today from frappe.utils import add_days, cint, cstr, flt, nowtime, today
from pypika import functions as fn from pypika import functions as fn
import erpnext import erpnext
@ -11,7 +11,16 @@ from erpnext.accounts.doctype.account.test_account import get_inventory_account
from erpnext.controllers.buying_controller import QtyMismatchError from erpnext.controllers.buying_controller import QtyMismatchError
from erpnext.stock.doctype.item.test_item import create_item, make_item from erpnext.stock.doctype.item.test_item import create_item, make_item
from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice from erpnext.stock.doctype.purchase_receipt.purchase_receipt import make_purchase_invoice
from erpnext.stock.doctype.serial_no.serial_no import SerialNoDuplicateError, get_serial_nos from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
SerialNoDuplicateError,
SerialNoExistsInFutureTransactionError,
)
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction from erpnext.stock.stock_ledger import SerialNoExistsInFutureTransaction
@ -184,14 +193,11 @@ class TestPurchaseReceipt(FrappeTestCase):
self.assertTrue(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name})) self.assertTrue(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
pr.load_from_db() pr.load_from_db()
batch_no = pr.items[0].batch_no
pr.cancel() pr.cancel()
self.assertFalse(frappe.db.get_value("Batch", {"item": item.name, "reference_name": pr.name}))
self.assertFalse(frappe.db.get_all("Serial No", {"batch_no": batch_no}))
def test_duplicate_serial_nos(self): def test_duplicate_serial_nos(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
item = frappe.db.exists("Item", {"item_name": "Test Serialized Item 123"}) item = frappe.db.exists("Item", {"item_name": "Test Serialized Item 123"})
if not item: if not item:
@ -206,67 +212,86 @@ class TestPurchaseReceipt(FrappeTestCase):
pr = make_purchase_receipt(item_code=item.name, qty=2, rate=500) pr = make_purchase_receipt(item_code=item.name, qty=2, rate=500)
pr.load_from_db() pr.load_from_db()
serial_nos = frappe.db.get_value( bundle_id = frappe.db.get_value(
"Stock Ledger Entry", "Stock Ledger Entry",
{"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "item_code": item.name}, {"voucher_type": "Purchase Receipt", "voucher_no": pr.name, "item_code": item.name},
"serial_no", "serial_and_batch_bundle",
) )
serial_nos = get_serial_nos(serial_nos) serial_nos = get_serial_nos_from_bundle(bundle_id)
self.assertEquals(get_serial_nos(pr.items[0].serial_no), serial_nos) self.assertEquals(get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle), serial_nos)
# Then tried to receive same serial nos in difference company bundle_id = make_serial_batch_bundle(
pr_different_company = make_purchase_receipt( frappe._dict(
item_code=item.name, {
qty=2, "item_code": item.item_code,
rate=500, "warehouse": "_Test Warehouse 2 - _TC1",
serial_no="\n".join(serial_nos), "company": "_Test Company 1",
company="_Test Company 1", "qty": 2,
do_not_submit=True, "voucher_type": "Purchase Receipt",
warehouse="Stores - _TC1", "serial_nos": serial_nos,
"posting_date": today(),
"posting_time": nowtime(),
"do_not_save": True,
}
)
) )
self.assertRaises(SerialNoDuplicateError, pr_different_company.submit) self.assertRaises(SerialNoDuplicateError, bundle_id.make_serial_and_batch_bundle)
# Then made delivery note to remove the serial nos from stock # Then made delivery note to remove the serial nos from stock
dn = create_delivery_note(item_code=item.name, qty=2, rate=1500, serial_no="\n".join(serial_nos)) dn = create_delivery_note(item_code=item.name, qty=2, rate=1500, serial_no=serial_nos)
dn.load_from_db() dn.load_from_db()
self.assertEquals(get_serial_nos(dn.items[0].serial_no), serial_nos) self.assertEquals(get_serial_nos_from_bundle(dn.items[0].serial_and_batch_bundle), serial_nos)
posting_date = add_days(today(), -3) posting_date = add_days(today(), -3)
# Try to receive same serial nos again in the same company with backdated. # Try to receive same serial nos again in the same company with backdated.
pr1 = make_purchase_receipt( bundle_id = make_serial_batch_bundle(
item_code=item.name, frappe._dict(
qty=2, {
rate=500, "item_code": item.item_code,
posting_date=posting_date, "warehouse": "_Test Warehouse - _TC",
serial_no="\n".join(serial_nos), "company": "_Test Company",
do_not_submit=True, "qty": 2,
"rate": 500,
"voucher_type": "Purchase Receipt",
"serial_nos": serial_nos,
"posting_date": posting_date,
"posting_time": nowtime(),
"do_not_save": True,
}
)
) )
self.assertRaises(SerialNoExistsInFutureTransaction, pr1.submit) self.assertRaises(SerialNoExistsInFutureTransactionError, bundle_id.make_serial_and_batch_bundle)
# Try to receive same serial nos with different company with backdated. # Try to receive same serial nos with different company with backdated.
pr2 = make_purchase_receipt( bundle_id = make_serial_batch_bundle(
item_code=item.name, frappe._dict(
qty=2, {
rate=500, "item_code": item.item_code,
posting_date=posting_date, "warehouse": "_Test Warehouse 2 - _TC1",
serial_no="\n".join(serial_nos), "company": "_Test Company 1",
company="_Test Company 1", "qty": 2,
do_not_submit=True, "rate": 500,
warehouse="Stores - _TC1", "voucher_type": "Purchase Receipt",
"serial_nos": serial_nos,
"posting_date": posting_date,
"posting_time": nowtime(),
"do_not_save": True,
}
)
) )
self.assertRaises(SerialNoExistsInFutureTransaction, pr2.submit) self.assertRaises(SerialNoExistsInFutureTransactionError, bundle_id.make_serial_and_batch_bundle)
# Receive the same serial nos after the delivery note posting date and time # Receive the same serial nos after the delivery note posting date and time
make_purchase_receipt(item_code=item.name, qty=2, rate=500, serial_no="\n".join(serial_nos)) make_purchase_receipt(item_code=item.name, qty=2, rate=500, serial_no=serial_nos)
# Raise the error for backdated deliver note entry cancel # Raise the error for backdated deliver note entry cancel
self.assertRaises(SerialNoExistsInFutureTransaction, dn.cancel) # self.assertRaises(SerialNoExistsInFutureTransactionError, dn.cancel)
def test_purchase_receipt_gl_entry(self): def test_purchase_receipt_gl_entry(self):
pr = make_purchase_receipt( pr = make_purchase_receipt(
@ -307,11 +332,13 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.cancel() pr.cancel()
self.assertTrue(get_gl_entries("Purchase Receipt", pr.name)) self.assertTrue(get_gl_entries("Purchase Receipt", pr.name))
def test_serial_no_supplier(self): def test_serial_no_warehouse(self):
pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1) pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1)
pr_row_1_serial_no = pr.get("items")[0].serial_no pr_row_1_serial_no = get_serial_nos_from_bundle(pr.get("items")[0].serial_and_batch_bundle)[0]
self.assertEqual(frappe.db.get_value("Serial No", pr_row_1_serial_no, "supplier"), pr.supplier) self.assertEqual(
frappe.db.get_value("Serial No", pr_row_1_serial_no, "warehouse"), pr.get("items")[0].warehouse
)
pr.cancel() pr.cancel()
self.assertFalse(frappe.db.get_value("Serial No", pr_row_1_serial_no, "warehouse")) self.assertFalse(frappe.db.get_value("Serial No", pr_row_1_serial_no, "warehouse"))
@ -325,15 +352,18 @@ class TestPurchaseReceipt(FrappeTestCase):
pr.get("items")[0].rejected_warehouse = "_Test Rejected Warehouse - _TC" pr.get("items")[0].rejected_warehouse = "_Test Rejected Warehouse - _TC"
pr.insert() pr.insert()
pr.submit() pr.submit()
pr.load_from_db()
accepted_serial_nos = pr.get("items")[0].serial_no.split("\n") accepted_serial_nos = get_serial_nos_from_bundle(pr.get("items")[0].serial_and_batch_bundle)
self.assertEqual(len(accepted_serial_nos), 3) self.assertEqual(len(accepted_serial_nos), 3)
for serial_no in accepted_serial_nos: for serial_no in accepted_serial_nos:
self.assertEqual( self.assertEqual(
frappe.db.get_value("Serial No", serial_no, "warehouse"), pr.get("items")[0].warehouse frappe.db.get_value("Serial No", serial_no, "warehouse"), pr.get("items")[0].warehouse
) )
rejected_serial_nos = pr.get("items")[0].rejected_serial_no.split("\n") rejected_serial_nos = get_serial_nos_from_bundle(
pr.get("items")[0].rejected_serial_and_batch_bundle
)
self.assertEqual(len(rejected_serial_nos), 2) self.assertEqual(len(rejected_serial_nos), 2)
for serial_no in rejected_serial_nos: for serial_no in rejected_serial_nos:
self.assertEqual( self.assertEqual(
@ -556,23 +586,21 @@ class TestPurchaseReceipt(FrappeTestCase):
pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1) pr = make_purchase_receipt(item_code="_Test Serialized Item With Series", qty=1)
serial_no = get_serial_nos(pr.get("items")[0].serial_no)[0] serial_no = get_serial_nos_from_bundle(pr.get("items")[0].serial_and_batch_bundle)[0]
_check_serial_no_values( _check_serial_no_values(serial_no, {"warehouse": "_Test Warehouse - _TC"})
serial_no, {"warehouse": "_Test Warehouse - _TC", "purchase_document_no": pr.name}
)
return_pr = make_purchase_receipt( return_pr = make_purchase_receipt(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=-1, qty=-1,
is_return=1, is_return=1,
return_against=pr.name, return_against=pr.name,
serial_no=serial_no, serial_no=[serial_no],
) )
_check_serial_no_values( _check_serial_no_values(
serial_no, serial_no,
{"warehouse": "", "purchase_document_no": pr.name, "delivery_document_no": return_pr.name}, {"warehouse": ""},
) )
return_pr.cancel() return_pr.cancel()
@ -677,20 +705,23 @@ class TestPurchaseReceipt(FrappeTestCase):
item_code = "Test Manual Created Serial No" item_code = "Test Manual Created Serial No"
if not frappe.db.exists("Item", item_code): if not frappe.db.exists("Item", item_code):
item = make_item(item_code, dict(has_serial_no=1)) make_item(item_code, dict(has_serial_no=1))
serial_no = ["12903812901"]
if not frappe.db.exists("Serial No", serial_no[0]):
frappe.get_doc(
{"doctype": "Serial No", "item_code": item_code, "serial_no": serial_no[0]}
).insert()
serial_no = "12903812901"
pr_doc = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no) pr_doc = make_purchase_receipt(item_code=item_code, qty=1, serial_no=serial_no)
pr_doc.load_from_db()
self.assertEqual( bundle_id = pr_doc.items[0].serial_and_batch_bundle
serial_no, self.assertEqual(serial_no[0], get_serial_nos_from_bundle(bundle_id)[0])
frappe.db.get_value(
"Serial No",
{"purchase_document_type": "Purchase Receipt", "purchase_document_no": pr_doc.name},
"name",
),
)
voucher_no = frappe.db.get_value("Serial and Batch Bundle", bundle_id, "voucher_no")
self.assertEqual(voucher_no, pr_doc.name)
pr_doc.cancel() pr_doc.cancel()
# check for the auto created serial nos # check for the auto created serial nos
@ -699,16 +730,15 @@ class TestPurchaseReceipt(FrappeTestCase):
make_item(item_code, dict(has_serial_no=1, serial_no_series="KLJL.###")) make_item(item_code, dict(has_serial_no=1, serial_no_series="KLJL.###"))
new_pr_doc = make_purchase_receipt(item_code=item_code, qty=1) new_pr_doc = make_purchase_receipt(item_code=item_code, qty=1)
new_pr_doc.load_from_db()
serial_no = get_serial_nos(new_pr_doc.items[0].serial_no)[0] bundle_id = new_pr_doc.items[0].serial_and_batch_bundle
self.assertEqual( serial_no = get_serial_nos_from_bundle(bundle_id)[0]
serial_no, self.assertTrue(serial_no)
frappe.db.get_value(
"Serial No", voucher_no = frappe.db.get_value("Serial and Batch Bundle", bundle_id, "voucher_no")
{"purchase_document_type": "Purchase Receipt", "purchase_document_no": new_pr_doc.name},
"name", self.assertEqual(voucher_no, new_pr_doc.name)
),
)
new_pr_doc.cancel() new_pr_doc.cancel()
@ -1491,7 +1521,7 @@ class TestPurchaseReceipt(FrappeTestCase):
) )
pi.load_from_db() pi.load_from_db()
batch_no = pi.items[0].batch_no batch_no = get_batch_from_bundle(pi.items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1)) frappe.db.set_value("Batch", batch_no, "expiry_date", add_days(today(), -1))
@ -1917,6 +1947,30 @@ def make_purchase_receipt(**args):
item_code = args.item or args.item_code or "_Test Item" item_code = args.item or args.item_code or "_Test Item"
uom = args.uom or frappe.db.get_value("Item", item_code, "stock_uom") or "_Test UOM" uom = args.uom or frappe.db.get_value("Item", item_code, "stock_uom") or "_Test UOM"
bundle_id = None
if args.get("batch_no") or args.get("serial_no"):
batches = {}
if args.get("batch_no"):
batches = frappe._dict({args.batch_no: qty})
serial_nos = args.get("serial_no") or []
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": item_code,
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": qty,
"batches": batches,
"voucher_type": "Purchase Receipt",
"serial_nos": serial_nos,
"posting_date": args.posting_date or today(),
"posting_time": args.posting_time,
}
)
).name
pr.append( pr.append(
"items", "items",
{ {
@ -1931,8 +1985,7 @@ def make_purchase_receipt(**args):
"rate": args.rate if args.rate != None else 50, "rate": args.rate if args.rate != None else 50,
"conversion_factor": args.conversion_factor or 1.0, "conversion_factor": args.conversion_factor or 1.0,
"stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0), "stock_qty": flt(qty) * (flt(args.conversion_factor) or 1.0),
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle_id,
"batch_no": args.batch_no,
"stock_uom": args.stock_uom or "_Test UOM", "stock_uom": args.stock_uom or "_Test UOM",
"uom": uom, "uom": uom,
"cost_center": args.cost_center "cost_center": args.cost_center
@ -1958,6 +2011,9 @@ def make_purchase_receipt(**args):
pr.insert() pr.insert()
if not args.do_not_submit: if not args.do_not_submit:
pr.submit() pr.submit()
pr.load_from_db()
return pr return pr

View File

@ -79,6 +79,7 @@
"purchase_order", "purchase_order",
"purchase_invoice", "purchase_invoice",
"column_break_40", "column_break_40",
"allow_zero_valuation_rate",
"is_fixed_asset", "is_fixed_asset",
"asset_location", "asset_location",
"asset_category", "asset_category",
@ -91,14 +92,19 @@
"delivery_note_item", "delivery_note_item",
"putaway_rule", "putaway_rule",
"section_break_45", "section_break_45",
"allow_zero_valuation_rate", "add_serial_batch_bundle",
"bom", "serial_and_batch_bundle",
"serial_no",
"col_break5", "col_break5",
"include_exploded_items", "add_serial_batch_for_rejected_qty",
"batch_no", "rejected_serial_and_batch_bundle",
"section_break_3vxt",
"serial_no",
"rejected_serial_no", "rejected_serial_no",
"item_tax_rate", "column_break_tolu",
"batch_no",
"subcontract_bom_section",
"include_exploded_items",
"bom",
"item_weight_details", "item_weight_details",
"weight_per_unit", "weight_per_unit",
"total_weight", "total_weight",
@ -110,6 +116,7 @@
"manufacturer_part_no", "manufacturer_part_no",
"accounting_details_section", "accounting_details_section",
"expense_account", "expense_account",
"item_tax_rate",
"column_break_102", "column_break_102",
"provisional_expense_account", "provisional_expense_account",
"accounting_dimensions_section", "accounting_dimensions_section",
@ -565,37 +572,8 @@
}, },
{ {
"fieldname": "section_break_45", "fieldname": "section_break_45",
"fieldtype": "Section Break" "fieldtype": "Section Break",
}, "label": "Serial and Batch No"
{
"depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "serial_no",
"fieldtype": "Small Text",
"in_list_view": 1,
"label": "Serial No",
"no_copy": 1,
"oldfieldname": "serial_no",
"oldfieldtype": "Text"
},
{
"depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "batch_no",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Batch No",
"no_copy": 1,
"oldfieldname": "batch_no",
"oldfieldtype": "Link",
"options": "Batch",
"print_hide": 1
},
{
"depends_on": "eval:!doc.is_fixed_asset",
"fieldname": "rejected_serial_no",
"fieldtype": "Small Text",
"label": "Rejected Serial No",
"no_copy": 1,
"print_hide": 1
}, },
{ {
"fieldname": "item_tax_template", "fieldname": "item_tax_template",
@ -1016,12 +994,70 @@
"no_copy": 1, "no_copy": 1,
"print_hide": 1, "print_hide": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"depends_on": "eval:parent.is_old_subcontracting_flow",
"fieldname": "subcontract_bom_section",
"fieldtype": "Section Break",
"label": "Subcontract BOM"
},
{
"fieldname": "serial_no",
"fieldtype": "Text",
"label": "Serial No",
"read_only": 1
},
{
"fieldname": "rejected_serial_no",
"fieldtype": "Text",
"label": "Rejected Serial No",
"read_only": 1
},
{
"fieldname": "batch_no",
"fieldtype": "Link",
"label": "Batch No",
"options": "Batch",
"read_only": 1
},
{
"fieldname": "rejected_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Rejected Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle"
},
{
"fieldname": "add_serial_batch_for_rejected_qty",
"fieldtype": "Button",
"label": "Add Serial / Batch No (Rejected Qty)"
},
{
"fieldname": "section_break_3vxt",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_tolu",
"fieldtype": "Column Break"
},
{
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch No"
} }
], ],
"idx": 1, "idx": 1,
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-02-28 15:43:04.470104", "modified": "2023-03-12 13:37:47.778021",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Purchase Receipt Item", "name": "Purchase Receipt Item",

View File

@ -11,7 +11,6 @@ from frappe import _
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import cint, cstr, floor, flt, nowdate from frappe.utils import cint, cstr, floor, flt, nowdate
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance from erpnext.stock.utils import get_stock_balance
@ -99,7 +98,6 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
item = frappe._dict(item) item = frappe._dict(item)
source_warehouse = item.get("s_warehouse") source_warehouse = item.get("s_warehouse")
serial_nos = get_serial_nos(item.get("serial_no"))
item.conversion_factor = flt(item.conversion_factor) or 1.0 item.conversion_factor = flt(item.conversion_factor) or 1.0
pending_qty, item_code = flt(item.qty), item.item_code pending_qty, item_code = flt(item.qty), item.item_code
pending_stock_qty = flt(item.transfer_qty) if doctype == "Stock Entry" else flt(item.stock_qty) pending_stock_qty = flt(item.transfer_qty) if doctype == "Stock Entry" else flt(item.stock_qty)
@ -145,9 +143,7 @@ def apply_putaway_rule(doctype, items, company, sync=None, purpose=None):
if not qty_to_allocate: if not qty_to_allocate:
break break
updated_table = add_row( updated_table = add_row(item, qty_to_allocate, rule.warehouse, updated_table, rule.name)
item, qty_to_allocate, rule.warehouse, updated_table, rule.name, serial_nos=serial_nos
)
pending_stock_qty -= stock_qty_to_allocate pending_stock_qty -= stock_qty_to_allocate
pending_qty -= qty_to_allocate pending_qty -= qty_to_allocate
@ -245,7 +241,7 @@ def get_ordered_putaway_rules(item_code, company, source_warehouse=None):
return False, vacant_rules return False, vacant_rules
def add_row(item, to_allocate, warehouse, updated_table, rule=None, serial_nos=None): def add_row(item, to_allocate, warehouse, updated_table, rule=None):
new_updated_table_row = copy.deepcopy(item) new_updated_table_row = copy.deepcopy(item)
new_updated_table_row.idx = 1 if not updated_table else cint(updated_table[-1].idx) + 1 new_updated_table_row.idx = 1 if not updated_table else cint(updated_table[-1].idx) + 1
new_updated_table_row.name = None new_updated_table_row.name = None
@ -264,8 +260,8 @@ def add_row(item, to_allocate, warehouse, updated_table, rule=None, serial_nos=N
if rule: if rule:
new_updated_table_row.putaway_rule = rule new_updated_table_row.putaway_rule = rule
if serial_nos:
new_updated_table_row.serial_no = get_serial_nos_to_allocate(serial_nos, to_allocate) new_updated_table_row.serial_and_batch_bundle = ""
updated_table.append(new_updated_table_row) updated_table.append(new_updated_table_row)
return updated_table return updated_table
@ -297,12 +293,3 @@ def show_unassigned_items_message(items_not_accomodated):
) )
frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True) frappe.msgprint(msg, title=_("Insufficient Capacity"), is_minimizable=True, wide=True)
def get_serial_nos_to_allocate(serial_nos, to_allocate):
if serial_nos:
allocated_serial_nos = serial_nos[0 : cint(to_allocate)]
serial_nos[:] = serial_nos[cint(to_allocate) :] # pop out allocated serial nos and modify list
return "\n".join(allocated_serial_nos) if allocated_serial_nos else ""
else:
return ""

View File

@ -7,6 +7,11 @@ from frappe.tests.utils import FrappeTestCase
from erpnext.stock.doctype.batch.test_batch import make_new_batch from erpnext.stock.doctype.batch.test_batch import make_new_batch
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
from erpnext.stock.get_item_details import get_conversion_factor from erpnext.stock.get_item_details import get_conversion_factor
@ -382,42 +387,49 @@ class TestPutawayRule(FrappeTestCase):
make_new_batch(batch_id="BOTTL-BATCH-1", item_code="Water Bottle") make_new_batch(batch_id="BOTTL-BATCH-1", item_code="Water Bottle")
pr = make_purchase_receipt(item_code="Water Bottle", qty=5, do_not_submit=1) pr = make_purchase_receipt(item_code="Water Bottle", qty=5, do_not_submit=1)
pr.items[0].batch_no = "BOTTL-BATCH-1"
pr.save() pr.save()
pr.submit() pr.submit()
pr.load_from_db()
serial_nos = frappe.get_list( batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
"Serial No", filters={"purchase_document_no": pr.name, "status": "Active"} serial_nos = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)
)
serial_nos = [d.name for d in serial_nos]
stock_entry = make_stock_entry( stock_entry = make_stock_entry(
item_code="Water Bottle", item_code="Water Bottle",
source="_Test Warehouse - _TC", source="_Test Warehouse - _TC",
qty=5, qty=5,
serial_no=serial_nos,
target="Finished Goods - _TC", target="Finished Goods - _TC",
purpose="Material Transfer", purpose="Material Transfer",
apply_putaway_rule=1, apply_putaway_rule=1,
do_not_save=1, do_not_save=1,
) )
stock_entry.items[0].batch_no = "BOTTL-BATCH-1"
stock_entry.items[0].serial_no = "\n".join(serial_nos)
stock_entry.save() stock_entry.save()
stock_entry.load_from_db()
self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1) self.assertEqual(stock_entry.items[0].t_warehouse, self.warehouse_1)
self.assertEqual(stock_entry.items[0].qty, 3) self.assertEqual(stock_entry.items[0].qty, 3)
self.assertEqual(stock_entry.items[0].putaway_rule, rule_1.name) self.assertEqual(stock_entry.items[0].putaway_rule, rule_1.name)
self.assertEqual(stock_entry.items[0].serial_no, "\n".join(serial_nos[:3])) self.assertEqual(
self.assertEqual(stock_entry.items[0].batch_no, "BOTTL-BATCH-1") get_serial_nos_from_bundle(stock_entry.items[0].serial_and_batch_bundle), serial_nos[0:3]
)
self.assertEqual(get_batch_from_bundle(stock_entry.items[0].serial_and_batch_bundle), batch_no)
self.assertEqual(stock_entry.items[1].t_warehouse, self.warehouse_2) self.assertEqual(stock_entry.items[1].t_warehouse, self.warehouse_2)
self.assertEqual(stock_entry.items[1].qty, 2) self.assertEqual(stock_entry.items[1].qty, 2)
self.assertEqual(stock_entry.items[1].putaway_rule, rule_2.name) self.assertEqual(stock_entry.items[1].putaway_rule, rule_2.name)
self.assertEqual(stock_entry.items[1].serial_no, "\n".join(serial_nos[3:])) self.assertEqual(
self.assertEqual(stock_entry.items[1].batch_no, "BOTTL-BATCH-1") get_serial_nos_from_bundle(stock_entry.items[1].serial_and_batch_bundle), serial_nos[3:5]
)
self.assertEqual(get_batch_from_bundle(stock_entry.items[1].serial_and_batch_bundle), batch_no)
self.assertUnchangedItemsOnResave(stock_entry) self.assertUnchangedItemsOnResave(stock_entry)
for row in stock_entry.items:
if row.serial_and_batch_bundle:
frappe.delete_doc("Serial and Batch Bundle", row.serial_and_batch_bundle)
stock_entry.load_from_db()
stock_entry.delete() stock_entry.delete()
pr.cancel() pr.cancel()
rule_1.delete() rule_1.delete()

View File

@ -0,0 +1,206 @@
// Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
// For license information, please see license.txt
frappe.ui.form.on('Serial and Batch Bundle', {
setup(frm) {
frm.trigger('set_queries');
},
refresh(frm) {
frm.trigger('toggle_fields');
frm.trigger('prepare_serial_batch_prompt');
},
item_code(frm) {
frm.clear_custom_buttons();
frm.trigger('prepare_serial_batch_prompt');
},
type_of_transaction(frm) {
frm.clear_custom_buttons();
frm.trigger('prepare_serial_batch_prompt');
},
warehouse(frm) {
if (frm.doc.warehouse) {
frm.call({
method: "set_warehouse",
doc: frm.doc,
callback(r) {
refresh_field("entries");
}
})
}
},
has_serial_no(frm) {
frm.trigger('toggle_fields');
},
has_batch_no(frm) {
frm.trigger('toggle_fields');
},
prepare_serial_batch_prompt(frm) {
if (frm.doc.docstatus === 0 && frm.doc.item_code
&& frm.doc.type_of_transaction === "Inward") {
let label = frm.doc?.has_serial_no === 1
? __('Serial Nos') : __('Batch Nos');
if (frm.doc?.has_serial_no === 1 && frm.doc?.has_batch_no === 1) {
label = __('Serial and Batch Nos');
}
let fields = frm.events.get_prompt_fields(frm);
frm.add_custom_button(__("Make " + label), () => {
frappe.prompt(fields, (data) => {
frm.events.add_serial_batch(frm, data);
}, "Add " + label, "Make " + label);
});
}
},
get_prompt_fields(frm) {
let attach_field = {
"label": __("Attach CSV File"),
"fieldname": "csv_file",
"fieldtype": "Attach"
}
if (!frm.doc.has_batch_no) {
attach_field.depends_on = "eval:doc.using_csv_file === 1"
}
let fields = [
{
"label": __("Using CSV File"),
"fieldname": "using_csv_file",
"default": 1,
"fieldtype": "Check",
},
attach_field,
{
"fieldtype": "Section Break",
}
]
if (frm.doc.has_serial_no) {
fields.push({
"label": "Serial Nos",
"fieldname": "serial_nos",
"fieldtype": "Small Text",
"depends_on": "eval:doc.using_csv_file === 0"
})
}
if (frm.doc.has_batch_no) {
fields = attach_field
}
return fields;
},
add_serial_batch(frm, prompt_data) {
frm.events.validate_prompt_data(frm, prompt_data);
frm.call({
method: "add_serial_batch",
doc: frm.doc,
args: {
"data": prompt_data,
},
callback(r) {
refresh_field("entries");
}
});
},
validate_prompt_data(frm, prompt_data) {
if (prompt_data.using_csv_file && !prompt_data.csv_file) {
frappe.throw(__("Please attach CSV file"));
}
if (frm.doc.has_serial_no && !prompt_data.using_csv_file && !prompt_data.serial_nos) {
frappe.throw(__("Please enter serial nos"));
}
},
toggle_fields(frm) {
frm.fields_dict.entries.grid.update_docfield_property(
'serial_no', 'read_only', !frm.doc.has_serial_no
);
frm.fields_dict.entries.grid.update_docfield_property(
'batch_no', 'read_only', !frm.doc.has_batch_no
);
},
set_queries(frm) {
frm.set_query('item_code', () => {
return {
query: 'erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle.item_query',
};
});
frm.set_query('voucher_type', () => {
return {
filters: {
'istable': 0,
'issingle': 0,
'is_submittable': 1,
}
};
});
frm.set_query('voucher_no', () => {
return {
filters: {
'docstatus': ["!=", 2],
}
};
});
frm.set_query('warehouse', () => {
return {
filters: {
'is_group': 0,
'company': frm.doc.company,
}
};
});
frm.set_query('serial_no', 'entries', () => {
return {
filters: {
item_code: frm.doc.item_code,
}
};
});
frm.set_query('batch_no', 'entries', () => {
return {
filters: {
item: frm.doc.item_code,
}
};
});
frm.set_query('warehouse', 'entries', () => {
return {
filters: {
company: frm.doc.company,
}
};
});
}
});
frappe.ui.form.on("Serial and Batch Entry", {
ledgers_add(frm, cdt, cdn) {
if (frm.doc.warehouse) {
locals[cdt][cdn].warehouse = frm.doc.warehouse;
}
},
})

View File

@ -0,0 +1,273 @@
{
"actions": [],
"autoname": "naming_series:",
"creation": "2022-09-29 14:56:38.338267",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"item_details_tab",
"naming_series",
"company",
"item_name",
"has_serial_no",
"has_batch_no",
"column_break_4",
"item_code",
"warehouse",
"type_of_transaction",
"serial_no_and_batch_no_tab",
"entries",
"quantity_and_rate_section",
"total_qty",
"item_group",
"column_break_13",
"avg_rate",
"total_amount",
"tab_break_12",
"voucher_type",
"voucher_no",
"voucher_detail_no",
"column_break_aouy",
"posting_date",
"posting_time",
"returned_against",
"section_break_wzou",
"is_cancelled",
"is_rejected",
"amended_from"
],
"fields": [
{
"fieldname": "item_details_tab",
"fieldtype": "Tab Break",
"label": "Serial and Batch"
},
{
"fieldname": "company",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Company",
"options": "Company",
"reqd": 1
},
{
"fetch_from": "item_code.item_group",
"fieldname": "item_group",
"fieldtype": "Link",
"hidden": 1,
"label": "Item Group",
"options": "Item Group"
},
{
"default": "0",
"fetch_from": "item_code.has_serial_no",
"fieldname": "has_serial_no",
"fieldtype": "Check",
"label": "Has Serial No",
"read_only": 1
},
{
"fieldname": "column_break_4",
"fieldtype": "Column Break"
},
{
"fieldname": "item_code",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Item Code",
"options": "Item",
"reqd": 1
},
{
"fetch_from": "item_code.item_name",
"fieldname": "item_name",
"fieldtype": "Data",
"label": "Item Name",
"read_only": 1
},
{
"default": "0",
"fetch_from": "item_code.has_batch_no",
"fieldname": "has_batch_no",
"fieldtype": "Check",
"label": "Has Batch No",
"read_only": 1
},
{
"fieldname": "serial_no_and_batch_no_tab",
"fieldtype": "Section Break",
"label": "Serial / Batch No"
},
{
"fieldname": "voucher_type",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Voucher Type",
"options": "DocType",
"reqd": 1
},
{
"fieldname": "voucher_no",
"fieldtype": "Dynamic Link",
"label": "Voucher No",
"no_copy": 1,
"options": "voucher_type"
},
{
"default": "0",
"fieldname": "is_cancelled",
"fieldtype": "Check",
"label": "Is Cancelled",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "amended_from",
"fieldtype": "Link",
"label": "Amended From",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1,
"read_only": 1
},
{
"fieldname": "tab_break_12",
"fieldtype": "Tab Break",
"label": "Reference"
},
{
"collapsible": 1,
"fieldname": "quantity_and_rate_section",
"fieldtype": "Tab Break",
"label": "Quantity and Rate"
},
{
"fieldname": "column_break_13",
"fieldtype": "Column Break"
},
{
"fieldname": "avg_rate",
"fieldtype": "Float",
"label": "Avg Rate",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "total_amount",
"fieldtype": "Float",
"label": "Total Amount",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "total_qty",
"fieldtype": "Float",
"label": "Total Qty",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_aouy",
"fieldtype": "Column Break"
},
{
"depends_on": "company",
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Warehouse",
"mandatory_depends_on": "eval:doc.type_of_transaction != \"Maintenance\"",
"options": "Warehouse"
},
{
"fieldname": "type_of_transaction",
"fieldtype": "Select",
"label": "Type of Transaction",
"options": "\nInward\nOutward\nMaintenance\nAsset Repair",
"reqd": 1
},
{
"fieldname": "naming_series",
"fieldtype": "Select",
"label": "Naming Series",
"options": "SBB-.####"
},
{
"default": "0",
"depends_on": "eval:doc.voucher_type == \"Purchase Receipt\"",
"fieldname": "is_rejected",
"fieldtype": "Check",
"label": "Is Rejected",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "section_break_wzou",
"fieldtype": "Section Break"
},
{
"fieldname": "posting_date",
"fieldtype": "Date",
"label": "Posting Date",
"no_copy": 1
},
{
"fieldname": "posting_time",
"fieldtype": "Time",
"label": "Posting Time",
"no_copy": 1
},
{
"fieldname": "voucher_detail_no",
"fieldtype": "Data",
"label": "Voucher Detail No",
"no_copy": 1,
"read_only": 1
},
{
"allow_bulk_edit": 1,
"fieldname": "entries",
"fieldtype": "Table",
"options": "Serial and Batch Entry",
"reqd": 1
},
{
"fieldname": "returned_against",
"fieldtype": "Data",
"label": "Returned Against",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-04-10 20:02:42.964309",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Bundle",
"naming_rule": "By \"Naming Series\" field",
"owner": "Administrator",
"permissions": [
{
"cancel": 1,
"create": 1,
"delete": 1,
"email": 1,
"export": 1,
"print": 1,
"read": 1,
"report": 1,
"role": "System Manager",
"share": 1,
"submit": 1,
"write": 1
}
],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"title_field": "item_code"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,418 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
# See license.txt
import json
import frappe
from frappe.tests.utils import FrappeTestCase
from frappe.utils import add_days, add_to_date, flt, nowdate, nowtime, today
from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
class TestSerialandBatchBundle(FrappeTestCase):
def test_inward_outward_serial_valuation(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
serial_item_code = "New Serial No Valuation 1"
make_item(
serial_item_code,
{
"has_serial_no": 1,
"serial_no_series": "TEST-SER-VAL-.#####",
"is_stock_item": 1,
},
)
pr = make_purchase_receipt(
item_code=serial_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
)
serial_no1 = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
pr = make_purchase_receipt(
item_code=serial_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=300
)
serial_no2 = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
dn = create_delivery_note(
item_code=serial_item_code,
warehouse="_Test Warehouse - _TC",
qty=1,
rate=1500,
serial_no=[serial_no2],
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -300)
dn = create_delivery_note(
item_code=serial_item_code,
warehouse="_Test Warehouse - _TC",
qty=1,
rate=1500,
serial_no=[serial_no1],
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -500)
def test_inward_outward_batch_valuation(self):
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
batch_item_code = "New Batch No Valuation 1"
make_item(
batch_item_code,
{
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-BATTCCH-VAL-.#####",
"is_stock_item": 1,
},
)
pr = make_purchase_receipt(
item_code=batch_item_code, warehouse="_Test Warehouse - _TC", qty=10, rate=500
)
batch_no1 = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
pr = make_purchase_receipt(
item_code=batch_item_code, warehouse="_Test Warehouse - _TC", qty=10, rate=300
)
batch_no2 = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
dn = create_delivery_note(
item_code=batch_item_code,
warehouse="_Test Warehouse - _TC",
qty=10,
rate=1500,
batch_no=batch_no2,
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -3000)
dn = create_delivery_note(
item_code=batch_item_code,
warehouse="_Test Warehouse - _TC",
qty=10,
rate=1500,
batch_no=batch_no1,
)
stock_value_difference = frappe.db.get_value(
"Stock Ledger Entry",
{"voucher_no": dn.name, "is_cancelled": 0, "voucher_type": "Delivery Note"},
"stock_value_difference",
)
self.assertEqual(flt(stock_value_difference, 2), -5000)
def test_old_batch_valuation(self):
frappe.flags.ignore_serial_batch_bundle_validation = True
batch_item_code = "Old Batch Item Valuation 1"
make_item(
batch_item_code,
{
"has_batch_no": 1,
"is_stock_item": 1,
},
)
batch_id = "Old Batch 1"
if not frappe.db.exists("Batch", batch_id):
batch_doc = frappe.get_doc(
{
"doctype": "Batch",
"batch_id": batch_id,
"item": batch_item_code,
"use_batchwise_valuation": 0,
}
).insert(ignore_permissions=True)
self.assertTrue(batch_doc.use_batchwise_valuation)
batch_doc.db_set("use_batchwise_valuation", 0)
stock_queue = []
qty_after_transaction = 0
balance_value = 0
for qty, valuation in {10: 100, 20: 200}.items():
stock_queue.append([qty, valuation])
qty_after_transaction += qty
balance_value += qty_after_transaction * valuation
doc = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"posting_date": today(),
"posting_time": nowtime(),
"batch_no": batch_id,
"incoming_rate": valuation,
"qty_after_transaction": qty_after_transaction,
"stock_value_difference": valuation * qty,
"balance_value": balance_value,
"valuation_rate": balance_value / qty_after_transaction,
"actual_qty": qty,
"item_code": batch_item_code,
"warehouse": "_Test Warehouse - _TC",
"stock_queue": json.dumps(stock_queue),
}
)
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
doc.flags.ignore_links = True
doc.flags.ignore_validate = True
doc.submit()
bundle_doc = make_serial_batch_bundle(
{
"item_code": batch_item_code,
"warehouse": "_Test Warehouse - _TC",
"voucher_type": "Stock Entry",
"posting_date": today(),
"posting_time": nowtime(),
"qty": -10,
"batches": frappe._dict({batch_id: 10}),
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
bundle_doc.reload()
for row in bundle_doc.entries:
self.assertEqual(flt(row.stock_value_difference, 2), -1666.67)
bundle_doc.flags.ignore_permissions = True
bundle_doc.flags.ignore_mandatory = True
bundle_doc.flags.ignore_links = True
bundle_doc.flags.ignore_validate = True
bundle_doc.submit()
bundle_doc = make_serial_batch_bundle(
{
"item_code": batch_item_code,
"warehouse": "_Test Warehouse - _TC",
"voucher_type": "Stock Entry",
"posting_date": today(),
"posting_time": nowtime(),
"qty": -20,
"batches": frappe._dict({batch_id: 20}),
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
bundle_doc.reload()
for row in bundle_doc.entries:
self.assertEqual(flt(row.stock_value_difference, 2), -3333.33)
bundle_doc.flags.ignore_permissions = True
bundle_doc.flags.ignore_mandatory = True
bundle_doc.flags.ignore_links = True
bundle_doc.flags.ignore_validate = True
bundle_doc.submit()
frappe.flags.ignore_serial_batch_bundle_validation = False
def test_old_serial_no_valuation(self):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
serial_no_item_code = "Old Serial No Item Valuation 1"
make_item(
serial_no_item_code,
{
"has_serial_no": 1,
"serial_no_series": "TEST-SER-VALL-.#####",
"is_stock_item": 1,
},
)
make_purchase_receipt(
item_code=serial_no_item_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
)
frappe.flags.ignore_serial_batch_bundle_validation = True
serial_no_id = "Old Serial No 1"
if not frappe.db.exists("Serial No", serial_no_id):
sn_doc = frappe.get_doc(
{
"doctype": "Serial No",
"serial_no": serial_no_id,
"item_code": serial_no_item_code,
"company": "_Test Company",
}
).insert(ignore_permissions=True)
sn_doc.db_set(
{
"warehouse": "_Test Warehouse - _TC",
"purchase_rate": 100,
}
)
doc = frappe.get_doc(
{
"doctype": "Stock Ledger Entry",
"posting_date": today(),
"posting_time": nowtime(),
"serial_no": serial_no_id,
"incoming_rate": 100,
"qty_after_transaction": 1,
"stock_value_difference": 100,
"balance_value": 100,
"valuation_rate": 100,
"actual_qty": 1,
"item_code": serial_no_item_code,
"warehouse": "_Test Warehouse - _TC",
"company": "_Test Company",
}
)
doc.flags.ignore_permissions = True
doc.flags.ignore_mandatory = True
doc.flags.ignore_links = True
doc.flags.ignore_validate = True
doc.submit()
bundle_doc = make_serial_batch_bundle(
{
"item_code": serial_no_item_code,
"warehouse": "_Test Warehouse - _TC",
"voucher_type": "Stock Entry",
"posting_date": today(),
"posting_time": nowtime(),
"qty": -1,
"serial_nos": [serial_no_id],
"type_of_transaction": "Outward",
"do_not_submit": True,
}
)
bundle_doc.reload()
for row in bundle_doc.entries:
self.assertEqual(flt(row.stock_value_difference, 2), -100.00)
def test_batch_not_belong_to_serial_no(self):
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
serial_and_batch_code = "New Serial No Valuation 1"
make_item(
serial_and_batch_code,
{
"has_serial_no": 1,
"serial_no_series": "TEST-SER-VALL-.#####",
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "TEST-SNBAT-VAL-.#####",
},
)
pr = make_purchase_receipt(
item_code=serial_and_batch_code, warehouse="_Test Warehouse - _TC", qty=1, rate=500
)
serial_no = get_serial_nos_from_bundle(pr.items[0].serial_and_batch_bundle)[0]
pr = make_purchase_receipt(
item_code=serial_and_batch_code, warehouse="_Test Warehouse - _TC", qty=1, rate=300
)
batch_no = get_batch_from_bundle(pr.items[0].serial_and_batch_bundle)
doc = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"item_code": serial_and_batch_code,
"warehouse": "_Test Warehouse - _TC",
"voucher_type": "Stock Entry",
"posting_date": today(),
"posting_time": nowtime(),
"qty": -1,
"type_of_transaction": "Outward",
}
)
doc.append(
"entries",
{
"batch_no": batch_no,
"serial_no": serial_no,
"qty": -1,
},
)
# Batch does not belong to serial no
self.assertRaises(frappe.exceptions.ValidationError, doc.save)
def get_batch_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_batch_nos
batches = get_batch_nos(bundle)
return list(batches.keys())[0]
def get_serial_nos_from_bundle(bundle):
from erpnext.stock.serial_batch_bundle import get_serial_nos
serial_nos = get_serial_nos(bundle)
return sorted(serial_nos) if serial_nos else []
def make_serial_batch_bundle(kwargs):
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
if isinstance(kwargs, dict):
kwargs = frappe._dict(kwargs)
type_of_transaction = "Inward" if kwargs.qty > 0 else "Outward"
if kwargs.get("type_of_transaction"):
type_of_transaction = kwargs.get("type_of_transaction")
sb = SerialBatchCreation(
{
"item_code": kwargs.item_code,
"warehouse": kwargs.warehouse,
"voucher_type": kwargs.voucher_type,
"voucher_no": kwargs.voucher_no,
"posting_date": kwargs.posting_date,
"posting_time": kwargs.posting_time,
"qty": kwargs.qty,
"avg_rate": kwargs.rate,
"batches": kwargs.batches,
"serial_nos": kwargs.serial_nos,
"type_of_transaction": type_of_transaction,
"company": kwargs.company or "_Test Company",
"do_not_submit": kwargs.do_not_submit,
}
)
if not kwargs.get("do_not_save"):
return sb.make_serial_and_batch_bundle()
return sb

View File

@ -0,0 +1,121 @@
{
"actions": [],
"creation": "2022-09-29 14:55:15.909881",
"doctype": "DocType",
"editable_grid": 1,
"engine": "InnoDB",
"field_order": [
"serial_no",
"batch_no",
"column_break_2",
"qty",
"warehouse",
"section_break_6",
"incoming_rate",
"column_break_8",
"outgoing_rate",
"stock_value_difference",
"is_outward",
"stock_queue"
],
"fields": [
{
"depends_on": "eval:parent.has_serial_no == 1",
"fieldname": "serial_no",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Serial No",
"mandatory_depends_on": "eval:parent.has_serial_no == 1",
"options": "Serial No",
"search_index": 1
},
{
"depends_on": "eval:parent.has_batch_no == 1",
"fieldname": "batch_no",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Batch No",
"mandatory_depends_on": "eval:parent.has_batch_no == 1",
"options": "Batch",
"search_index": 1
},
{
"default": "1",
"fieldname": "qty",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Qty"
},
{
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Warehouse",
"options": "Warehouse",
"search_index": 1
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
},
{
"collapsible": 1,
"fieldname": "section_break_6",
"fieldtype": "Section Break",
"label": "Rate Section"
},
{
"fieldname": "incoming_rate",
"fieldtype": "Float",
"label": "Incoming Rate",
"no_copy": 1,
"read_only": 1,
"read_only_depends_on": "eval:parent.type_of_transaction == \"Outward\""
},
{
"fieldname": "outgoing_rate",
"fieldtype": "Float",
"label": "Outgoing Rate",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break_8",
"fieldtype": "Column Break"
},
{
"fieldname": "stock_value_difference",
"fieldtype": "Float",
"label": "Change in Stock Value",
"no_copy": 1,
"read_only": 1
},
{
"default": "0",
"fieldname": "is_outward",
"fieldtype": "Check",
"label": "Is Outward",
"read_only": 1
},
{
"fieldname": "stock_queue",
"fieldtype": "Small Text",
"label": "FIFO Stock Queue (qty, rate)",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2023-03-31 11:18:59.809486",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Entry",
"owner": "Administrator",
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": []
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt
# import frappe
from frappe.model.document import Document
class SerialandBatchEntry(Document):
pass

View File

@ -12,24 +12,15 @@
"column_break0", "column_break0",
"serial_no", "serial_no",
"item_code", "item_code",
"warehouse",
"batch_no", "batch_no",
"warehouse",
"purchase_rate",
"column_break1", "column_break1",
"status",
"item_name", "item_name",
"description", "description",
"item_group", "item_group",
"brand", "brand",
"sales_order",
"purchase_details",
"column_break2",
"purchase_document_type",
"purchase_document_no",
"purchase_date",
"purchase_time",
"purchase_rate",
"column_break3",
"supplier",
"supplier_name",
"asset_details", "asset_details",
"asset", "asset",
"asset_status", "asset_status",
@ -38,14 +29,6 @@
"employee", "employee",
"delivery_details", "delivery_details",
"delivery_document_type", "delivery_document_type",
"delivery_document_no",
"delivery_date",
"delivery_time",
"column_break5",
"customer",
"customer_name",
"invoice_details",
"sales_invoice",
"warranty_amc_details", "warranty_amc_details",
"column_break6", "column_break6",
"warranty_expiry_date", "warranty_expiry_date",
@ -54,9 +37,8 @@
"maintenance_status", "maintenance_status",
"warranty_period", "warranty_period",
"more_info", "more_info",
"serial_no_details",
"company", "company",
"status", "column_break_2cmm",
"work_order" "work_order"
], ],
"fields": [ "fields": [
@ -90,40 +72,20 @@
"options": "Item", "options": "Item",
"reqd": 1 "reqd": 1
}, },
{
"description": "Warehouse can only be changed via Stock Entry / Delivery Note / Purchase Receipt",
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Warehouse",
"no_copy": 1,
"oldfieldname": "warehouse",
"oldfieldtype": "Link",
"options": "Warehouse",
"read_only": 1,
"search_index": 1
},
{
"fieldname": "batch_no",
"fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Batch No",
"options": "Batch",
"read_only": 1
},
{ {
"fieldname": "column_break1", "fieldname": "column_break1",
"fieldtype": "Column Break" "fieldtype": "Column Break"
}, },
{ {
"fetch_from": "item_code.item_name",
"fetch_if_empty": 1,
"fieldname": "item_name", "fieldname": "item_name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Item Name", "label": "Item Name",
"read_only": 1 "read_only": 1
}, },
{ {
"fetch_from": "item_code.description",
"fieldname": "description", "fieldname": "description",
"fieldtype": "Text", "fieldtype": "Text",
"label": "Description", "label": "Description",
@ -150,84 +112,6 @@
"options": "Brand", "options": "Brand",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "sales_order",
"fieldtype": "Link",
"label": "Sales Order",
"options": "Sales Order"
},
{
"fieldname": "purchase_details",
"fieldtype": "Section Break",
"label": "Purchase / Manufacture Details"
},
{
"fieldname": "column_break2",
"fieldtype": "Column Break",
"width": "50%"
},
{
"fieldname": "purchase_document_type",
"fieldtype": "Link",
"label": "Creation Document Type",
"no_copy": 1,
"options": "DocType",
"read_only": 1
},
{
"fieldname": "purchase_document_no",
"fieldtype": "Dynamic Link",
"label": "Creation Document No",
"no_copy": 1,
"options": "purchase_document_type",
"read_only": 1
},
{
"fieldname": "purchase_date",
"fieldtype": "Date",
"label": "Creation Date",
"no_copy": 1,
"oldfieldname": "purchase_date",
"oldfieldtype": "Date",
"read_only": 1
},
{
"fieldname": "purchase_time",
"fieldtype": "Time",
"label": "Creation Time",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "purchase_rate",
"fieldtype": "Currency",
"label": "Incoming Rate",
"no_copy": 1,
"oldfieldname": "purchase_rate",
"oldfieldtype": "Currency",
"options": "Company:company:default_currency",
"read_only": 1
},
{
"fieldname": "column_break3",
"fieldtype": "Column Break",
"width": "50%"
},
{
"fieldname": "supplier",
"fieldtype": "Link",
"label": "Supplier",
"no_copy": 1,
"options": "Supplier"
},
{
"bold": 1,
"fieldname": "supplier_name",
"fieldtype": "Data",
"label": "Supplier Name",
"no_copy": 1,
"read_only": 1
},
{ {
"fieldname": "asset_details", "fieldname": "asset_details",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -283,67 +167,6 @@
"options": "DocType", "options": "DocType",
"read_only": 1 "read_only": 1
}, },
{
"fieldname": "delivery_document_no",
"fieldtype": "Dynamic Link",
"label": "Delivery Document No",
"no_copy": 1,
"options": "delivery_document_type",
"read_only": 1
},
{
"fieldname": "delivery_date",
"fieldtype": "Date",
"label": "Delivery Date",
"no_copy": 1,
"oldfieldname": "delivery_date",
"oldfieldtype": "Date",
"read_only": 1
},
{
"fieldname": "delivery_time",
"fieldtype": "Time",
"label": "Delivery Time",
"no_copy": 1,
"read_only": 1
},
{
"fieldname": "column_break5",
"fieldtype": "Column Break",
"width": "50%"
},
{
"fieldname": "customer",
"fieldtype": "Link",
"label": "Customer",
"no_copy": 1,
"oldfieldname": "customer",
"oldfieldtype": "Link",
"options": "Customer",
"print_hide": 1
},
{
"bold": 1,
"fieldname": "customer_name",
"fieldtype": "Data",
"label": "Customer Name",
"no_copy": 1,
"oldfieldname": "customer_name",
"oldfieldtype": "Data",
"read_only": 1
},
{
"fieldname": "invoice_details",
"fieldtype": "Section Break",
"label": "Invoice Details"
},
{
"fieldname": "sales_invoice",
"fieldtype": "Link",
"label": "Sales Invoice",
"options": "Sales Invoice",
"read_only": 1
},
{ {
"fieldname": "warranty_amc_details", "fieldname": "warranty_amc_details",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -366,6 +189,7 @@
"width": "150px" "width": "150px"
}, },
{ {
"fetch_from": "item_code.warranty_period",
"fieldname": "warranty_period", "fieldname": "warranty_period",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Warranty Period (Days)", "label": "Warranty Period (Days)",
@ -400,14 +224,11 @@
"fieldtype": "Section Break", "fieldtype": "Section Break",
"label": "More Information" "label": "More Information"
}, },
{
"fieldname": "serial_no_details",
"fieldtype": "Text Editor",
"label": "Serial No Details"
},
{ {
"fieldname": "company", "fieldname": "company",
"fieldtype": "Link", "fieldtype": "Link",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Company", "label": "Company",
"options": "Company", "options": "Company",
"remember_last_selected_value": 1, "remember_last_selected_value": 1,
@ -415,25 +236,51 @@
"search_index": 1, "search_index": 1,
"set_only_once": 1 "set_only_once": 1
}, },
{
"fieldname": "status",
"fieldtype": "Select",
"in_standard_filter": 1,
"label": "Status",
"options": "\nActive\nInactive\nDelivered\nExpired",
"read_only": 1
},
{ {
"fieldname": "work_order", "fieldname": "work_order",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Work Order", "label": "Work Order",
"options": "Work Order" "options": "Work Order"
},
{
"fieldname": "warehouse",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Warehouse",
"options": "Warehouse",
"read_only": 1
},
{
"fieldname": "batch_no",
"fieldtype": "Link",
"label": "Batch No",
"options": "Batch",
"read_only": 1
},
{
"fieldname": "purchase_rate",
"fieldtype": "Float",
"label": "Incoming Rate",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"in_standard_filter": 1,
"label": "Status",
"options": "\nActive\nInactive\nExpired",
"read_only": 1
},
{
"fieldname": "column_break_2cmm",
"fieldtype": "Column Break"
} }
], ],
"icon": "fa fa-barcode", "icon": "fa fa-barcode",
"idx": 1, "idx": 1,
"links": [], "links": [],
"modified": "2023-04-14 15:58:46.139887", "modified": "2023-04-16 15:58:46.139887",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Serial No", "name": "Serial No",

View File

@ -9,19 +9,9 @@ import frappe
from frappe import ValidationError, _ from frappe import ValidationError, _
from frappe.model.naming import make_autoname from frappe.model.naming import make_autoname
from frappe.query_builder.functions import Coalesce from frappe.query_builder.functions import Coalesce
from frappe.utils import ( from frappe.utils import cint, cstr, getdate, nowdate, safe_json_loads
add_days,
cint,
cstr,
flt,
get_link_to_form,
getdate,
nowdate,
safe_json_loads,
)
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
from erpnext.stock.get_item_details import get_reserved_qty_for_so
class SerialNoCannotCreateDirectError(ValidationError): class SerialNoCannotCreateDirectError(ValidationError):
@ -32,38 +22,10 @@ class SerialNoCannotCannotChangeError(ValidationError):
pass pass
class SerialNoNotRequiredError(ValidationError):
pass
class SerialNoRequiredError(ValidationError):
pass
class SerialNoQtyError(ValidationError):
pass
class SerialNoItemError(ValidationError):
pass
class SerialNoWarehouseError(ValidationError): class SerialNoWarehouseError(ValidationError):
pass pass
class SerialNoBatchError(ValidationError):
pass
class SerialNoNotExistsError(ValidationError):
pass
class SerialNoDuplicateError(ValidationError):
pass
class SerialNo(StockController): class SerialNo(StockController):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(SerialNo, self).__init__(*args, **kwargs) super(SerialNo, self).__init__(*args, **kwargs)
@ -80,18 +42,14 @@ class SerialNo(StockController):
self.set_maintenance_status() self.set_maintenance_status()
self.validate_warehouse() self.validate_warehouse()
self.validate_item()
self.set_status()
def set_status(self): def validate_warehouse(self):
if self.delivery_document_type: if not self.get("__islocal"):
self.status = "Delivered" item_code, warehouse = frappe.db.get_value("Serial No", self.name, ["item_code", "warehouse"])
elif self.warranty_expiry_date and getdate(self.warranty_expiry_date) <= getdate(nowdate()): if not self.via_stock_ledger and item_code != self.item_code:
self.status = "Expired" frappe.throw(_("Item Code cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
elif not self.warehouse: if not self.via_stock_ledger and warehouse != self.warehouse:
self.status = "Inactive" frappe.throw(_("Warehouse cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
else:
self.status = "Active"
def set_maintenance_status(self): def set_maintenance_status(self):
if not self.warranty_expiry_date and not self.amc_expiry_date: if not self.warranty_expiry_date and not self.amc_expiry_date:
@ -109,137 +67,6 @@ class SerialNo(StockController):
if self.warranty_expiry_date and getdate(self.warranty_expiry_date) >= getdate(nowdate()): if self.warranty_expiry_date and getdate(self.warranty_expiry_date) >= getdate(nowdate()):
self.maintenance_status = "Under Warranty" self.maintenance_status = "Under Warranty"
def validate_warehouse(self):
if not self.get("__islocal"):
item_code, warehouse = frappe.db.get_value("Serial No", self.name, ["item_code", "warehouse"])
if not self.via_stock_ledger and item_code != self.item_code:
frappe.throw(_("Item Code cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
if not self.via_stock_ledger and warehouse != self.warehouse:
frappe.throw(_("Warehouse cannot be changed for Serial No."), SerialNoCannotCannotChangeError)
def validate_item(self):
"""
Validate whether serial no is required for this item
"""
item = frappe.get_cached_doc("Item", self.item_code)
if item.has_serial_no != 1:
frappe.throw(
_("Item {0} is not setup for Serial Nos. Check Item master").format(self.item_code)
)
self.item_group = item.item_group
self.description = item.description
self.item_name = item.item_name
self.brand = item.brand
self.warranty_period = item.warranty_period
def set_purchase_details(self, purchase_sle):
if purchase_sle:
self.purchase_document_type = purchase_sle.voucher_type
self.purchase_document_no = purchase_sle.voucher_no
self.purchase_date = purchase_sle.posting_date
self.purchase_time = purchase_sle.posting_time
self.purchase_rate = purchase_sle.incoming_rate
if purchase_sle.voucher_type in ("Purchase Receipt", "Purchase Invoice"):
self.supplier, self.supplier_name = frappe.db.get_value(
purchase_sle.voucher_type, purchase_sle.voucher_no, ["supplier", "supplier_name"]
)
# If sales return entry
if self.purchase_document_type == "Delivery Note":
self.sales_invoice = None
else:
for fieldname in (
"purchase_document_type",
"purchase_document_no",
"purchase_date",
"purchase_time",
"purchase_rate",
"supplier",
"supplier_name",
):
self.set(fieldname, None)
def set_sales_details(self, delivery_sle):
if delivery_sle:
self.delivery_document_type = delivery_sle.voucher_type
self.delivery_document_no = delivery_sle.voucher_no
self.delivery_date = delivery_sle.posting_date
self.delivery_time = delivery_sle.posting_time
if delivery_sle.voucher_type in ("Delivery Note", "Sales Invoice"):
self.customer, self.customer_name = frappe.db.get_value(
delivery_sle.voucher_type, delivery_sle.voucher_no, ["customer", "customer_name"]
)
if self.warranty_period:
self.warranty_expiry_date = add_days(
cstr(delivery_sle.posting_date), cint(self.warranty_period)
)
else:
for fieldname in (
"delivery_document_type",
"delivery_document_no",
"delivery_date",
"delivery_time",
"customer",
"customer_name",
"warranty_expiry_date",
):
self.set(fieldname, None)
def get_last_sle(self, serial_no=None):
entries = {}
sle_dict = self.get_stock_ledger_entries(serial_no)
if sle_dict:
if sle_dict.get("incoming", []):
entries["purchase_sle"] = sle_dict["incoming"][0]
if len(sle_dict.get("incoming", [])) - len(sle_dict.get("outgoing", [])) > 0:
entries["last_sle"] = sle_dict["incoming"][0]
else:
entries["last_sle"] = sle_dict["outgoing"][0]
entries["delivery_sle"] = sle_dict["outgoing"][0]
return entries
def get_stock_ledger_entries(self, serial_no=None):
sle_dict = {}
if not serial_no:
serial_no = self.name
for sle in frappe.db.sql(
"""
SELECT voucher_type, voucher_no,
posting_date, posting_time, incoming_rate, actual_qty, serial_no
FROM
`tabStock Ledger Entry`
WHERE
item_code=%s AND company = %s
AND is_cancelled = 0
AND (serial_no = %s
OR serial_no like %s
OR serial_no like %s
OR serial_no like %s
)
ORDER BY
posting_date desc, posting_time desc, creation desc""",
(
self.item_code,
self.company,
serial_no,
serial_no + "\n%",
"%\n" + serial_no,
"%\n" + serial_no + "\n%",
),
as_dict=1,
):
if serial_no.upper() in get_serial_nos(sle.serial_no):
if cint(sle.actual_qty) > 0:
sle_dict.setdefault("incoming", []).append(sle)
else:
sle_dict.setdefault("outgoing", []).append(sle)
return sle_dict
def on_trash(self): def on_trash(self):
sl_entries = frappe.db.sql( sl_entries = frappe.db.sql(
"""select serial_no from `tabStock Ledger Entry` """select serial_no from `tabStock Ledger Entry`
@ -260,305 +87,13 @@ class SerialNo(StockController):
_("Cannot delete Serial No {0}, as it is used in stock transactions").format(self.name) _("Cannot delete Serial No {0}, as it is used in stock transactions").format(self.name)
) )
def update_serial_no_reference(self, serial_no=None):
last_sle = self.get_last_sle(serial_no)
self.set_purchase_details(last_sle.get("purchase_sle"))
self.set_sales_details(last_sle.get("delivery_sle"))
self.set_maintenance_status()
self.set_status()
def get_available_serial_nos(serial_no_series, qty) -> List[str]:
def process_serial_no(sle):
item_det = get_item_details(sle.item_code)
validate_serial_no(sle, item_det)
update_serial_nos(sle, item_det)
def validate_serial_no(sle, item_det):
serial_nos = get_serial_nos(sle.serial_no) if sle.serial_no else []
validate_material_transfer_entry(sle)
if item_det.has_serial_no == 0:
if serial_nos:
frappe.throw(
_("Item {0} is not setup for Serial Nos. Column must be blank").format(sle.item_code),
SerialNoNotRequiredError,
)
elif not sle.is_cancelled:
if serial_nos:
if cint(sle.actual_qty) != flt(sle.actual_qty):
frappe.throw(
_("Serial No {0} quantity {1} cannot be a fraction").format(sle.item_code, sle.actual_qty)
)
if len(serial_nos) and len(serial_nos) != abs(cint(sle.actual_qty)):
frappe.throw(
_("{0} Serial Numbers required for Item {1}. You have provided {2}.").format(
abs(sle.actual_qty), sle.item_code, len(serial_nos)
),
SerialNoQtyError,
)
if len(serial_nos) != len(set(serial_nos)):
frappe.throw(
_("Duplicate Serial No entered for Item {0}").format(sle.item_code), SerialNoDuplicateError
)
for serial_no in serial_nos:
if frappe.db.exists("Serial No", serial_no):
sr = frappe.db.get_value(
"Serial No",
serial_no,
[
"name",
"item_code",
"batch_no",
"sales_order",
"delivery_document_no",
"delivery_document_type",
"warehouse",
"purchase_document_type",
"purchase_document_no",
"company",
"status",
],
as_dict=1,
)
if sr.item_code != sle.item_code:
if not allow_serial_nos_with_different_item(serial_no, sle):
frappe.throw(
_("Serial No {0} does not belong to Item {1}").format(serial_no, sle.item_code),
SerialNoItemError,
)
if cint(sle.actual_qty) > 0 and has_serial_no_exists(sr, sle):
doc_name = frappe.bold(get_link_to_form(sr.purchase_document_type, sr.purchase_document_no))
frappe.throw(
_("Serial No {0} has already been received in the {1} #{2}").format(
frappe.bold(serial_no), sr.purchase_document_type, doc_name
),
SerialNoDuplicateError,
)
if (
sr.delivery_document_no
and sle.voucher_type not in ["Stock Entry", "Stock Reconciliation"]
and sle.voucher_type == sr.delivery_document_type
):
return_against = frappe.db.get_value(sle.voucher_type, sle.voucher_no, "return_against")
if return_against and return_against != sr.delivery_document_no:
frappe.throw(_("Serial no {0} has been already returned").format(sr.name))
if cint(sle.actual_qty) < 0:
if sr.warehouse != sle.warehouse:
frappe.throw(
_("Serial No {0} does not belong to Warehouse {1}").format(serial_no, sle.warehouse),
SerialNoWarehouseError,
)
if not sr.purchase_document_no:
frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError)
if sle.voucher_type in ("Delivery Note", "Sales Invoice"):
if sr.batch_no and sr.batch_no != sle.batch_no:
frappe.throw(
_("Serial No {0} does not belong to Batch {1}").format(serial_no, sle.batch_no),
SerialNoBatchError,
)
if not sle.is_cancelled and not sr.warehouse:
frappe.throw(
_("Serial No {0} does not belong to any Warehouse").format(serial_no),
SerialNoWarehouseError,
)
# if Sales Order reference in Serial No validate the Delivery Note or Invoice is against the same
if sr.sales_order:
if sle.voucher_type == "Sales Invoice":
if not frappe.db.exists(
"Sales Invoice Item",
{"parent": sle.voucher_no, "item_code": sle.item_code, "sales_order": sr.sales_order},
):
frappe.throw(
_(
"Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}"
).format(sr.name, sle.item_code, sr.sales_order)
)
elif sle.voucher_type == "Delivery Note":
if not frappe.db.exists(
"Delivery Note Item",
{
"parent": sle.voucher_no,
"item_code": sle.item_code,
"against_sales_order": sr.sales_order,
},
):
invoice = frappe.db.get_value(
"Delivery Note Item",
{"parent": sle.voucher_no, "item_code": sle.item_code},
"against_sales_invoice",
)
if not invoice or frappe.db.exists(
"Sales Invoice Item",
{"parent": invoice, "item_code": sle.item_code, "sales_order": sr.sales_order},
):
frappe.throw(
_(
"Cannot deliver Serial No {0} of item {1} as it is reserved to fullfill Sales Order {2}"
).format(sr.name, sle.item_code, sr.sales_order)
)
# if Sales Order reference in Delivery Note or Invoice validate SO reservations for item
if sle.voucher_type == "Sales Invoice":
sales_order = frappe.db.get_value(
"Sales Invoice Item",
{"parent": sle.voucher_no, "item_code": sle.item_code},
"sales_order",
)
if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
validate_so_serial_no(sr, sales_order)
elif sle.voucher_type == "Delivery Note":
sales_order = frappe.get_value(
"Delivery Note Item",
{"parent": sle.voucher_no, "item_code": sle.item_code},
"against_sales_order",
)
if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
validate_so_serial_no(sr, sales_order)
else:
sales_invoice = frappe.get_value(
"Delivery Note Item",
{"parent": sle.voucher_no, "item_code": sle.item_code},
"against_sales_invoice",
)
if sales_invoice:
sales_order = frappe.db.get_value(
"Sales Invoice Item",
{"parent": sales_invoice, "item_code": sle.item_code},
"sales_order",
)
if sales_order and get_reserved_qty_for_so(sales_order, sle.item_code):
validate_so_serial_no(sr, sales_order)
elif cint(sle.actual_qty) < 0:
# transfer out
frappe.throw(_("Serial No {0} not in stock").format(serial_no), SerialNoNotExistsError)
elif cint(sle.actual_qty) < 0 or not item_det.serial_no_series:
frappe.throw(
_("Serial Nos Required for Serialized Item {0}").format(sle.item_code), SerialNoRequiredError
)
elif serial_nos:
# SLE is being cancelled and has serial nos
for serial_no in serial_nos:
check_serial_no_validity_on_cancel(serial_no, sle)
def check_serial_no_validity_on_cancel(serial_no, sle):
sr = frappe.db.get_value(
"Serial No", serial_no, ["name", "warehouse", "company", "status"], as_dict=1
)
sr_link = frappe.utils.get_link_to_form("Serial No", serial_no)
doc_link = frappe.utils.get_link_to_form(sle.voucher_type, sle.voucher_no)
actual_qty = cint(sle.actual_qty)
is_stock_reco = sle.voucher_type == "Stock Reconciliation"
msg = None
if sr and (actual_qty < 0 or is_stock_reco) and (sr.warehouse and sr.warehouse != sle.warehouse):
# receipt(inward) is being cancelled
msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the warehouse {3}").format(
sle.voucher_type, doc_link, sr_link, frappe.bold(sle.warehouse)
)
elif sr and actual_qty > 0 and not is_stock_reco:
# delivery is being cancelled, check for warehouse.
if sr.warehouse:
# serial no is active in another warehouse/company.
msg = _("Cannot cancel {0} {1} as Serial No {2} is active in warehouse {3}").format(
sle.voucher_type, doc_link, sr_link, frappe.bold(sr.warehouse)
)
elif sr.company != sle.company and sr.status == "Delivered":
# serial no is inactive (allowed) or delivered from another company (block).
msg = _("Cannot cancel {0} {1} as Serial No {2} does not belong to the company {3}").format(
sle.voucher_type, doc_link, sr_link, frappe.bold(sle.company)
)
if msg:
frappe.throw(msg, title=_("Cannot cancel"))
def validate_material_transfer_entry(sle_doc):
sle_doc.update({"skip_update_serial_no": False, "skip_serial_no_validaiton": False})
if (
sle_doc.voucher_type == "Stock Entry"
and not sle_doc.is_cancelled
and frappe.get_cached_value("Stock Entry", sle_doc.voucher_no, "purpose") == "Material Transfer"
):
if sle_doc.actual_qty < 0:
sle_doc.skip_update_serial_no = True
else:
sle_doc.skip_serial_no_validaiton = True
def validate_so_serial_no(sr, sales_order):
if not sr.sales_order or sr.sales_order != sales_order:
msg = _(
"Sales Order {0} has reservation for the item {1}, you can only deliver reserved {1} against {0}."
).format(sales_order, sr.item_code)
frappe.throw(_("""{0} Serial No {1} cannot be delivered""").format(msg, sr.name))
def has_serial_no_exists(sn, sle):
if (
sn.warehouse and not sle.skip_serial_no_validaiton and sle.voucher_type != "Stock Reconciliation"
):
return True
if sn.company != sle.company:
return False
def allow_serial_nos_with_different_item(sle_serial_no, sle):
"""
Allows same serial nos for raw materials and finished goods
in Manufacture / Repack type Stock Entry
"""
allow_serial_nos = False
if sle.voucher_type == "Stock Entry" and cint(sle.actual_qty) > 0:
stock_entry = frappe.get_cached_doc("Stock Entry", sle.voucher_no)
if stock_entry.purpose in ("Repack", "Manufacture"):
for d in stock_entry.get("items"):
if d.serial_no and (d.s_warehouse if not sle.is_cancelled else d.t_warehouse):
serial_nos = get_serial_nos(d.serial_no)
if sle_serial_no in serial_nos:
allow_serial_nos = True
return allow_serial_nos
def update_serial_nos(sle, item_det):
if sle.skip_update_serial_no:
return
if (
not sle.is_cancelled
and not sle.serial_no
and cint(sle.actual_qty) > 0
and item_det.has_serial_no == 1
and item_det.serial_no_series
):
serial_nos = get_auto_serial_nos(item_det.serial_no_series, sle.actual_qty)
sle.db_set("serial_no", serial_nos)
validate_serial_no(sle, item_det)
if sle.serial_no:
auto_make_serial_nos(sle)
def get_auto_serial_nos(serial_no_series, qty):
serial_nos = [] serial_nos = []
for i in range(cint(qty)): for i in range(cint(qty)):
serial_nos.append(get_new_serial_number(serial_no_series)) serial_nos.append(get_new_serial_number(serial_no_series))
return "\n".join(serial_nos) return serial_nos
def get_new_serial_number(series): def get_new_serial_number(series):
@ -568,41 +103,6 @@ def get_new_serial_number(series):
return sr_no return sr_no
def auto_make_serial_nos(args):
serial_nos = get_serial_nos(args.get("serial_no"))
created_numbers = []
voucher_type = args.get("voucher_type")
item_code = args.get("item_code")
for serial_no in serial_nos:
is_new = False
if frappe.db.exists("Serial No", serial_no):
sr = frappe.get_cached_doc("Serial No", serial_no)
elif args.get("actual_qty", 0) > 0:
sr = frappe.new_doc("Serial No")
is_new = True
sr = update_args_for_serial_no(sr, serial_no, args, is_new=is_new)
if is_new:
created_numbers.append(sr.name)
form_links = list(map(lambda d: get_link_to_form("Serial No", d), created_numbers))
# Setting up tranlated title field for all cases
singular_title = _("Serial Number Created")
multiple_title = _("Serial Numbers Created")
if voucher_type:
multiple_title = singular_title = _("{0} Created").format(voucher_type)
if len(form_links) == 1:
frappe.msgprint(_("Serial No {0} Created").format(form_links[0]), singular_title)
elif len(form_links) > 0:
message = _("The following serial numbers were created: <br><br> {0}").format(
get_items_html(form_links, item_code)
)
frappe.msgprint(message, multiple_title)
def get_items_html(serial_nos, item_code): def get_items_html(serial_nos, item_code):
body = ", ".join(serial_nos) body = ", ".join(serial_nos)
return """<details><summary> return """<details><summary>
@ -614,16 +114,6 @@ def get_items_html(serial_nos, item_code):
) )
def get_item_details(item_code):
return frappe.db.sql(
"""select name, has_batch_no, docstatus,
is_stock_item, has_serial_no, serial_no_series
from tabItem where name=%s""",
item_code,
as_dict=True,
)[0]
def get_serial_nos(serial_no): def get_serial_nos(serial_no):
if isinstance(serial_no, list): if isinstance(serial_no, list):
return serial_no return serial_no
@ -641,100 +131,6 @@ def clean_serial_no_string(serial_no: str) -> str:
return "\n".join(serial_no_list) return "\n".join(serial_no_list)
def update_args_for_serial_no(serial_no_doc, serial_no, args, is_new=False):
for field in ["item_code", "work_order", "company", "batch_no", "supplier", "location"]:
if args.get(field):
serial_no_doc.set(field, args.get(field))
serial_no_doc.via_stock_ledger = args.get("via_stock_ledger") or True
serial_no_doc.warehouse = args.get("warehouse") if args.get("actual_qty", 0) > 0 else None
if is_new:
serial_no_doc.serial_no = serial_no
if (
serial_no_doc.sales_order
and args.get("voucher_type") == "Stock Entry"
and not args.get("actual_qty", 0) > 0
):
serial_no_doc.sales_order = None
serial_no_doc.validate_item()
serial_no_doc.update_serial_no_reference(serial_no)
if is_new:
serial_no_doc.db_insert()
else:
serial_no_doc.db_update()
return serial_no_doc
def update_serial_nos_after_submit(controller, parentfield):
stock_ledger_entries = frappe.db.sql(
"""select voucher_detail_no, serial_no, actual_qty, warehouse
from `tabStock Ledger Entry` where voucher_type=%s and voucher_no=%s""",
(controller.doctype, controller.name),
as_dict=True,
)
if not stock_ledger_entries:
return
for d in controller.get(parentfield):
if d.serial_no:
continue
update_rejected_serial_nos = (
True
if (
controller.doctype in ("Purchase Receipt", "Purchase Invoice", "Subcontracting Receipt")
and d.rejected_qty
)
else False
)
accepted_serial_nos_updated = False
if controller.doctype == "Stock Entry":
warehouse = d.t_warehouse
qty = d.transfer_qty
elif controller.doctype in ("Sales Invoice", "Delivery Note"):
warehouse = d.warehouse
qty = d.stock_qty
else:
warehouse = d.warehouse
qty = (
d.qty
if controller.doctype in ["Stock Reconciliation", "Subcontracting Receipt"]
else d.stock_qty
)
for sle in stock_ledger_entries:
if sle.voucher_detail_no == d.name:
if (
not accepted_serial_nos_updated
and qty
and abs(sle.actual_qty) == abs(qty)
and sle.warehouse == warehouse
and sle.serial_no != d.serial_no
):
d.serial_no = sle.serial_no
frappe.db.set_value(d.doctype, d.name, "serial_no", sle.serial_no)
accepted_serial_nos_updated = True
if not update_rejected_serial_nos:
break
elif (
update_rejected_serial_nos
and abs(sle.actual_qty) == d.rejected_qty
and sle.warehouse == d.rejected_warehouse
and sle.serial_no != d.rejected_serial_no
):
d.rejected_serial_no = sle.serial_no
frappe.db.set_value(d.doctype, d.name, "rejected_serial_no", sle.serial_no)
update_rejected_serial_nos = False
if accepted_serial_nos_updated:
break
def update_maintenance_status(): def update_maintenance_status():
serial_nos = frappe.db.sql( serial_nos = frappe.db.sql(
"""select name from `tabSerial No` where (amc_expiry_date<%s or """select name from `tabSerial No` where (amc_expiry_date<%s or
@ -896,3 +292,16 @@ def fetch_serial_numbers(filters, qty, do_not_include=None):
serial_numbers = query.run(as_dict=True) serial_numbers = query.run(as_dict=True)
return serial_numbers return serial_numbers
def get_serial_nos_for_outward(kwargs):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_available_serial_nos,
)
serial_nos = get_available_serial_nos(kwargs)
if not serial_nos:
return []
return [d.serial_no for d in serial_nos]

View File

@ -1,14 +0,0 @@
frappe.listview_settings['Serial No'] = {
add_fields: ["item_code", "warehouse", "warranty_expiry_date", "delivery_document_type"],
get_indicator: (doc) => {
if (doc.delivery_document_type) {
return [__("Delivered"), "green", "delivery_document_type,is,set"];
} else if (doc.warranty_expiry_date && frappe.datetime.get_diff(doc.warranty_expiry_date, frappe.datetime.nowdate()) <= 0) {
return [__("Expired"), "red", "warranty_expiry_date,not in,|warranty_expiry_date,<=,Today|delivery_document_type,is,not set"];
} else if (!doc.warehouse) {
return [__("Inactive"), "grey", "warehouse,is,not set"];
} else {
return [__("Active"), "green", "delivery_document_type,is,not set"];
}
}
};

View File

@ -6,11 +6,18 @@
import frappe import frappe
from frappe import _, _dict
from frappe.tests.utils import FrappeTestCase from frappe.tests.utils import FrappeTestCase
from frappe.utils import today
from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note
from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.item.test_item import make_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import * from erpnext.stock.doctype.serial_no.serial_no import *
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
@ -44,26 +51,22 @@ class TestSerialNo(FrappeTestCase):
def test_inter_company_transfer(self): def test_inter_company_transfer(self):
se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") se = make_serialized_item(target_warehouse="_Test Warehouse - _TC")
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
dn = create_delivery_note( dn = create_delivery_note(
item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0] item_code="_Test Serialized Item With Series", qty=1, serial_no=[serial_nos[0]]
) )
serial_no = frappe.get_doc("Serial No", serial_nos[0]) serial_no = frappe.get_doc("Serial No", serial_nos[0])
# check Serial No details after delivery # check Serial No details after delivery
self.assertEqual(serial_no.status, "Delivered")
self.assertEqual(serial_no.warehouse, None) self.assertEqual(serial_no.warehouse, None)
self.assertEqual(serial_no.company, "_Test Company")
self.assertEqual(serial_no.delivery_document_type, "Delivery Note")
self.assertEqual(serial_no.delivery_document_no, dn.name)
wh = create_warehouse("_Test Warehouse", company="_Test Company 1") wh = create_warehouse("_Test Warehouse", company="_Test Company 1")
pr = make_purchase_receipt( pr = make_purchase_receipt(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=1, qty=1,
serial_no=serial_nos[0], serial_no=[serial_nos[0]],
company="_Test Company 1", company="_Test Company 1",
warehouse=wh, warehouse=wh,
) )
@ -71,11 +74,7 @@ class TestSerialNo(FrappeTestCase):
serial_no.reload() serial_no.reload()
# check Serial No details after purchase in second company # check Serial No details after purchase in second company
self.assertEqual(serial_no.status, "Active")
self.assertEqual(serial_no.warehouse, wh) self.assertEqual(serial_no.warehouse, wh)
self.assertEqual(serial_no.company, "_Test Company 1")
self.assertEqual(serial_no.purchase_document_type, "Purchase Receipt")
self.assertEqual(serial_no.purchase_document_no, pr.name)
def test_inter_company_transfer_intermediate_cancellation(self): def test_inter_company_transfer_intermediate_cancellation(self):
""" """
@ -84,25 +83,19 @@ class TestSerialNo(FrappeTestCase):
Try to cancel intermediate receipts/deliveries to test if it is blocked. Try to cancel intermediate receipts/deliveries to test if it is blocked.
""" """
se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") se = make_serialized_item(target_warehouse="_Test Warehouse - _TC")
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
sn_doc = frappe.get_doc("Serial No", serial_nos[0]) sn_doc = frappe.get_doc("Serial No", serial_nos[0])
# check Serial No details after purchase in first company # check Serial No details after purchase in first company
self.assertEqual(sn_doc.status, "Active")
self.assertEqual(sn_doc.company, "_Test Company")
self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC")
self.assertEqual(sn_doc.purchase_document_no, se.name)
dn = create_delivery_note( dn = create_delivery_note(
item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0] item_code="_Test Serialized Item With Series", qty=1, serial_no=[serial_nos[0]]
) )
sn_doc.reload() sn_doc.reload()
# check Serial No details after delivery from **first** company # check Serial No details after delivery from **first** company
self.assertEqual(sn_doc.status, "Delivered")
self.assertEqual(sn_doc.company, "_Test Company")
self.assertEqual(sn_doc.warehouse, None) self.assertEqual(sn_doc.warehouse, None)
self.assertEqual(sn_doc.delivery_document_no, dn.name)
# try cancelling the first Serial No Receipt, even though it is delivered # try cancelling the first Serial No Receipt, even though it is delivered
# block cancellation is Serial No is out of the warehouse # block cancellation is Serial No is out of the warehouse
@ -113,7 +106,7 @@ class TestSerialNo(FrappeTestCase):
pr = make_purchase_receipt( pr = make_purchase_receipt(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=1, qty=1,
serial_no=serial_nos[0], serial_no=[serial_nos[0]],
company="_Test Company 1", company="_Test Company 1",
warehouse=wh, warehouse=wh,
) )
@ -128,17 +121,14 @@ class TestSerialNo(FrappeTestCase):
dn_2 = create_delivery_note( dn_2 = create_delivery_note(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=1, qty=1,
serial_no=serial_nos[0], serial_no=[serial_nos[0]],
company="_Test Company 1", company="_Test Company 1",
warehouse=wh, warehouse=wh,
) )
sn_doc.reload() sn_doc.reload()
# check Serial No details after delivery from **second** company # check Serial No details after delivery from **second** company
self.assertEqual(sn_doc.status, "Delivered")
self.assertEqual(sn_doc.company, "_Test Company 1")
self.assertEqual(sn_doc.warehouse, None) self.assertEqual(sn_doc.warehouse, None)
self.assertEqual(sn_doc.delivery_document_no, dn_2.name)
# cannot cancel any intermediate document before last Delivery Note # cannot cancel any intermediate document before last Delivery Note
self.assertRaises(frappe.ValidationError, se.cancel) self.assertRaises(frappe.ValidationError, se.cancel)
@ -153,12 +143,12 @@ class TestSerialNo(FrappeTestCase):
""" """
# Receipt in **first** company # Receipt in **first** company
se = make_serialized_item(target_warehouse="_Test Warehouse - _TC") se = make_serialized_item(target_warehouse="_Test Warehouse - _TC")
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
sn_doc = frappe.get_doc("Serial No", serial_nos[0]) sn_doc = frappe.get_doc("Serial No", serial_nos[0])
# Delivery from first company # Delivery from first company
dn = create_delivery_note( dn = create_delivery_note(
item_code="_Test Serialized Item With Series", qty=1, serial_no=serial_nos[0] item_code="_Test Serialized Item With Series", qty=1, serial_no=[serial_nos[0]]
) )
# Receipt in **second** company # Receipt in **second** company
@ -166,7 +156,7 @@ class TestSerialNo(FrappeTestCase):
pr = make_purchase_receipt( pr = make_purchase_receipt(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=1, qty=1,
serial_no=serial_nos[0], serial_no=[serial_nos[0]],
company="_Test Company 1", company="_Test Company 1",
warehouse=wh, warehouse=wh,
) )
@ -175,72 +165,29 @@ class TestSerialNo(FrappeTestCase):
dn_2 = create_delivery_note( dn_2 = create_delivery_note(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
qty=1, qty=1,
serial_no=serial_nos[0], serial_no=[serial_nos[0]],
company="_Test Company 1", company="_Test Company 1",
warehouse=wh, warehouse=wh,
) )
sn_doc.reload() sn_doc.reload()
self.assertEqual(sn_doc.status, "Delivered") self.assertEqual(sn_doc.warehouse, None)
self.assertEqual(sn_doc.company, "_Test Company 1")
self.assertEqual(sn_doc.delivery_document_no, dn_2.name)
dn_2.cancel() dn_2.cancel()
sn_doc.reload() sn_doc.reload()
# Fallback on Purchase Receipt if Delivery is cancelled # Fallback on Purchase Receipt if Delivery is cancelled
self.assertEqual(sn_doc.status, "Active")
self.assertEqual(sn_doc.company, "_Test Company 1")
self.assertEqual(sn_doc.warehouse, wh) self.assertEqual(sn_doc.warehouse, wh)
self.assertEqual(sn_doc.purchase_document_no, pr.name)
pr.cancel() pr.cancel()
sn_doc.reload() sn_doc.reload()
# Inactive in same company if Receipt cancelled # Inactive in same company if Receipt cancelled
self.assertEqual(sn_doc.status, "Inactive")
self.assertEqual(sn_doc.company, "_Test Company 1")
self.assertEqual(sn_doc.warehouse, None) self.assertEqual(sn_doc.warehouse, None)
dn.cancel() dn.cancel()
sn_doc.reload() sn_doc.reload()
# Fallback on Purchase Receipt in FIRST company if # Fallback on Purchase Receipt in FIRST company if
# Delivery from FIRST company is cancelled # Delivery from FIRST company is cancelled
self.assertEqual(sn_doc.status, "Active")
self.assertEqual(sn_doc.company, "_Test Company")
self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC") self.assertEqual(sn_doc.warehouse, "_Test Warehouse - _TC")
self.assertEqual(sn_doc.purchase_document_no, se.name)
def test_auto_creation_of_serial_no(self):
"""
Test if auto created Serial No excludes existing serial numbers
"""
item_code = make_item(
"_Test Auto Serial Item ", {"has_serial_no": 1, "serial_no_series": "XYZ.###"}
).item_code
# Reserve XYZ005
pr_1 = make_purchase_receipt(item_code=item_code, qty=1, serial_no="XYZ005")
# XYZ005 is already used and will throw an error if used again
pr_2 = make_purchase_receipt(item_code=item_code, qty=10)
self.assertEqual(get_serial_nos(pr_1.get("items")[0].serial_no)[0], "XYZ005")
for serial_no in get_serial_nos(pr_2.get("items")[0].serial_no):
self.assertNotEqual(serial_no, "XYZ005")
def test_serial_no_sanitation(self):
"Test if Serial No input is sanitised before entering the DB."
item_code = "_Test Serialized Item"
test_records = frappe.get_test_records("Stock Entry")
se = frappe.copy_doc(test_records[0])
se.get("items")[0].item_code = item_code
se.get("items")[0].qty = 4
se.get("items")[0].serial_no = " _TS1, _TS2 , _TS3 , _TS4 - 2021"
se.get("items")[0].transfer_qty = 4
se.set_stock_entry_type()
se.insert()
se.submit()
self.assertEqual(se.get("items")[0].serial_no, "_TS1\n_TS2\n_TS3\n_TS4 - 2021")
def test_correct_serial_no_incoming_rate(self): def test_correct_serial_no_incoming_rate(self):
"""Check correct consumption rate based on serial no record.""" """Check correct consumption rate based on serial no record."""
@ -248,19 +195,28 @@ class TestSerialNo(FrappeTestCase):
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
serial_nos = ["LOWVALUATION", "HIGHVALUATION"] serial_nos = ["LOWVALUATION", "HIGHVALUATION"]
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
frappe.get_doc(
{"doctype": "Serial No", "item_code": item_code, "serial_no": serial_no}
).insert()
in1 = make_stock_entry( in1 = make_stock_entry(
item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, serial_no=serial_nos[0] item_code=item_code, to_warehouse=warehouse, qty=1, rate=42, serial_no=[serial_nos[0]]
) )
in2 = make_stock_entry( in2 = make_stock_entry(
item_code=item_code, to_warehouse=warehouse, qty=1, rate=113, serial_no=serial_nos[1] item_code=item_code, to_warehouse=warehouse, qty=1, rate=113, serial_no=[serial_nos[1]]
) )
out = create_delivery_note( out = create_delivery_note(
item_code=item_code, qty=1, serial_no=serial_nos[0], do_not_submit=True item_code=item_code, qty=1, serial_no=[serial_nos[0]], do_not_submit=True
) )
# change serial no bundle = out.items[0].serial_and_batch_bundle
out.items[0].serial_no = serial_nos[1] doc = frappe.get_doc("Serial and Batch Bundle", bundle)
doc.entries[0].serial_no = serial_nos[1]
doc.save()
out.save() out.save()
out.submit() out.submit()
@ -288,49 +244,99 @@ class TestSerialNo(FrappeTestCase):
in1.reload() in1.reload()
in2.reload() in2.reload()
batch1 = in1.items[0].batch_no batch1 = get_batch_from_bundle(in1.items[0].serial_and_batch_bundle)
batch2 = in2.items[0].batch_no batch2 = get_batch_from_bundle(in2.items[0].serial_and_batch_bundle)
batch_wise_serials = { batch_wise_serials = {
batch1: get_serial_nos(in1.items[0].serial_no), batch1: get_serial_nos_from_bundle(in1.items[0].serial_and_batch_bundle),
batch2: get_serial_nos(in2.items[0].serial_no), batch2: get_serial_nos_from_bundle(in2.items[0].serial_and_batch_bundle),
} }
# Test FIFO # Test FIFO
first_fetch = auto_fetch_serial_number(5, item_code, warehouse) first_fetch = get_auto_serial_nos(
_dict(
{
"qty": 5,
"item_code": item_code,
"warehouse": warehouse,
}
)
)
self.assertEqual(first_fetch, batch_wise_serials[batch1]) self.assertEqual(first_fetch, batch_wise_serials[batch1])
# partial FIFO # partial FIFO
partial_fetch = auto_fetch_serial_number(2, item_code, warehouse) partial_fetch = get_auto_serial_nos(
_dict(
{
"qty": 2,
"item_code": item_code,
"warehouse": warehouse,
}
)
)
self.assertTrue( self.assertTrue(
set(partial_fetch).issubset(set(first_fetch)), set(partial_fetch).issubset(set(first_fetch)),
msg=f"{partial_fetch} should be subset of {first_fetch}", msg=f"{partial_fetch} should be subset of {first_fetch}",
) )
# exclusion # exclusion
remaining = auto_fetch_serial_number( remaining = get_auto_serial_nos(
3, item_code, warehouse, exclude_sr_nos=json.dumps(partial_fetch) _dict(
{
"qty": 3,
"item_code": item_code,
"warehouse": warehouse,
"ignore_serial_nos": partial_fetch,
}
)
) )
self.assertEqual(sorted(remaining + partial_fetch), first_fetch) self.assertEqual(sorted(remaining + partial_fetch), first_fetch)
# batchwise # batchwise
for batch, expected_serials in batch_wise_serials.items(): for batch, expected_serials in batch_wise_serials.items():
fetched_sr = auto_fetch_serial_number(5, item_code, warehouse, batch_nos=batch) fetched_sr = get_auto_serial_nos(
_dict({"qty": 5, "item_code": item_code, "warehouse": warehouse, "batches": [batch]})
)
self.assertEqual(fetched_sr, sorted(expected_serials)) self.assertEqual(fetched_sr, sorted(expected_serials))
# non existing warehouse # non existing warehouse
self.assertEqual(auto_fetch_serial_number(10, item_code, warehouse="Nonexisting"), []) self.assertFalse(
get_auto_serial_nos(
_dict({"qty": 10, "item_code": item_code, "warehouse": "Non Existing Warehouse"})
)
)
# multi batch # multi batch
all_serials = [sr for sr_list in batch_wise_serials.values() for sr in sr_list] all_serials = [sr for sr_list in batch_wise_serials.values() for sr in sr_list]
fetched_serials = auto_fetch_serial_number( fetched_serials = get_auto_serial_nos(
10, item_code, warehouse, batch_nos=list(batch_wise_serials.keys()) _dict(
{
"qty": 10,
"item_code": item_code,
"warehouse": warehouse,
"batches": list(batch_wise_serials.keys()),
}
)
) )
self.assertEqual(sorted(all_serials), fetched_serials) self.assertEqual(sorted(all_serials), fetched_serials)
# expiry date # expiry date
frappe.db.set_value("Batch", batch1, "expiry_date", "1980-01-01") frappe.db.set_value("Batch", batch1, "expiry_date", "1980-01-01")
non_expired_serials = auto_fetch_serial_number( non_expired_serials = get_auto_serial_nos(
5, item_code, warehouse, posting_date="2021-01-01", batch_nos=batch1 _dict({"qty": 5, "item_code": item_code, "warehouse": warehouse, "batches": [batch1]})
) )
self.assertEqual(non_expired_serials, []) self.assertEqual(non_expired_serials, [])
def get_auto_serial_nos(kwargs):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_available_serial_nos,
)
serial_nos = get_available_serial_nos(kwargs)
return sorted([d.serial_no for d in serial_nos])

View File

@ -7,6 +7,8 @@ frappe.provide("erpnext.accounts.dimensions");
frappe.ui.form.on('Stock Entry', { frappe.ui.form.on('Stock Entry', {
setup: function(frm) { setup: function(frm) {
frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle'];
frm.set_indicator_formatter('item_code', function(doc) { frm.set_indicator_formatter('item_code', function(doc) {
if (!doc.s_warehouse) { if (!doc.s_warehouse) {
return 'blue'; return 'blue';
@ -403,28 +405,6 @@ frappe.ui.form.on('Stock Entry', {
} }
}, },
set_serial_no: function(frm, cdt, cdn, callback) {
var d = frappe.model.get_doc(cdt, cdn);
if(!d.item_code && !d.s_warehouse && !d.qty) return;
var args = {
'item_code' : d.item_code,
'warehouse' : cstr(d.s_warehouse),
'stock_qty' : d.transfer_qty
};
frappe.call({
method: "erpnext.stock.get_item_details.get_serial_no",
args: {"args": args},
callback: function(r) {
if (!r.exe && r.message){
frappe.model.set_value(cdt, cdn, "serial_no", r.message);
}
if (callback) {
callback();
}
}
});
},
make_retention_stock_entry: function(frm) { make_retention_stock_entry: function(frm) {
frappe.call({ frappe.call({
method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse", method: "erpnext.stock.doctype.stock_entry.stock_entry.move_sample_to_retention_warehouse",
@ -491,8 +471,7 @@ frappe.ui.form.on('Stock Entry', {
'item_code': child.item_code, 'item_code': child.item_code,
'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse), 'warehouse': cstr(child.s_warehouse) || cstr(child.t_warehouse),
'transfer_qty': child.transfer_qty, 'transfer_qty': child.transfer_qty,
'serial_no': child.serial_no, 'serial_and_batch_bundle': child.serial_and_batch_bundle,
'batch_no': child.batch_no,
'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty, 'qty': child.s_warehouse ? -1* child.transfer_qty : child.transfer_qty,
'posting_date': frm.doc.posting_date, 'posting_date': frm.doc.posting_date,
'posting_time': frm.doc.posting_time, 'posting_time': frm.doc.posting_time,
@ -680,20 +659,16 @@ frappe.ui.form.on('Stock Entry', {
}); });
frappe.ui.form.on('Stock Entry Detail', { frappe.ui.form.on('Stock Entry Detail', {
qty: function(frm, cdt, cdn) { qty(frm, cdt, cdn) {
frm.events.set_serial_no(frm, cdt, cdn, () => {
frm.events.set_basic_rate(frm, cdt, cdn);
});
},
conversion_factor: function(frm, cdt, cdn) {
frm.events.set_basic_rate(frm, cdt, cdn); frm.events.set_basic_rate(frm, cdt, cdn);
}, },
s_warehouse: function(frm, cdt, cdn) { conversion_factor(frm, cdt, cdn) {
frm.events.set_serial_no(frm, cdt, cdn, () => { frm.events.set_basic_rate(frm, cdt, cdn);
frm.events.get_warehouse_details(frm, cdt, cdn); },
});
s_warehouse(frm, cdt, cdn) {
frm.events.get_warehouse_details(frm, cdt, cdn);
// set allow_zero_valuation_rate to 0 if s_warehouse is selected. // set allow_zero_valuation_rate to 0 if s_warehouse is selected.
let item = frappe.get_doc(cdt, cdn); let item = frappe.get_doc(cdt, cdn);
@ -702,16 +677,16 @@ frappe.ui.form.on('Stock Entry Detail', {
} }
}, },
t_warehouse: function(frm, cdt, cdn) { t_warehouse(frm, cdt, cdn) {
frm.events.get_warehouse_details(frm, cdt, cdn); frm.events.get_warehouse_details(frm, cdt, cdn);
}, },
basic_rate: function(frm, cdt, cdn) { basic_rate(frm, cdt, cdn) {
var item = locals[cdt][cdn]; var item = locals[cdt][cdn];
frm.events.calculate_basic_amount(frm, item); frm.events.calculate_basic_amount(frm, item);
}, },
uom: function(doc, cdt, cdn) { uom(doc, cdt, cdn) {
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
if(d.uom && d.item_code){ if(d.uom && d.item_code){
return frappe.call({ return frappe.call({
@ -730,7 +705,7 @@ frappe.ui.form.on('Stock Entry Detail', {
} }
}, },
item_code: function(frm, cdt, cdn) { item_code(frm, cdt, cdn) {
var d = locals[cdt][cdn]; var d = locals[cdt][cdn];
if(d.item_code) { if(d.item_code) {
var args = { var args = {
@ -769,26 +744,38 @@ frappe.ui.form.on('Stock Entry Detail', {
no_batch_serial_number_value = !d.batch_no; no_batch_serial_number_value = !d.batch_no;
} }
if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog) { if (no_batch_serial_number_value && !frappe.flags.hide_serial_batch_dialog && !frappe.flags.dialog_set) {
frappe.flags.dialog_set = true;
erpnext.stock.select_batch_and_serial_no(frm, d); erpnext.stock.select_batch_and_serial_no(frm, d);
} else {
frappe.flags.dialog_set = false;
} }
} }
} }
}); });
} }
}, },
expense_account: function(frm, cdt, cdn) {
expense_account(frm, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "expense_account"); erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "expense_account");
}, },
cost_center: function(frm, cdt, cdn) {
cost_center(frm, cdt, cdn) {
erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "cost_center"); erpnext.utils.copy_value_in_all_rows(frm.doc, cdt, cdn, "items", "cost_center");
}, },
sample_quantity: function(frm, cdt, cdn) {
sample_quantity(frm, cdt, cdn) {
validate_sample_quantity(frm, cdt, cdn); validate_sample_quantity(frm, cdt, cdn);
}, },
batch_no: function(frm, cdt, cdn) {
batch_no(frm, cdt, cdn) {
validate_sample_quantity(frm, cdt, cdn); validate_sample_quantity(frm, cdt, cdn);
}, },
add_serial_batch_bundle(frm, cdt, cdn) {
var child = locals[cdt][cdn];
erpnext.stock.select_batch_and_serial_no(frm, child);
}
}); });
var validate_sample_quantity = function(frm, cdt, cdn) { var validate_sample_quantity = function(frm, cdt, cdn) {
@ -1093,35 +1080,29 @@ erpnext.stock.StockEntry = class StockEntry extends erpnext.stock.StockControlle
}; };
erpnext.stock.select_batch_and_serial_no = (frm, item) => { erpnext.stock.select_batch_and_serial_no = (frm, item) => {
let get_warehouse_type_and_name = (item) => { let path = "assets/erpnext/js/utils/serial_no_batch_selector.js";
let value = '';
if(frm.fields_dict.from_warehouse.disp_status === "Write") {
value = cstr(item.s_warehouse) || '';
return {
type: 'Source Warehouse',
name: value
};
} else {
value = cstr(item.t_warehouse) || '';
return {
type: 'Target Warehouse',
name: value
};
}
}
if(item && !item.has_serial_no && !item.has_batch_no) return; frappe.db.get_value("Item", item.item_code, ["has_batch_no", "has_serial_no"])
if (frm.doc.purpose === 'Material Receipt') return; .then((r) => {
if (r.message && (r.message.has_batch_no || r.message.has_serial_no)) {
item.has_serial_no = r.message.has_serial_no;
item.has_batch_no = r.message.has_batch_no;
item.outward = item.s_warehouse ? 1 : 0;
frappe.require("assets/erpnext/js/utils/serial_no_batch_selector.js", function() { frappe.require(path, function() {
if (frm.batch_selector?.dialog?.display) return; new erpnext.SerialBatchPackageSelector(
frm.batch_selector = new erpnext.SerialNoBatchSelector({ frm, item, (r) => {
frm: frm, if (r) {
item: item, frappe.model.set_value(item.doctype, item.name, {
warehouse_details: get_warehouse_type_and_name(item), "serial_and_batch_bundle": r.name,
"qty": Math.abs(r.total_qty)
});
}
}
);
});
}
}); });
});
} }
function attach_bom_items(bom_no) { function attach_bom_items(bom_no) {

View File

@ -4,6 +4,7 @@
import json import json
from collections import defaultdict from collections import defaultdict
from typing import List
import frappe import frappe
from frappe import _ from frappe import _
@ -27,12 +28,9 @@ from erpnext.controllers.taxes_and_totals import init_landed_taxes_and_totals
from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, validate_bom_no from erpnext.manufacturing.doctype.bom.bom import add_additional_cost, validate_bom_no
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.stock.doctype.batch.batch import get_batch_no, get_batch_qty, set_batch_nos from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.item.item import get_item_defaults from erpnext.stock.doctype.item.item import get_item_defaults
from erpnext.stock.doctype.serial_no.serial_no import ( from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
get_serial_nos,
update_serial_nos_after_submit,
)
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
OpeningEntryAccountError, OpeningEntryAccountError,
) )
@ -40,7 +38,11 @@ from erpnext.stock.get_item_details import (
get_bin_details, get_bin_details,
get_conversion_factor, get_conversion_factor,
get_default_cost_center, get_default_cost_center,
get_reserved_qty_for_so, )
from erpnext.stock.serial_batch_bundle import (
SerialBatchCreation,
get_empty_batches_based_work_order,
get_serial_or_batch_items,
) )
from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle, get_valuation_rate
from erpnext.stock.utils import get_bin, get_incoming_rate from erpnext.stock.utils import get_bin, get_incoming_rate
@ -140,16 +142,10 @@ class StockEntry(StockController):
self.validate_job_card_item() self.validate_job_card_item()
self.set_purpose_for_stock_entry() self.set_purpose_for_stock_entry()
self.clean_serial_nos() self.clean_serial_nos()
self.validate_duplicate_serial_no()
if not self.from_bom: if not self.from_bom:
self.fg_completed_qty = 0.0 self.fg_completed_qty = 0.0
if self._action == "submit":
self.make_batches("t_warehouse")
else:
set_batch_nos(self, "s_warehouse")
self.validate_serialized_batch() self.validate_serialized_batch()
self.set_actual_qty() self.set_actual_qty()
self.calculate_rate_and_amount() self.calculate_rate_and_amount()
@ -198,8 +194,6 @@ class StockEntry(StockController):
def on_submit(self): def on_submit(self):
self.update_stock_ledger() self.update_stock_ledger()
update_serial_nos_after_submit(self, "items")
self.update_work_order() self.update_work_order()
self.validate_subcontract_order() self.validate_subcontract_order()
self.update_subcontract_order_supplied_items() self.update_subcontract_order_supplied_items()
@ -210,13 +204,9 @@ class StockEntry(StockController):
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.update_cost_in_project() self.update_cost_in_project()
self.validate_reserved_serial_no_consumption()
self.update_transferred_qty() self.update_transferred_qty()
self.update_quality_inspection() self.update_quality_inspection()
if self.work_order and self.purpose == "Manufacture":
self.update_so_in_serial_number()
if self.purpose == "Material Transfer" and self.add_to_transit: if self.purpose == "Material Transfer" and self.add_to_transit:
self.set_material_request_transfer_status("In Transit") self.set_material_request_transfer_status("In Transit")
if self.purpose == "Material Transfer" and self.outgoing_stock_entry: if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
@ -232,7 +222,12 @@ class StockEntry(StockController):
self.update_work_order() self.update_work_order()
self.update_stock_ledger() self.update_stock_ledger()
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation") self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Serial and Batch Bundle",
)
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
@ -247,6 +242,12 @@ class StockEntry(StockController):
if self.purpose == "Material Transfer" and self.outgoing_stock_entry: if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
self.set_material_request_transfer_status("In Transit") self.set_material_request_transfer_status("In Transit")
def before_save(self):
self.make_serial_and_batch_bundle_for_outward()
def on_update(self):
self.set_serial_and_batch_bundle()
def set_job_card_data(self): def set_job_card_data(self):
if self.job_card and not self.work_order: if self.job_card and not self.work_order:
data = frappe.db.get_value( data = frappe.db.get_value(
@ -361,7 +362,6 @@ class StockEntry(StockController):
def validate_item(self): def validate_item(self):
stock_items = self.get_stock_items() stock_items = self.get_stock_items()
serialized_items = self.get_serialized_items()
for item in self.get("items"): for item in self.get("items"):
if flt(item.qty) and flt(item.qty) < 0: if flt(item.qty) and flt(item.qty) < 0:
frappe.throw( frappe.throw(
@ -403,16 +403,6 @@ class StockEntry(StockController):
flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item) flt(item.qty) * flt(item.conversion_factor), self.precision("transfer_qty", item)
) )
if (
self.purpose in ("Material Transfer", "Material Transfer for Manufacture")
and not item.serial_no
and item.item_code in serialized_items
):
frappe.throw(
_("Row #{0}: Please specify Serial No for Item {1}").format(item.idx, item.item_code),
frappe.MandatoryError,
)
def validate_qty(self): def validate_qty(self):
manufacture_purpose = ["Manufacture", "Material Consumption for Manufacture"] manufacture_purpose = ["Manufacture", "Material Consumption for Manufacture"]
@ -712,6 +702,9 @@ class StockEntry(StockController):
self.set_total_incoming_outgoing_value() self.set_total_incoming_outgoing_value()
self.set_total_amount() self.set_total_amount()
if not reset_outgoing_rate:
self.set_serial_and_batch_bundle()
def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True): def set_basic_rate(self, reset_outgoing_rate=True, raise_error_if_no_rate=True):
""" """
Set rate for outgoing, scrapped and finished items Set rate for outgoing, scrapped and finished items
@ -741,6 +734,9 @@ class StockEntry(StockController):
d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost) d.basic_rate = self.get_basic_rate_for_repacked_items(d.transfer_qty, outgoing_items_cost)
if not d.basic_rate and not d.allow_zero_valuation_rate: if not d.basic_rate and not d.allow_zero_valuation_rate:
if self.is_new():
raise_error_if_no_rate = False
d.basic_rate = get_valuation_rate( d.basic_rate = get_valuation_rate(
d.item_code, d.item_code,
d.t_warehouse, d.t_warehouse,
@ -750,7 +746,7 @@ class StockEntry(StockController):
currency=erpnext.get_company_currency(self.company), currency=erpnext.get_company_currency(self.company),
company=self.company, company=self.company,
raise_error_if_no_rate=raise_error_if_no_rate, raise_error_if_no_rate=raise_error_if_no_rate,
batch_no=d.batch_no, serial_and_batch_bundle=d.serial_and_batch_bundle,
) )
# do not round off basic rate to avoid precision loss # do not round off basic rate to avoid precision loss
@ -795,12 +791,11 @@ class StockEntry(StockController):
"posting_date": self.posting_date, "posting_date": self.posting_date,
"posting_time": self.posting_time, "posting_time": self.posting_time,
"qty": item.s_warehouse and -1 * flt(item.transfer_qty) or flt(item.transfer_qty), "qty": item.s_warehouse and -1 * flt(item.transfer_qty) or flt(item.transfer_qty),
"serial_no": item.serial_no,
"batch_no": item.batch_no,
"voucher_type": self.doctype, "voucher_type": self.doctype,
"voucher_no": self.name, "voucher_no": self.name,
"company": self.company, "company": self.company,
"allow_zero_valuation": item.allow_zero_valuation_rate, "allow_zero_valuation": item.allow_zero_valuation_rate,
"serial_and_batch_bundle": item.serial_and_batch_bundle,
} }
) )
@ -882,25 +877,65 @@ class StockEntry(StockController):
if self.stock_entry_type and not self.purpose: if self.stock_entry_type and not self.purpose:
self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose") self.purpose = frappe.get_cached_value("Stock Entry Type", self.stock_entry_type, "purpose")
def validate_duplicate_serial_no(self): def make_serial_and_batch_bundle_for_outward(self):
warehouse_wise_serial_nos = {} if self.docstatus == 1:
return
# In case of repack the source and target serial nos could be same serial_or_batch_items = get_serial_or_batch_items(self.items)
for warehouse in ["s_warehouse", "t_warehouse"]: if not serial_or_batch_items:
serial_nos = [] return
for row in self.items:
if not (row.serial_no and row.get(warehouse)):
continue
for sn in get_serial_nos(row.serial_no): already_picked_serial_nos = []
if sn in serial_nos:
frappe.throw(
_("The serial no {0} has added multiple times in the stock entry {1}").format(
frappe.bold(sn), self.name
)
)
serial_nos.append(sn) for row in self.items:
if not row.s_warehouse:
continue
if row.item_code not in serial_or_batch_items:
continue
bundle_doc = None
if row.serial_and_batch_bundle and abs(row.qty) != abs(
frappe.get_cached_value("Serial and Batch Bundle", row.serial_and_batch_bundle, "total_qty")
):
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": row.s_warehouse,
"serial_and_batch_bundle": row.serial_and_batch_bundle,
"type_of_transaction": "Outward",
"ignore_serial_nos": already_picked_serial_nos,
"qty": row.qty * -1,
}
).update_serial_and_batch_entries()
elif not row.serial_and_batch_bundle:
bundle_doc = SerialBatchCreation(
{
"item_code": row.item_code,
"warehouse": row.s_warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": self.doctype,
"voucher_detail_no": row.name,
"qty": row.qty * -1,
"ignore_serial_nos": already_picked_serial_nos,
"type_of_transaction": "Outward",
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
if not bundle_doc:
continue
if self.docstatus == 0:
for entry in bundle_doc.entries:
if not entry.serial_no:
continue
already_picked_serial_nos.append(entry.serial_no)
row.serial_and_batch_bundle = bundle_doc.name
def validate_subcontract_order(self): def validate_subcontract_order(self):
"""Throw exception if more raw material is transferred against Subcontract Order than in """Throw exception if more raw material is transferred against Subcontract Order than in
@ -1205,6 +1240,28 @@ class StockEntry(StockController):
sl_entries.append(sle) sl_entries.append(sle)
def make_serial_and_batch_bundle_for_transfer(self):
ids = frappe._dict(
frappe.get_all(
"Stock Entry Detail",
fields=["name", "serial_and_batch_bundle"],
filters={"parent": self.outgoing_stock_entry, "serial_and_batch_bundle": ("is", "set")},
as_list=1,
)
)
if not ids:
return
for d in self.get("items"):
serial_and_batch_bundle = ids.get(d.ste_detail)
if not serial_and_batch_bundle:
continue
d.serial_and_batch_bundle = self.make_package_for_transfer(
serial_and_batch_bundle, d.s_warehouse, "Outward", do_not_submit=True
)
def get_sle_for_target_warehouse(self, sl_entries, finished_item_row): def get_sle_for_target_warehouse(self, sl_entries, finished_item_row):
for d in self.get("items"): for d in self.get("items"):
if cstr(d.t_warehouse): if cstr(d.t_warehouse):
@ -1216,9 +1273,36 @@ class StockEntry(StockController):
"incoming_rate": flt(d.valuation_rate), "incoming_rate": flt(d.valuation_rate),
}, },
) )
if cstr(d.s_warehouse) or (finished_item_row and d.name == finished_item_row.name): if cstr(d.s_warehouse) or (finished_item_row and d.name == finished_item_row.name):
sle.recalculate_rate = 1 sle.recalculate_rate = 1
allowed_types = [
"Material Transfer",
"Send to Subcontractor",
"Material Transfer for Manufacture",
]
if self.purpose in allowed_types and d.serial_and_batch_bundle and self.docstatus == 1:
sle.serial_and_batch_bundle = self.make_package_for_transfer(
d.serial_and_batch_bundle, d.t_warehouse
)
if sle.serial_and_batch_bundle and self.docstatus == 2:
bundle_id = frappe.get_cached_value(
"Serial and Batch Bundle",
{
"voucher_detail_no": d.name,
"voucher_no": self.name,
"is_cancelled": 0,
"type_of_transaction": "Inward",
},
"name",
)
if sle.serial_and_batch_bundle != bundle_id:
sle.serial_and_batch_bundle = bundle_id
sl_entries.append(sle) sl_entries.append(sle)
def get_gl_entries(self, warehouse_account): def get_gl_entries(self, warehouse_account):
@ -1326,7 +1410,6 @@ class StockEntry(StockController):
pro_doc.run_method("update_work_order_qty") pro_doc.run_method("update_work_order_qty")
if self.purpose == "Manufacture": if self.purpose == "Manufacture":
pro_doc.run_method("update_planned_qty") pro_doc.run_method("update_planned_qty")
pro_doc.update_batch_produced_qty(self)
pro_doc.run_method("update_status") pro_doc.run_method("update_status")
if not pro_doc.operations: if not pro_doc.operations:
@ -1368,10 +1451,8 @@ class StockEntry(StockController):
"qty": args.get("qty"), "qty": args.get("qty"),
"transfer_qty": args.get("qty"), "transfer_qty": args.get("qty"),
"conversion_factor": 1, "conversion_factor": 1,
"batch_no": "",
"actual_qty": 0, "actual_qty": 0,
"basic_rate": 0, "basic_rate": 0,
"serial_no": "",
"has_serial_no": item.has_serial_no, "has_serial_no": item.has_serial_no,
"has_batch_no": item.has_batch_no, "has_batch_no": item.has_batch_no,
"sample_quantity": item.sample_quantity, "sample_quantity": item.sample_quantity,
@ -1406,15 +1487,6 @@ class StockEntry(StockController):
stock_and_rate = get_warehouse_details(args) if args.get("warehouse") else {} stock_and_rate = get_warehouse_details(args) if args.get("warehouse") else {}
ret.update(stock_and_rate) ret.update(stock_and_rate)
# automatically select batch for outgoing item
if (
args.get("s_warehouse", None)
and args.get("qty")
and ret.get("has_batch_no")
and not args.get("batch_no")
):
args.batch_no = get_batch_no(args["item_code"], args["s_warehouse"], args["qty"])
if ( if (
self.purpose == "Send to Subcontractor" self.purpose == "Send to Subcontractor"
and self.get(self.subcontract_data.order_field) and self.get(self.subcontract_data.order_field)
@ -1453,8 +1525,6 @@ class StockEntry(StockController):
"ste_detail": d.name, "ste_detail": d.name,
"stock_uom": d.stock_uom, "stock_uom": d.stock_uom,
"conversion_factor": d.conversion_factor, "conversion_factor": d.conversion_factor,
"serial_no": d.serial_no,
"batch_no": d.batch_no,
}, },
) )
@ -1625,6 +1695,7 @@ class StockEntry(StockController):
if ( if (
self.work_order self.work_order
and self.pro_doc.has_batch_no and self.pro_doc.has_batch_no
and not self.pro_doc.has_serial_no
and cint( and cint(
frappe.db.get_single_value( frappe.db.get_single_value(
"Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True "Manufacturing Settings", "make_serial_no_batch_from_work_order", cache=True
@ -1636,42 +1707,34 @@ class StockEntry(StockController):
self.add_finished_goods(args, item) self.add_finished_goods(args, item)
def set_batchwise_finished_goods(self, args, item): def set_batchwise_finished_goods(self, args, item):
filters = { batches = get_empty_batches_based_work_order(self.work_order, self.pro_doc.production_item)
"reference_name": self.pro_doc.name,
"reference_doctype": self.pro_doc.doctype,
"qty_to_produce": (">", 0),
"batch_qty": ("=", 0),
}
fields = ["qty_to_produce as qty", "produced_qty", "name"] if not batches:
data = frappe.get_all("Batch", filters=filters, fields=fields, order_by="creation asc")
if not data:
self.add_finished_goods(args, item) self.add_finished_goods(args, item)
else: else:
self.add_batchwise_finished_good(data, args, item) self.add_batchwise_finished_good(batches, args, item)
def add_batchwise_finished_good(self, data, args, item): def add_batchwise_finished_good(self, batches, args, item):
qty = flt(self.fg_completed_qty) qty = flt(self.fg_completed_qty)
row = frappe._dict({"batches_to_be_consume": defaultdict(float)})
for row in data: self.update_batches_to_be_consume(batches, row, qty)
batch_qty = flt(row.qty) - flt(row.produced_qty)
if not batch_qty:
continue
if qty <= 0: if not row.batches_to_be_consume:
break return
fg_qty = batch_qty id = create_serial_and_batch_bundle(
if batch_qty >= qty: row,
fg_qty = qty frappe._dict(
{
"item_code": self.pro_doc.production_item,
"warehouse": args.get("to_warehouse"),
}
),
)
qty -= batch_qty args["serial_and_batch_bundle"] = id
args["qty"] = fg_qty self.add_finished_goods(args, item)
args["batch_no"] = row.name
self.add_finished_goods(args, item)
def add_finished_goods(self, args, item): def add_finished_goods(self, args, item):
self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no) self.add_to_stock_entry_detail({item.name: args}, bom_no=self.bom_no)
@ -1875,21 +1938,41 @@ class StockEntry(StockController):
qty = frappe.utils.ceil(qty) qty = frappe.utils.ceil(qty)
if row.batch_details: if row.batch_details:
batches = sorted(row.batch_details.items(), key=lambda x: x[0]) row.batches_to_be_consume = defaultdict(float)
for batch_no, batch_qty in batches: batches = row.batch_details
if qty <= 0 or batch_qty <= 0: self.update_batches_to_be_consume(batches, row, qty)
continue
if batch_qty > qty: elif row.serial_nos:
batch_qty = qty serial_nos = row.serial_nos[0 : cint(qty)]
row.serial_nos = serial_nos
item.batch_no = batch_no self.update_item_in_stock_entry_detail(row, item, qty)
self.update_item_in_stock_entry_detail(row, item, batch_qty)
row.batch_details[batch_no] -= batch_qty def update_batches_to_be_consume(self, batches, row, qty):
qty -= batch_qty qty_to_be_consumed = qty
else: batches = sorted(batches.items(), key=lambda x: x[0])
self.update_item_in_stock_entry_detail(row, item, qty)
for batch_no, batch_qty in batches:
if qty_to_be_consumed <= 0 or batch_qty <= 0:
continue
if batch_qty > qty_to_be_consumed:
batch_qty = qty_to_be_consumed
row.batches_to_be_consume[batch_no] += batch_qty
if batch_no and row.serial_nos:
serial_nos = self.get_serial_nos_based_on_transferred_batch(batch_no, row.serial_nos)
serial_nos = serial_nos[0 : cint(batch_qty)]
# remove consumed serial nos from list
for sn in serial_nos:
row.serial_nos.remove(sn)
if "batch_details" in row:
row.batch_details[batch_no] -= batch_qty
qty_to_be_consumed -= batch_qty
def update_item_in_stock_entry_detail(self, row, item, qty) -> None: def update_item_in_stock_entry_detail(self, row, item, qty) -> None:
if not qty: if not qty:
@ -1900,7 +1983,7 @@ class StockEntry(StockController):
"to_warehouse": "", "to_warehouse": "",
"qty": qty, "qty": qty,
"item_name": item.item_name, "item_name": item.item_name,
"batch_no": item.batch_no, "serial_and_batch_bundle": create_serial_and_batch_bundle(row, item, "Outward"),
"description": item.description, "description": item.description,
"stock_uom": item.stock_uom, "stock_uom": item.stock_uom,
"expense_account": item.expense_account, "expense_account": item.expense_account,
@ -1911,24 +1994,14 @@ class StockEntry(StockController):
if self.is_return: if self.is_return:
ste_item_details["to_warehouse"] = item.s_warehouse ste_item_details["to_warehouse"] = item.s_warehouse
if row.serial_nos:
serial_nos = row.serial_nos
if item.batch_no:
serial_nos = self.get_serial_nos_based_on_transferred_batch(item.batch_no, row.serial_nos)
serial_nos = serial_nos[0 : cint(qty)]
ste_item_details["serial_no"] = "\n".join(serial_nos)
# remove consumed serial nos from list
for sn in serial_nos:
row.serial_nos.remove(sn)
self.add_to_stock_entry_detail({item.item_code: ste_item_details}) self.add_to_stock_entry_detail({item.item_code: ste_item_details})
@staticmethod @staticmethod
def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list: def get_serial_nos_based_on_transferred_batch(batch_no, serial_nos) -> list:
serial_nos = frappe.get_all( serial_nos = frappe.get_all(
"Serial No", filters={"batch_no": batch_no, "name": ("in", serial_nos)}, order_by="creation" "Serial No",
filters={"batch_no": batch_no, "name": ("in", serial_nos), "warehouse": ("is", "not set")},
order_by="creation",
) )
return [d.name for d in serial_nos] return [d.name for d in serial_nos]
@ -2070,8 +2143,7 @@ class StockEntry(StockController):
"expense_account", "expense_account",
"description", "description",
"item_name", "item_name",
"serial_no", "serial_and_batch_bundle",
"batch_no",
"allow_zero_valuation_rate", "allow_zero_valuation_rate",
]: ]:
if item_row.get(field): if item_row.get(field):
@ -2180,42 +2252,6 @@ class StockEntry(StockController):
stock_bin = get_bin(item_code, reserve_warehouse) stock_bin = get_bin(item_code, reserve_warehouse)
stock_bin.update_reserved_qty_for_sub_contracting() stock_bin.update_reserved_qty_for_sub_contracting()
def update_so_in_serial_number(self):
so_name, item_code = frappe.db.get_value(
"Work Order", self.work_order, ["sales_order", "production_item"]
)
if so_name and item_code:
qty_to_reserve = get_reserved_qty_for_so(so_name, item_code)
if qty_to_reserve:
reserved_qty = frappe.db.sql(
"""select count(name) from `tabSerial No` where item_code=%s and
sales_order=%s""",
(item_code, so_name),
)
if reserved_qty and reserved_qty[0][0]:
qty_to_reserve -= reserved_qty[0][0]
if qty_to_reserve > 0:
for item in self.items:
has_serial_no = frappe.get_cached_value("Item", item.item_code, "has_serial_no")
if item.item_code == item_code and has_serial_no:
serial_nos = (item.serial_no).split("\n")
for serial_no in serial_nos:
if qty_to_reserve > 0:
frappe.db.set_value("Serial No", serial_no, "sales_order", so_name)
qty_to_reserve -= 1
def validate_reserved_serial_no_consumption(self):
for item in self.items:
if item.s_warehouse and not item.t_warehouse and item.serial_no:
for sr in get_serial_nos(item.serial_no):
sales_order = frappe.db.get_value("Serial No", sr, "sales_order")
if sales_order:
msg = _(
"(Serial No: {0}) cannot be consumed as it's reserverd to fullfill Sales Order {1}."
).format(sr, sales_order)
frappe.throw(_("Item {0} {1}").format(item.item_code, msg))
def update_transferred_qty(self): def update_transferred_qty(self):
if self.purpose == "Material Transfer" and self.outgoing_stock_entry: if self.purpose == "Material Transfer" and self.outgoing_stock_entry:
stock_entries = {} stock_entries = {}
@ -2308,40 +2344,48 @@ class StockEntry(StockController):
frappe.db.set_value("Material Request", material_request, "transfer_status", status) frappe.db.set_value("Material Request", material_request, "transfer_status", status)
def set_serial_no_batch_for_finished_good(self): def set_serial_no_batch_for_finished_good(self):
serial_nos = [] if not (
if self.pro_doc.serial_no: (self.pro_doc.has_serial_no or self.pro_doc.has_batch_no)
serial_nos = self.get_serial_nos_for_fg() or [] and frappe.db.get_single_value("Manufacturing Settings", "make_serial_no_batch_from_work_order")
):
return
for row in self.items: for d in self.items:
if row.is_finished_item and row.item_code == self.pro_doc.production_item: if d.is_finished_item and d.item_code == self.pro_doc.production_item:
serial_nos = self.get_available_serial_nos()
if serial_nos: if serial_nos:
row.serial_no = "\n".join(serial_nos[0 : cint(row.qty)]) row = frappe._dict({"serial_nos": serial_nos[0 : cint(d.qty)]})
def get_serial_nos_for_fg(self): id = create_serial_and_batch_bundle(
fields = [ row,
"`tabStock Entry`.`name`", frappe._dict(
"`tabStock Entry Detail`.`qty`", {
"`tabStock Entry Detail`.`serial_no`", "item_code": d.item_code,
"`tabStock Entry Detail`.`batch_no`", "warehouse": d.t_warehouse,
] }
),
)
filters = [ d.serial_and_batch_bundle = id
["Stock Entry", "work_order", "=", self.work_order],
["Stock Entry", "purpose", "=", "Manufacture"],
["Stock Entry", "docstatus", "<", 2],
["Stock Entry Detail", "item_code", "=", self.pro_doc.production_item],
]
stock_entries = frappe.get_all("Stock Entry", fields=fields, filters=filters) def get_available_serial_nos(self) -> List[str]:
return self.get_available_serial_nos(stock_entries) serial_nos = []
data = frappe.get_all(
"Serial No",
filters={
"item_code": self.pro_doc.production_item,
"warehouse": ("is", "not set"),
"status": "Inactive",
"work_order": self.pro_doc.name,
},
fields=["name"],
order_by="creation asc",
)
def get_available_serial_nos(self, stock_entries): for row in data:
used_serial_nos = [] serial_nos.append(row.name)
for row in stock_entries:
if row.serial_no:
used_serial_nos.extend(get_serial_nos(row.serial_no))
return sorted(list(set(get_serial_nos(self.pro_doc.serial_no)) - set(used_serial_nos))) return serial_nos
def update_subcontracting_order_status(self): def update_subcontracting_order_status(self):
if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]: if self.subcontracting_order and self.purpose in ["Send to Subcontractor", "Material Transfer"]:
@ -2365,6 +2409,11 @@ class StockEntry(StockController):
@frappe.whitelist() @frappe.whitelist()
def move_sample_to_retention_warehouse(company, items): def move_sample_to_retention_warehouse(company, items):
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
)
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
if isinstance(items, str): if isinstance(items, str):
items = json.loads(items) items = json.loads(items)
retention_warehouse = frappe.db.get_single_value("Stock Settings", "sample_retention_warehouse") retention_warehouse = frappe.db.get_single_value("Stock Settings", "sample_retention_warehouse")
@ -2373,20 +2422,25 @@ def move_sample_to_retention_warehouse(company, items):
stock_entry.purpose = "Material Transfer" stock_entry.purpose = "Material Transfer"
stock_entry.set_stock_entry_type() stock_entry.set_stock_entry_type()
for item in items: for item in items:
if item.get("sample_quantity") and item.get("batch_no"): if item.get("sample_quantity") and item.get("serial_and_batch_bundle"):
batch_no = get_batch_from_bundle(item.get("serial_and_batch_bundle"))
sample_quantity = validate_sample_quantity( sample_quantity = validate_sample_quantity(
item.get("item_code"), item.get("item_code"),
item.get("sample_quantity"), item.get("sample_quantity"),
item.get("transfer_qty") or item.get("qty"), item.get("transfer_qty") or item.get("qty"),
item.get("batch_no"), batch_no,
) )
if sample_quantity: if sample_quantity:
sample_serial_nos = "" cls_obj = SerialBatchCreation(
if item.get("serial_no"): {
serial_nos = (item.get("serial_no")).split() "type_of_transaction": "Outward",
if serial_nos and len(serial_nos) > item.get("sample_quantity"): "serial_and_batch_bundle": item.get("serial_and_batch_bundle"),
serial_no_list = serial_nos[: -(len(serial_nos) - item.get("sample_quantity"))] "item_code": item.get("item_code"),
sample_serial_nos = "\n".join(serial_no_list) }
)
cls_obj.duplicate_package()
stock_entry.append( stock_entry.append(
"items", "items",
@ -2399,8 +2453,7 @@ def move_sample_to_retention_warehouse(company, items):
"uom": item.get("uom"), "uom": item.get("uom"),
"stock_uom": item.get("stock_uom"), "stock_uom": item.get("stock_uom"),
"conversion_factor": item.get("conversion_factor") or 1.0, "conversion_factor": item.get("conversion_factor") or 1.0,
"serial_no": sample_serial_nos, "serial_and_batch_bundle": cls_obj.serial_and_batch_bundle,
"batch_no": item.get("batch_no"),
}, },
) )
if stock_entry.get("items"): if stock_entry.get("items"):
@ -2412,6 +2465,7 @@ def make_stock_in_entry(source_name, target_doc=None):
def set_missing_values(source, target): def set_missing_values(source, target):
target.stock_entry_type = "Material Transfer" target.stock_entry_type = "Material Transfer"
target.set_missing_values() target.set_missing_values()
target.make_serial_and_batch_bundle_for_transfer()
def update_item(source_doc, target_doc, source_parent): def update_item(source_doc, target_doc, source_parent):
target_doc.t_warehouse = "" target_doc.t_warehouse = ""
@ -2725,9 +2779,17 @@ def get_available_materials(work_order) -> dict:
if row.batch_no: if row.batch_no:
item_data.batch_details[row.batch_no] += row.qty item_data.batch_details[row.batch_no] += row.qty
if row.batch_nos:
for batch_no, qty in row.batch_nos.items():
item_data.batch_details[batch_no] += qty
if row.serial_no: if row.serial_no:
item_data.serial_nos.extend(get_serial_nos(row.serial_no)) item_data.serial_nos.extend(get_serial_nos(row.serial_no))
item_data.serial_nos.sort() item_data.serial_nos.sort()
if row.serial_nos:
item_data.serial_nos.extend(get_serial_nos(row.serial_nos))
item_data.serial_nos.sort()
else: else:
# Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture' # Consume raw material qty in case of 'Manufacture' or 'Material Consumption for Manufacture'
@ -2735,18 +2797,30 @@ def get_available_materials(work_order) -> dict:
if row.batch_no: if row.batch_no:
item_data.batch_details[row.batch_no] -= row.qty item_data.batch_details[row.batch_no] -= row.qty
if row.batch_nos:
for batch_no, qty in row.batch_nos.items():
item_data.batch_details[batch_no] += qty
if row.serial_no: if row.serial_no:
for serial_no in get_serial_nos(row.serial_no): for serial_no in get_serial_nos(row.serial_no):
item_data.serial_nos.remove(serial_no) item_data.serial_nos.remove(serial_no)
if row.serial_nos:
for serial_no in get_serial_nos(row.serial_nos):
item_data.serial_nos.remove(serial_no)
return available_materials return available_materials
def get_stock_entry_data(work_order): def get_stock_entry_data(work_order):
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_voucher_wise_serial_batch_from_bundle,
)
stock_entry = frappe.qb.DocType("Stock Entry") stock_entry = frappe.qb.DocType("Stock Entry")
stock_entry_detail = frappe.qb.DocType("Stock Entry Detail") stock_entry_detail = frappe.qb.DocType("Stock Entry Detail")
return ( data = (
frappe.qb.from_(stock_entry) frappe.qb.from_(stock_entry)
.from_(stock_entry_detail) .from_(stock_entry_detail)
.select( .select(
@ -2760,9 +2834,11 @@ def get_stock_entry_data(work_order):
stock_entry_detail.stock_uom, stock_entry_detail.stock_uom,
stock_entry_detail.expense_account, stock_entry_detail.expense_account,
stock_entry_detail.cost_center, stock_entry_detail.cost_center,
stock_entry_detail.serial_and_batch_bundle,
stock_entry_detail.batch_no, stock_entry_detail.batch_no,
stock_entry_detail.serial_no, stock_entry_detail.serial_no,
stock_entry.purpose, stock_entry.purpose,
stock_entry.name,
) )
.where( .where(
(stock_entry.name == stock_entry_detail.parent) (stock_entry.name == stock_entry_detail.parent)
@ -2777,3 +2853,86 @@ def get_stock_entry_data(work_order):
) )
.orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx) .orderby(stock_entry.creation, stock_entry_detail.item_code, stock_entry_detail.idx)
).run(as_dict=1) ).run(as_dict=1)
if not data:
return []
voucher_nos = [row.get("name") for row in data if row.get("name")]
if voucher_nos:
bundle_data = get_voucher_wise_serial_batch_from_bundle(voucher_no=voucher_nos)
for row in data:
key = (row.item_code, row.warehouse, row.name)
if row.purpose != "Material Transfer for Manufacture":
key = (row.item_code, row.s_warehouse, row.name)
if bundle_data.get(key):
row.update(bundle_data.get(key))
return data
def create_serial_and_batch_bundle(row, child, type_of_transaction=None):
item_details = frappe.get_cached_value(
"Item", child.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
if not (item_details.has_serial_no or item_details.has_batch_no):
return
if not type_of_transaction:
type_of_transaction = "Inward"
doc = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"voucher_type": "Stock Entry",
"item_code": child.item_code,
"warehouse": child.warehouse,
"type_of_transaction": type_of_transaction,
}
)
if row.serial_nos and row.batches_to_be_consume:
doc.has_serial_no = 1
doc.has_batch_no = 1
batchwise_serial_nos = get_batchwise_serial_nos(child.item_code, row)
for batch_no, qty in row.batches_to_be_consume.items():
while qty > 0:
qty -= 1
doc.append(
"entries",
{
"batch_no": batch_no,
"serial_no": batchwise_serial_nos.get(batch_no).pop(0),
"warehouse": row.warehouse,
"qty": -1,
},
)
elif row.serial_nos:
doc.has_serial_no = 1
for serial_no in row.serial_nos:
doc.append("entries", {"serial_no": serial_no, "warehouse": row.warehouse, "qty": -1})
elif row.batches_to_be_consume:
doc.has_batch_no = 1
for batch_no, qty in row.batches_to_be_consume.items():
doc.append("entries", {"batch_no": batch_no, "warehouse": row.warehouse, "qty": qty * -1})
return doc.insert(ignore_permissions=True).name
def get_batchwise_serial_nos(item_code, row):
batchwise_serial_nos = {}
for batch_no in row.batches_to_be_consume:
serial_nos = frappe.get_all(
"Serial No",
filters={"item_code": item_code, "batch_no": batch_no, "name": ("in", row.serial_nos)},
)
if serial_nos:
batchwise_serial_nos[batch_no] = sorted([serial_no.name for serial_no in serial_nos])
return batchwise_serial_nos

View File

@ -52,6 +52,7 @@ def make_stock_entry(**args):
:do_not_save: Optional flag :do_not_save: Optional flag
:do_not_submit: Optional flag :do_not_submit: Optional flag
""" """
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
def process_serial_numbers(serial_nos_list): def process_serial_numbers(serial_nos_list):
serial_nos_list = [ serial_nos_list = [
@ -131,16 +132,36 @@ def make_stock_entry(**args):
# We can find out the serial number using the batch source document # We can find out the serial number using the batch source document
serial_number = args.serial_no serial_number = args.serial_no
if not args.serial_no and args.qty and args.batch_no: bundle_id = None
serial_number_list = frappe.get_list( if args.serial_no or args.batch_no or args.batches:
doctype="Stock Ledger Entry", batches = frappe._dict({})
fields=["serial_no"], if args.batch_no:
filters={"batch_no": args.batch_no, "warehouse": args.from_warehouse}, batches = frappe._dict({args.batch_no: args.qty})
elif args.batches:
batches = args.batches
bundle_id = (
SerialBatchCreation(
{
"item_code": args.item,
"warehouse": args.source or args.target,
"voucher_type": "Stock Entry",
"total_qty": args.qty * (-1 if args.source else 1),
"batches": batches,
"serial_nos": args.serial_no,
"type_of_transaction": "Outward" if args.source else "Inward",
"company": s.company,
"posting_date": s.posting_date,
"posting_time": s.posting_time,
"rate": args.rate or args.basic_rate,
"do_not_submit": True,
}
)
.make_serial_and_batch_bundle()
.name
) )
serial_number = process_serial_numbers(serial_number_list)
args.serial_no = serial_number args.serial_no = serial_number
s.append( s.append(
"items", "items",
{ {
@ -148,6 +169,7 @@ def make_stock_entry(**args):
"s_warehouse": args.source, "s_warehouse": args.source,
"t_warehouse": args.target, "t_warehouse": args.target,
"qty": args.qty, "qty": args.qty,
"serial_and_batch_bundle": bundle_id,
"basic_rate": args.rate or args.basic_rate, "basic_rate": args.rate or args.basic_rate,
"conversion_factor": args.conversion_factor or 1.0, "conversion_factor": args.conversion_factor or 1.0,
"transfer_qty": flt(args.qty) * (flt(args.conversion_factor) or 1.0), "transfer_qty": flt(args.qty) * (flt(args.conversion_factor) or 1.0),
@ -164,4 +186,7 @@ def make_stock_entry(**args):
s.insert() s.insert()
if not args.do_not_submit: if not args.do_not_submit:
s.submit() s.submit()
s.load_from_db()
return s return s

View File

@ -14,12 +14,13 @@ from erpnext.stock.doctype.item.test_item import (
make_item_variant, make_item_variant,
set_item_variant_settings, set_item_variant_settings,
) )
from erpnext.stock.doctype.serial_no.serial_no import * # noqa from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
from erpnext.stock.doctype.stock_entry.stock_entry import ( get_batch_from_bundle,
FinishedGoodError, get_serial_nos_from_bundle,
make_stock_in_entry, make_serial_batch_bundle,
move_sample_to_retention_warehouse,
) )
from erpnext.stock.doctype.serial_no.serial_no import * # noqa
from erpnext.stock.doctype.stock_entry.stock_entry import FinishedGoodError, make_stock_in_entry
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import StockFreezeError
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
@ -28,6 +29,7 @@ from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
create_stock_reconciliation, create_stock_reconciliation,
) )
from erpnext.stock.serial_batch_bundle import SerialBatchCreation
from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle from erpnext.stock.stock_ledger import NegativeStockError, get_previous_sle
@ -549,28 +551,47 @@ class TestStockEntry(FrappeTestCase):
def test_serial_no_not_reqd(self): def test_serial_no_not_reqd(self):
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])
se.get("items")[0].serial_no = "ABCD" se.get("items")[0].serial_no = "ABCD"
se.set_stock_entry_type()
se.insert() bundle_id = make_serial_batch_bundle(
self.assertRaises(SerialNoNotRequiredError, se.submit) frappe._dict(
{
"item_code": se.get("items")[0].item_code,
"warehouse": se.get("items")[0].t_warehouse,
"company": se.company,
"qty": 2,
"voucher_type": "Stock Entry",
"serial_nos": ["ABCD"],
"posting_date": se.posting_date,
"posting_time": se.posting_time,
"do_not_save": True,
}
)
)
self.assertRaises(frappe.ValidationError, bundle_id.make_serial_and_batch_bundle)
def test_serial_no_reqd(self): def test_serial_no_reqd(self):
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])
se.get("items")[0].item_code = "_Test Serialized Item" se.get("items")[0].item_code = "_Test Serialized Item"
se.get("items")[0].qty = 2 se.get("items")[0].qty = 2
se.get("items")[0].transfer_qty = 2 se.get("items")[0].transfer_qty = 2
se.set_stock_entry_type()
se.insert()
self.assertRaises(SerialNoRequiredError, se.submit)
def test_serial_no_qty_more(self): bundle_id = make_serial_batch_bundle(
se = frappe.copy_doc(test_records[0]) frappe._dict(
se.get("items")[0].item_code = "_Test Serialized Item" {
se.get("items")[0].qty = 2 "item_code": se.get("items")[0].item_code,
se.get("items")[0].serial_no = "ABCD\nEFGH\nXYZ" "warehouse": se.get("items")[0].t_warehouse,
se.get("items")[0].transfer_qty = 2 "company": se.company,
se.set_stock_entry_type() "qty": 2,
se.insert() "voucher_type": "Stock Entry",
self.assertRaises(SerialNoQtyError, se.submit) "posting_date": se.posting_date,
"posting_time": se.posting_time,
"do_not_save": True,
}
)
)
self.assertRaises(frappe.ValidationError, bundle_id.make_serial_and_batch_bundle)
def test_serial_no_qty_less(self): def test_serial_no_qty_less(self):
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])
@ -578,91 +599,85 @@ class TestStockEntry(FrappeTestCase):
se.get("items")[0].qty = 2 se.get("items")[0].qty = 2
se.get("items")[0].serial_no = "ABCD" se.get("items")[0].serial_no = "ABCD"
se.get("items")[0].transfer_qty = 2 se.get("items")[0].transfer_qty = 2
se.set_stock_entry_type()
se.insert() bundle_id = make_serial_batch_bundle(
self.assertRaises(SerialNoQtyError, se.submit) frappe._dict(
{
"item_code": se.get("items")[0].item_code,
"warehouse": se.get("items")[0].t_warehouse,
"company": se.company,
"qty": 2,
"serial_nos": ["ABCD"],
"voucher_type": "Stock Entry",
"posting_date": se.posting_date,
"posting_time": se.posting_time,
"do_not_save": True,
}
)
)
self.assertRaises(frappe.ValidationError, bundle_id.make_serial_and_batch_bundle)
def test_serial_no_transfer_in(self): def test_serial_no_transfer_in(self):
serial_nos = ["ABCD1", "EFGH1"]
for serial_no in serial_nos:
if not frappe.db.exists("Serial No", serial_no):
doc = frappe.new_doc("Serial No")
doc.serial_no = serial_no
doc.item_code = "_Test Serialized Item"
doc.insert(ignore_permissions=True)
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])
se.get("items")[0].item_code = "_Test Serialized Item" se.get("items")[0].item_code = "_Test Serialized Item"
se.get("items")[0].qty = 2 se.get("items")[0].qty = 2
se.get("items")[0].serial_no = "ABCD\nEFGH"
se.get("items")[0].transfer_qty = 2 se.get("items")[0].transfer_qty = 2
se.set_stock_entry_type() se.set_stock_entry_type()
se.get("items")[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": se.get("items")[0].item_code,
"warehouse": se.get("items")[0].t_warehouse,
"company": se.company,
"qty": 2,
"voucher_type": "Stock Entry",
"serial_nos": serial_nos,
"posting_date": se.posting_date,
"posting_time": se.posting_time,
"do_not_submit": True,
}
)
).name
se.insert() se.insert()
se.submit() se.submit()
self.assertTrue(frappe.db.exists("Serial No", "ABCD")) self.assertTrue(frappe.db.get_value("Serial No", "ABCD1", "warehouse"))
self.assertTrue(frappe.db.exists("Serial No", "EFGH")) self.assertTrue(frappe.db.get_value("Serial No", "EFGH1", "warehouse"))
se.cancel() se.cancel()
self.assertFalse(frappe.db.get_value("Serial No", "ABCD", "warehouse")) self.assertFalse(frappe.db.get_value("Serial No", "ABCD1", "warehouse"))
def test_serial_no_not_exists(self):
frappe.db.sql("delete from `tabSerial No` where name in ('ABCD', 'EFGH')")
make_serialized_item(target_warehouse="_Test Warehouse 1 - _TC")
se = frappe.copy_doc(test_records[0])
se.purpose = "Material Issue"
se.get("items")[0].item_code = "_Test Serialized Item With Series"
se.get("items")[0].qty = 2
se.get("items")[0].s_warehouse = "_Test Warehouse 1 - _TC"
se.get("items")[0].t_warehouse = None
se.get("items")[0].serial_no = "ABCD\nEFGH"
se.get("items")[0].transfer_qty = 2
se.set_stock_entry_type()
se.insert()
self.assertRaises(SerialNoNotExistsError, se.submit)
def test_serial_duplicate(self):
se, serial_nos = self.test_serial_by_series()
se = frappe.copy_doc(test_records[0])
se.get("items")[0].item_code = "_Test Serialized Item With Series"
se.get("items")[0].qty = 1
se.get("items")[0].serial_no = serial_nos[0]
se.get("items")[0].transfer_qty = 1
se.set_stock_entry_type()
se.insert()
self.assertRaises(SerialNoDuplicateError, se.submit)
def test_serial_by_series(self): def test_serial_by_series(self):
se = make_serialized_item() se = make_serialized_item()
serial_nos = get_serial_nos(se.get("items")[0].serial_no) serial_nos = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)
self.assertTrue(frappe.db.exists("Serial No", serial_nos[0])) self.assertTrue(frappe.db.exists("Serial No", serial_nos[0]))
self.assertTrue(frappe.db.exists("Serial No", serial_nos[1])) self.assertTrue(frappe.db.exists("Serial No", serial_nos[1]))
return se, serial_nos return se, serial_nos
def test_serial_item_error(self):
se, serial_nos = self.test_serial_by_series()
if not frappe.db.exists("Serial No", "ABCD"):
make_serialized_item(item_code="_Test Serialized Item", serial_no="ABCD\nEFGH")
se = frappe.copy_doc(test_records[0])
se.purpose = "Material Transfer"
se.get("items")[0].item_code = "_Test Serialized Item"
se.get("items")[0].qty = 1
se.get("items")[0].transfer_qty = 1
se.get("items")[0].serial_no = serial_nos[0]
se.get("items")[0].s_warehouse = "_Test Warehouse - _TC"
se.get("items")[0].t_warehouse = "_Test Warehouse 1 - _TC"
se.set_stock_entry_type()
se.insert()
self.assertRaises(SerialNoItemError, se.submit)
def test_serial_move(self): def test_serial_move(self):
se = make_serialized_item() se = make_serialized_item()
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]
se = frappe.copy_doc(test_records[0]) se = frappe.copy_doc(test_records[0])
se.purpose = "Material Transfer" se.purpose = "Material Transfer"
se.get("items")[0].item_code = "_Test Serialized Item With Series" se.get("items")[0].item_code = "_Test Serialized Item With Series"
se.get("items")[0].qty = 1 se.get("items")[0].qty = 1
se.get("items")[0].transfer_qty = 1 se.get("items")[0].transfer_qty = 1
se.get("items")[0].serial_no = serial_no se.get("items")[0].serial_no = [serial_no]
se.get("items")[0].s_warehouse = "_Test Warehouse - _TC" se.get("items")[0].s_warehouse = "_Test Warehouse - _TC"
se.get("items")[0].t_warehouse = "_Test Warehouse 1 - _TC" se.get("items")[0].t_warehouse = "_Test Warehouse 1 - _TC"
se.set_stock_entry_type() se.set_stock_entry_type()
@ -677,29 +692,12 @@ class TestStockEntry(FrappeTestCase):
frappe.db.get_value("Serial No", serial_no, "warehouse"), "_Test Warehouse - _TC" frappe.db.get_value("Serial No", serial_no, "warehouse"), "_Test Warehouse - _TC"
) )
def test_serial_warehouse_error(self):
make_serialized_item(target_warehouse="_Test Warehouse 1 - _TC")
t = make_serialized_item()
serial_nos = get_serial_nos(t.get("items")[0].serial_no)
se = frappe.copy_doc(test_records[0])
se.purpose = "Material Transfer"
se.get("items")[0].item_code = "_Test Serialized Item With Series"
se.get("items")[0].qty = 1
se.get("items")[0].transfer_qty = 1
se.get("items")[0].serial_no = serial_nos[0]
se.get("items")[0].s_warehouse = "_Test Warehouse 1 - _TC"
se.get("items")[0].t_warehouse = "_Test Warehouse - _TC"
se.set_stock_entry_type()
se.insert()
self.assertRaises(SerialNoWarehouseError, se.submit)
def test_serial_cancel(self): def test_serial_cancel(self):
se, serial_nos = self.test_serial_by_series() se, serial_nos = self.test_serial_by_series()
se.cancel() se.load_from_db()
serial_no = get_serial_nos_from_bundle(se.get("items")[0].serial_and_batch_bundle)[0]
serial_no = get_serial_nos(se.get("items")[0].serial_no)[0] se.cancel()
self.assertFalse(frappe.db.get_value("Serial No", serial_no, "warehouse")) self.assertFalse(frappe.db.get_value("Serial No", serial_no, "warehouse"))
def test_serial_batch_item_stock_entry(self): def test_serial_batch_item_stock_entry(self):
@ -726,8 +724,8 @@ class TestStockEntry(FrappeTestCase):
se = make_stock_entry( se = make_stock_entry(
item_code=item.item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100 item_code=item.item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100
) )
batch_no = se.items[0].batch_no batch_no = get_batch_from_bundle(se.items[0].serial_and_batch_bundle)
serial_no = get_serial_nos(se.items[0].serial_no)[0] serial_no = get_serial_nos_from_bundle(se.items[0].serial_and_batch_bundle)[0]
batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code) batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no") batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no")
@ -738,67 +736,7 @@ class TestStockEntry(FrappeTestCase):
se.cancel() se.cancel()
batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no") batch_in_serial_no = frappe.db.get_value("Serial No", serial_no, "batch_no")
self.assertEqual(batch_in_serial_no, None) self.assertEqual(frappe.db.get_value("Serial No", serial_no, "warehouse"), None)
self.assertEqual(frappe.db.get_value("Serial No", serial_no, "status"), "Inactive")
self.assertEqual(frappe.db.exists("Batch", batch_no), None)
def test_serial_batch_item_qty_deduction(self):
"""
Behaviour: Create 2 Stock Entries, both adding Serial Nos to same batch
Expected: 1) Cancelling first Stock Entry (origin transaction of created batch)
should throw a LinkExistsError
2) Cancelling second Stock Entry should make Serial Nos that are, linked to mentioned batch
and in that transaction only, Inactive.
"""
from erpnext.stock.doctype.batch.batch import get_batch_qty
item = frappe.db.exists("Item", {"item_name": "Batched and Serialised Item"})
if not item:
item = create_item("Batched and Serialised Item")
item.has_batch_no = 1
item.create_new_batch = 1
item.has_serial_no = 1
item.batch_number_series = "B-BATCH-.##"
item.serial_no_series = "S-.####"
item.save()
else:
item = frappe.get_doc("Item", {"item_name": "Batched and Serialised Item"})
se1 = make_stock_entry(
item_code=item.item_code, target="_Test Warehouse - _TC", qty=1, basic_rate=100
)
batch_no = se1.items[0].batch_no
serial_no1 = get_serial_nos(se1.items[0].serial_no)[0]
# Check Source (Origin) Document of Batch
self.assertEqual(frappe.db.get_value("Batch", batch_no, "reference_name"), se1.name)
se2 = make_stock_entry(
item_code=item.item_code,
target="_Test Warehouse - _TC",
qty=1,
basic_rate=100,
batch_no=batch_no,
)
serial_no2 = get_serial_nos(se2.items[0].serial_no)[0]
batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
self.assertEqual(batch_qty, 2)
se2.cancel()
# Check decrease in Batch Qty
batch_qty = get_batch_qty(batch_no, "_Test Warehouse - _TC", item.item_code)
self.assertEqual(batch_qty, 1)
# Check if Serial No from Stock Entry 1 is intact
self.assertEqual(frappe.db.get_value("Serial No", serial_no1, "batch_no"), batch_no)
self.assertEqual(frappe.db.get_value("Serial No", serial_no1, "status"), "Active")
# Check if Serial No from Stock Entry 2 is Unlinked and Inactive
self.assertEqual(frappe.db.get_value("Serial No", serial_no2, "batch_no"), None)
self.assertEqual(frappe.db.get_value("Serial No", serial_no2, "status"), "Inactive")
def test_warehouse_company_validation(self): def test_warehouse_company_validation(self):
company = frappe.db.get_value("Warehouse", "_Test Warehouse 2 - _TC1", "company") company = frappe.db.get_value("Warehouse", "_Test Warehouse 2 - _TC1", "company")
@ -1004,7 +942,7 @@ class TestStockEntry(FrappeTestCase):
def test_same_serial_nos_in_repack_or_manufacture_entries(self): def test_same_serial_nos_in_repack_or_manufacture_entries(self):
s1 = make_serialized_item(target_warehouse="_Test Warehouse - _TC") s1 = make_serialized_item(target_warehouse="_Test Warehouse - _TC")
serial_nos = s1.get("items")[0].serial_no serial_nos = get_serial_nos_from_bundle(s1.get("items")[0].serial_and_batch_bundle)
s2 = make_stock_entry( s2 = make_stock_entry(
item_code="_Test Serialized Item With Series", item_code="_Test Serialized Item With Series",
@ -1016,6 +954,26 @@ class TestStockEntry(FrappeTestCase):
do_not_save=True, do_not_save=True,
) )
cls_obj = SerialBatchCreation(
{
"type_of_transaction": "Inward",
"serial_and_batch_bundle": s2.items[0].serial_and_batch_bundle,
"item_code": "_Test Serialized Item",
}
)
cls_obj.duplicate_package()
bundle_id = cls_obj.serial_and_batch_bundle
doc = frappe.get_doc("Serial and Batch Bundle", bundle_id)
doc.db_set(
{
"item_code": "_Test Serialized Item",
"warehouse": "_Test Warehouse - _TC",
}
)
doc.load_from_db()
s2.append( s2.append(
"items", "items",
{ {
@ -1026,90 +984,90 @@ class TestStockEntry(FrappeTestCase):
"expense_account": "Stock Adjustment - _TC", "expense_account": "Stock Adjustment - _TC",
"conversion_factor": 1.0, "conversion_factor": 1.0,
"cost_center": "_Test Cost Center - _TC", "cost_center": "_Test Cost Center - _TC",
"serial_no": serial_nos, "serial_and_batch_bundle": bundle_id,
}, },
) )
s2.submit() s2.submit()
s2.cancel() s2.cancel()
def test_retain_sample(self): # def test_retain_sample(self):
from erpnext.stock.doctype.batch.batch import get_batch_qty # from erpnext.stock.doctype.batch.batch import get_batch_qty
from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse # from erpnext.stock.doctype.warehouse.test_warehouse import create_warehouse
create_warehouse("Test Warehouse for Sample Retention") # create_warehouse("Test Warehouse for Sample Retention")
frappe.db.set_value( # frappe.db.set_value(
"Stock Settings", # "Stock Settings",
None, # None,
"sample_retention_warehouse", # "sample_retention_warehouse",
"Test Warehouse for Sample Retention - _TC", # "Test Warehouse for Sample Retention - _TC",
) # )
test_item_code = "Retain Sample Item" # test_item_code = "Retain Sample Item"
if not frappe.db.exists("Item", test_item_code): # if not frappe.db.exists("Item", test_item_code):
item = frappe.new_doc("Item") # item = frappe.new_doc("Item")
item.item_code = test_item_code # item.item_code = test_item_code
item.item_name = "Retain Sample Item" # item.item_name = "Retain Sample Item"
item.description = "Retain Sample Item" # item.description = "Retain Sample Item"
item.item_group = "All Item Groups" # item.item_group = "All Item Groups"
item.is_stock_item = 1 # item.is_stock_item = 1
item.has_batch_no = 1 # item.has_batch_no = 1
item.create_new_batch = 1 # item.create_new_batch = 1
item.retain_sample = 1 # item.retain_sample = 1
item.sample_quantity = 4 # item.sample_quantity = 4
item.save() # item.save()
receipt_entry = frappe.new_doc("Stock Entry") # receipt_entry = frappe.new_doc("Stock Entry")
receipt_entry.company = "_Test Company" # receipt_entry.company = "_Test Company"
receipt_entry.purpose = "Material Receipt" # receipt_entry.purpose = "Material Receipt"
receipt_entry.append( # receipt_entry.append(
"items", # "items",
{ # {
"item_code": test_item_code, # "item_code": test_item_code,
"t_warehouse": "_Test Warehouse - _TC", # "t_warehouse": "_Test Warehouse - _TC",
"qty": 40, # "qty": 40,
"basic_rate": 12, # "basic_rate": 12,
"cost_center": "_Test Cost Center - _TC", # "cost_center": "_Test Cost Center - _TC",
"sample_quantity": 4, # "sample_quantity": 4,
}, # },
) # )
receipt_entry.set_stock_entry_type() # receipt_entry.set_stock_entry_type()
receipt_entry.insert() # receipt_entry.insert()
receipt_entry.submit() # receipt_entry.submit()
retention_data = move_sample_to_retention_warehouse( # retention_data = move_sample_to_retention_warehouse(
receipt_entry.company, receipt_entry.get("items") # receipt_entry.company, receipt_entry.get("items")
) # )
retention_entry = frappe.new_doc("Stock Entry") # retention_entry = frappe.new_doc("Stock Entry")
retention_entry.company = retention_data.company # retention_entry.company = retention_data.company
retention_entry.purpose = retention_data.purpose # retention_entry.purpose = retention_data.purpose
retention_entry.append( # retention_entry.append(
"items", # "items",
{ # {
"item_code": test_item_code, # "item_code": test_item_code,
"t_warehouse": "Test Warehouse for Sample Retention - _TC", # "t_warehouse": "Test Warehouse for Sample Retention - _TC",
"s_warehouse": "_Test Warehouse - _TC", # "s_warehouse": "_Test Warehouse - _TC",
"qty": 4, # "qty": 4,
"basic_rate": 12, # "basic_rate": 12,
"cost_center": "_Test Cost Center - _TC", # "cost_center": "_Test Cost Center - _TC",
"batch_no": receipt_entry.get("items")[0].batch_no, # "batch_no": get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle),
}, # },
) # )
retention_entry.set_stock_entry_type() # retention_entry.set_stock_entry_type()
retention_entry.insert() # retention_entry.insert()
retention_entry.submit() # retention_entry.submit()
qty_in_usable_warehouse = get_batch_qty( # qty_in_usable_warehouse = get_batch_qty(
receipt_entry.get("items")[0].batch_no, "_Test Warehouse - _TC", "_Test Item" # get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle), "_Test Warehouse - _TC", "_Test Item"
) # )
qty_in_retention_warehouse = get_batch_qty( # qty_in_retention_warehouse = get_batch_qty(
receipt_entry.get("items")[0].batch_no, # get_batch_from_bundle(receipt_entry.get("items")[0].serial_and_batch_bundle),
"Test Warehouse for Sample Retention - _TC", # "Test Warehouse for Sample Retention - _TC",
"_Test Item", # "_Test Item",
) # )
self.assertEqual(qty_in_usable_warehouse, 36) # self.assertEqual(qty_in_usable_warehouse, 36)
self.assertEqual(qty_in_retention_warehouse, 4) # self.assertEqual(qty_in_retention_warehouse, 4)
def test_quality_check(self): def test_quality_check(self):
item_code = "_Test Item For QC" item_code = "_Test Item For QC"
@ -1403,7 +1361,7 @@ class TestStockEntry(FrappeTestCase):
posting_date="2021-09-01", posting_date="2021-09-01",
purpose="Material Receipt", purpose="Material Receipt",
) )
batch_nos.append(se1.items[0].batch_no) batch_nos.append(get_batch_from_bundle(se1.items[0].serial_and_batch_bundle))
se2 = make_stock_entry( se2 = make_stock_entry(
item_code=item_code, item_code=item_code,
qty=2, qty=2,
@ -1411,9 +1369,9 @@ class TestStockEntry(FrappeTestCase):
posting_date="2021-09-03", posting_date="2021-09-03",
purpose="Material Receipt", purpose="Material Receipt",
) )
batch_nos.append(se2.items[0].batch_no) batch_nos.append(get_batch_from_bundle(se2.items[0].serial_and_batch_bundle))
with self.assertRaises(NegativeStockError) as nse: with self.assertRaises(frappe.ValidationError) as nse:
make_stock_entry( make_stock_entry(
item_code=item_code, item_code=item_code,
qty=1, qty=1,
@ -1434,8 +1392,6 @@ class TestStockEntry(FrappeTestCase):
""" """
from erpnext.stock.doctype.batch.test_batch import TestBatch from erpnext.stock.doctype.batch.test_batch import TestBatch
batch_nos = []
item_code = "_TestMultibatchFifo" item_code = "_TestMultibatchFifo"
TestBatch.make_batch_item(item_code) TestBatch.make_batch_item(item_code)
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
@ -1452,18 +1408,25 @@ class TestStockEntry(FrappeTestCase):
) )
receipt.save() receipt.save()
receipt.submit() receipt.submit()
batch_nos.extend(row.batch_no for row in receipt.items) receipt.load_from_db()
batches = frappe._dict(
{get_batch_from_bundle(row.serial_and_batch_bundle): row.qty for row in receipt.items}
)
self.assertEqual(receipt.value_difference, 30) self.assertEqual(receipt.value_difference, 30)
issue = make_stock_entry( issue = make_stock_entry(
item_code=item_code, qty=1, from_warehouse=warehouse, purpose="Material Issue", do_not_save=True item_code=item_code,
qty=2,
from_warehouse=warehouse,
purpose="Material Issue",
do_not_save=True,
batches=batches,
) )
issue.append("items", frappe.copy_doc(issue.items[0], ignore_no_copy=False))
for row, batch_no in zip(issue.items, batch_nos):
row.batch_no = batch_no
issue.save() issue.save()
issue.submit() issue.submit()
issue.reload() # reload because reposting current voucher updates rate issue.reload() # reload because reposting current voucher updates rate
self.assertEqual(issue.value_difference, -30) self.assertEqual(issue.value_difference, -30)
@ -1745,10 +1708,31 @@ def make_serialized_item(**args):
if args.company: if args.company:
se.company = args.company se.company = args.company
if args.target_warehouse:
se.get("items")[0].t_warehouse = args.target_warehouse
se.get("items")[0].item_code = args.item_code or "_Test Serialized Item With Series" se.get("items")[0].item_code = args.item_code or "_Test Serialized Item With Series"
if args.serial_no: if args.serial_no:
se.get("items")[0].serial_no = args.serial_no serial_nos = args.serial_no
if isinstance(serial_nos, str):
serial_nos = [serial_nos]
se.get("items")[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": se.get("items")[0].item_code,
"warehouse": se.get("items")[0].t_warehouse,
"company": se.company,
"qty": 2,
"voucher_type": "Stock Entry",
"serial_nos": serial_nos,
"posting_date": today(),
"posting_time": nowtime(),
"do_not_submit": True,
}
)
).name
if args.cost_center: if args.cost_center:
se.get("items")[0].cost_center = args.cost_center se.get("items")[0].cost_center = args.cost_center
@ -1759,12 +1743,11 @@ def make_serialized_item(**args):
se.get("items")[0].qty = 2 se.get("items")[0].qty = 2
se.get("items")[0].transfer_qty = 2 se.get("items")[0].transfer_qty = 2
if args.target_warehouse:
se.get("items")[0].t_warehouse = args.target_warehouse
se.set_stock_entry_type() se.set_stock_entry_type()
se.insert() se.insert()
se.submit() se.submit()
se.load_from_db()
return se return se

View File

@ -46,8 +46,10 @@
"basic_amount", "basic_amount",
"amount", "amount",
"serial_no_batch", "serial_no_batch",
"serial_no", "add_serial_batch_bundle",
"serial_and_batch_bundle",
"col_break4", "col_break4",
"serial_no",
"batch_no", "batch_no",
"accounting", "accounting",
"expense_account", "expense_account",
@ -292,7 +294,8 @@
"label": "Serial No", "label": "Serial No",
"no_copy": 1, "no_copy": 1,
"oldfieldname": "serial_no", "oldfieldname": "serial_no",
"oldfieldtype": "Text" "oldfieldtype": "Text",
"read_only": 1
}, },
{ {
"fieldname": "col_break4", "fieldname": "col_break4",
@ -305,7 +308,8 @@
"no_copy": 1, "no_copy": 1,
"oldfieldname": "batch_no", "oldfieldname": "batch_no",
"oldfieldtype": "Link", "oldfieldtype": "Link",
"options": "Batch" "options": "Batch",
"read_only": 1
}, },
{ {
"depends_on": "eval:parent.inspection_required && doc.t_warehouse", "depends_on": "eval:parent.inspection_required && doc.t_warehouse",
@ -566,6 +570,19 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Has Item Scanned", "label": "Has Item Scanned",
"read_only": 1 "read_only": 1
},
{
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch No"
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
} }
], ],
"idx": 1, "idx": 1,

View File

@ -15,9 +15,10 @@
"voucher_type", "voucher_type",
"voucher_no", "voucher_no",
"voucher_detail_no", "voucher_detail_no",
"serial_and_batch_bundle",
"dependant_sle_voucher_detail_no", "dependant_sle_voucher_detail_no",
"recalculate_rate",
"section_break_11", "section_break_11",
"recalculate_rate",
"actual_qty", "actual_qty",
"qty_after_transaction", "qty_after_transaction",
"incoming_rate", "incoming_rate",
@ -31,12 +32,14 @@
"company", "company",
"stock_uom", "stock_uom",
"project", "project",
"batch_no",
"column_break_26", "column_break_26",
"fiscal_year", "fiscal_year",
"serial_no", "has_batch_no",
"has_serial_no",
"is_cancelled", "is_cancelled",
"to_rename" "to_rename",
"serial_no",
"batch_no"
], ],
"fields": [ "fields": [
{ {
@ -309,6 +312,27 @@
"label": "Recalculate Incoming/Outgoing Rate", "label": "Recalculate Incoming/Outgoing Rate",
"no_copy": 1, "no_copy": 1,
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial and Batch Bundle",
"options": "Serial and Batch Bundle",
"search_index": 1
},
{
"default": "0",
"fetch_from": "item_code.has_batch_no",
"fieldname": "has_batch_no",
"fieldtype": "Check",
"label": "Has Batch No"
},
{
"default": "0",
"fetch_from": "item_code.has_serial_no",
"fieldname": "has_serial_no",
"fieldtype": "Check",
"label": "Has Serial No"
} }
], ],
"hide_toolbar": 1, "hide_toolbar": 1,
@ -317,7 +341,7 @@
"in_create": 1, "in_create": 1,
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"links": [], "links": [],
"modified": "2021-12-21 06:25:30.040801", "modified": "2023-04-03 16:33:16.270722",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Ledger Entry", "name": "Stock Ledger Entry",

View File

@ -12,6 +12,7 @@ from frappe.utils import add_days, cint, formatdate, get_datetime, getdate
from erpnext.accounts.utils import get_fiscal_year from erpnext.accounts.utils import get_fiscal_year
from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock from erpnext.controllers.item_variant import ItemTemplateCannotHaveStock
from erpnext.stock.serial_batch_bundle import SerialBatchBundle
class StockFreezeError(frappe.ValidationError): class StockFreezeError(frappe.ValidationError):
@ -40,7 +41,6 @@ class StockLedgerEntry(Document):
from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company from erpnext.stock.utils import validate_disabled_warehouse, validate_warehouse_company
self.validate_mandatory() self.validate_mandatory()
self.validate_item()
self.validate_batch() self.validate_batch()
validate_disabled_warehouse(self.warehouse) validate_disabled_warehouse(self.warehouse)
validate_warehouse_company(self.warehouse, self.company) validate_warehouse_company(self.warehouse, self.company)
@ -51,24 +51,20 @@ class StockLedgerEntry(Document):
def on_submit(self): def on_submit(self):
self.check_stock_frozen_date() self.check_stock_frozen_date()
self.calculate_batch_qty()
# Added to handle few test cases where serial_and_batch_bundles are not required
if frappe.flags.in_test and frappe.flags.ignore_serial_batch_bundle_validation:
return
if not self.get("via_landed_cost_voucher"): if not self.get("via_landed_cost_voucher"):
from erpnext.stock.doctype.serial_no.serial_no import process_serial_no SerialBatchBundle(
sle=self,
process_serial_no(self) item_code=self.item_code,
warehouse=self.warehouse,
def calculate_batch_qty(self): company=self.company,
if self.batch_no:
batch_qty = (
frappe.db.get_value(
"Stock Ledger Entry",
{"docstatus": 1, "batch_no": self.batch_no, "is_cancelled": 0},
"sum(actual_qty)",
)
or 0
) )
frappe.db.set_value("Batch", self.batch_no, "batch_qty", batch_qty)
self.validate_serial_batch_no_bundle()
def validate_mandatory(self): def validate_mandatory(self):
mandatory = ["warehouse", "posting_date", "voucher_type", "voucher_no", "company"] mandatory = ["warehouse", "posting_date", "voucher_type", "voucher_no", "company"]
@ -79,47 +75,45 @@ class StockLedgerEntry(Document):
if self.voucher_type != "Stock Reconciliation" and not self.actual_qty: if self.voucher_type != "Stock Reconciliation" and not self.actual_qty:
frappe.throw(_("Actual Qty is mandatory")) frappe.throw(_("Actual Qty is mandatory"))
def validate_item(self): def validate_serial_batch_no_bundle(self):
item_det = frappe.db.sql( item_detail = frappe.get_cached_value(
"""select name, item_name, has_batch_no, docstatus, "Item",
is_stock_item, has_variants, stock_uom, create_new_batch
from tabItem where name=%s""",
self.item_code, self.item_code,
as_dict=True, ["has_serial_no", "has_batch_no", "is_stock_item", "has_variants", "stock_uom"],
as_dict=1,
) )
if not item_det: values_to_be_change = {}
frappe.throw(_("Item {0} not found").format(self.item_code)) if self.has_batch_no != item_detail.has_batch_no:
values_to_be_change["has_batch_no"] = item_detail.has_batch_no
item_det = item_det[0] if self.has_serial_no != item_detail.has_serial_no:
values_to_be_change["has_serial_no"] = item_detail.has_serial_no
if item_det.is_stock_item != 1: if values_to_be_change:
frappe.throw(_("Item {0} must be a stock Item").format(self.item_code)) self.db_set(values_to_be_change)
# check if batch number is valid if not item_detail:
if item_det.has_batch_no == 1: self.throw_error_message(f"Item {self.item_code} not found")
batch_item = (
self.item_code
if self.item_code == item_det.item_name
else self.item_code + ":" + item_det.item_name
)
if not self.batch_no:
frappe.throw(_("Batch number is mandatory for Item {0}").format(batch_item))
elif not frappe.db.get_value("Batch", {"item": self.item_code, "name": self.batch_no}):
frappe.throw(
_("{0} is not a valid Batch Number for Item {1}").format(self.batch_no, batch_item)
)
elif item_det.has_batch_no == 0 and self.batch_no and self.is_cancelled == 0: if item_detail.has_variants:
frappe.throw(_("The Item {0} cannot have Batch").format(self.item_code)) self.throw_error_message(
f"Stock cannot exist for Item {self.item_code} since has variants",
if item_det.has_variants:
frappe.throw(
_("Stock cannot exist for Item {0} since has variants").format(self.item_code),
ItemTemplateCannotHaveStock, ItemTemplateCannotHaveStock,
) )
self.stock_uom = item_det.stock_uom if item_detail.is_stock_item != 1:
self.throw_error_message("Item {0} must be a stock Item").format(self.item_code)
if item_detail.has_serial_no or item_detail.has_batch_no:
if not self.serial_and_batch_bundle:
self.throw_error_message(f"Serial No / Batch No are mandatory for Item {self.item_code}")
if self.serial_and_batch_bundle and not (item_detail.has_serial_no or item_detail.has_batch_no):
self.throw_error_message(f"Serial No and Batch No are not allowed for Item {self.item_code}")
def throw_error_message(self, message, exception=frappe.ValidationError):
frappe.throw(_(message), exception)
def check_stock_frozen_date(self): def check_stock_frozen_date(self):
stock_settings = frappe.get_cached_doc("Stock Settings") stock_settings = frappe.get_cached_doc("Stock Settings")

View File

@ -18,6 +18,11 @@ from erpnext.stock.doctype.landed_cost_voucher.test_landed_cost_voucher import (
create_landed_cost_voucher, create_landed_cost_voucher,
) )
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry
from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import BackDatedStockTransaction from erpnext.stock.doctype.stock_ledger_entry.stock_ledger_entry import BackDatedStockTransaction
from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.test_stock_reconciliation import (
@ -480,13 +485,12 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list) dns = create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list)
sle_details = fetch_sle_details_for_doc_list(dns, ["stock_value_difference"]) sle_details = fetch_sle_details_for_doc_list(dns, ["stock_value_difference"])
svd_list = [-1 * d["stock_value_difference"] for d in sle_details] svd_list = [-1 * d["stock_value_difference"] for d in sle_details]
expected_incoming_rates = expected_abs_svd = [75, 125, 75, 125] expected_incoming_rates = expected_abs_svd = sorted([75.0, 125.0, 75.0, 125.0])
self.assertEqual(expected_abs_svd, svd_list, "Incorrect 'Stock Value Difference' values") self.assertEqual(expected_abs_svd, sorted(svd_list), "Incorrect 'Stock Value Difference' values")
for dn, incoming_rate in zip(dns, expected_incoming_rates): for dn, incoming_rate in zip(dns, expected_incoming_rates):
self.assertEqual( self.assertTrue(
dn.items[0].incoming_rate, dn.items[0].incoming_rate in expected_abs_svd,
incoming_rate,
"Incorrect 'Incoming Rate' values fetched for DN items", "Incorrect 'Incoming Rate' values fetched for DN items",
) )
@ -513,9 +517,12 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
osr2 = create_stock_reconciliation( osr2 = create_stock_reconciliation(
warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0] warehouse=warehouses[0], item_code=item, qty=13, rate=200, batch_no=batches[0]
) )
expected_sles = [ expected_sles = [
{"actual_qty": -10, "stock_value_difference": -10 * 100},
{"actual_qty": 13, "stock_value_difference": 200 * 13}, {"actual_qty": 13, "stock_value_difference": 200 * 13},
] ]
update_invariants(expected_sles) update_invariants(expected_sles)
self.assertSLEs(osr2, expected_sles) self.assertSLEs(osr2, expected_sles)
@ -524,7 +531,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
) )
expected_sles = [ expected_sles = [
{"actual_qty": -10, "stock_value_difference": -10 * 100}, {"actual_qty": -13, "stock_value_difference": -13 * 200},
{"actual_qty": 5, "stock_value_difference": 250}, {"actual_qty": 5, "stock_value_difference": 250},
] ]
update_invariants(expected_sles) update_invariants(expected_sles)
@ -534,7 +541,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0] warehouse=warehouses[0], item_code=item, qty=20, rate=75, batch_no=batches[0]
) )
expected_sles = [ expected_sles = [
{"actual_qty": -13, "stock_value_difference": -13 * 200}, {"actual_qty": -5, "stock_value_difference": -5 * 50},
{"actual_qty": 20, "stock_value_difference": 20 * 75}, {"actual_qty": 20, "stock_value_difference": 20 * 75},
] ]
update_invariants(expected_sles) update_invariants(expected_sles)
@ -711,7 +718,7 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
"qty_after_transaction", "qty_after_transaction",
"stock_queue", "stock_queue",
] ]
item, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) item, warehouses, batches = setup_item_valuation_test()
def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns): def check_sle_details_against_expected(sle_details, expected_sle_details, detail, columns):
for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)): for i, (sle_vals, ex_sle_vals) in enumerate(zip(sle_details, expected_sle_details)):
@ -736,8 +743,8 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
) )
sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
expected_sle_details = [ expected_sle_details = [
(50.0, 50.0, 1.0, 1.0, "[[1.0, 50.0]]"), (50.0, 50.0, 1.0, 1.0, "[]"),
(100.0, 150.0, 1.0, 2.0, "[[1.0, 50.0], [1.0, 100.0]]"), (100.0, 150.0, 1.0, 2.0, "[]"),
] ]
details_list.append((sle_details, expected_sle_details, "Material Receipt Entries", columns)) details_list.append((sle_details, expected_sle_details, "Material Receipt Entries", columns))
@ -749,152 +756,152 @@ class TestStockLedgerEntry(FrappeTestCase, StockTestMixin):
se_entry_list_mi, "Material Issue" se_entry_list_mi, "Material Issue"
) )
sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0) sle_details = fetch_sle_details_for_doc_list(ses, columns=columns, as_dict=0)
expected_sle_details = [(-50.0, 100.0, -1.0, 1.0, "[[1, 100.0]]")] expected_sle_details = [(-100.0, 50.0, -1.0, 1.0, "[]")]
details_list.append((sle_details, expected_sle_details, "Material Issue Entries", columns)) details_list.append((sle_details, expected_sle_details, "Material Issue Entries", columns))
# Run assertions # Run assertions
for details in details_list: for details in details_list:
check_sle_details_against_expected(*details) check_sle_details_against_expected(*details)
def test_mixed_valuation_batches_fifo(self): # def test_mixed_valuation_batches_fifo(self):
item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0) # item_code, warehouses, batches = setup_item_valuation_test(use_batchwise_valuation=0)
warehouse = warehouses[0] # warehouse = warehouses[0]
state = {"qty": 0.0, "stock_value": 0.0} # state = {"qty": 0.0, "stock_value": 0.0}
def update_invariants(exp_sles): # def update_invariants(exp_sles):
for sle in exp_sles: # for sle in exp_sles:
state["stock_value"] += sle["stock_value_difference"] # state["stock_value"] += sle["stock_value_difference"]
state["qty"] += sle["actual_qty"] # state["qty"] += sle["actual_qty"]
sle["stock_value"] = state["stock_value"] # sle["stock_value"] = state["stock_value"]
sle["qty_after_transaction"] = state["qty"] # sle["qty_after_transaction"] = state["qty"]
return exp_sles # return exp_sles
old1 = make_stock_entry( # old1 = make_stock_entry(
item_code=item_code, target=warehouse, batch_no=batches[0], qty=10, rate=10 # item_code=item_code, target=warehouse, batch_no=batches[0], qty=10, rate=10
) # )
self.assertSLEs( # self.assertSLEs(
old1, # old1,
update_invariants( # update_invariants(
[ # [
{"actual_qty": 10, "stock_value_difference": 10 * 10, "stock_queue": [[10, 10]]}, # {"actual_qty": 10, "stock_value_difference": 10 * 10, "stock_queue": [[10, 10]]},
] # ]
), # ),
) # )
old2 = make_stock_entry( # old2 = make_stock_entry(
item_code=item_code, target=warehouse, batch_no=batches[1], qty=10, rate=20 # item_code=item_code, target=warehouse, batch_no=batches[1], qty=10, rate=20
) # )
self.assertSLEs( # self.assertSLEs(
old2, # old2,
update_invariants( # update_invariants(
[ # [
{"actual_qty": 10, "stock_value_difference": 10 * 20, "stock_queue": [[10, 10], [10, 20]]}, # {"actual_qty": 10, "stock_value_difference": 10 * 20, "stock_queue": [[10, 10], [10, 20]]},
] # ]
), # ),
) # )
old3 = make_stock_entry( # old3 = make_stock_entry(
item_code=item_code, target=warehouse, batch_no=batches[0], qty=5, rate=15 # item_code=item_code, target=warehouse, batch_no=batches[0], qty=5, rate=15
) # )
self.assertSLEs( # self.assertSLEs(
old3, # old3,
update_invariants( # update_invariants(
[ # [
{ # {
"actual_qty": 5, # "actual_qty": 5,
"stock_value_difference": 5 * 15, # "stock_value_difference": 5 * 15,
"stock_queue": [[10, 10], [10, 20], [5, 15]], # "stock_queue": [[10, 10], [10, 20], [5, 15]],
}, # },
] # ]
), # ),
) # )
new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40) # new1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=40)
batches.append(new1.items[0].batch_no) # batches.append(new1.items[0].batch_no)
# assert old queue remains # # assert old queue remains
self.assertSLEs( # self.assertSLEs(
new1, # new1,
update_invariants( # update_invariants(
[ # [
{ # {
"actual_qty": 10, # "actual_qty": 10,
"stock_value_difference": 10 * 40, # "stock_value_difference": 10 * 40,
"stock_queue": [[10, 10], [10, 20], [5, 15]], # "stock_queue": [[10, 10], [10, 20], [5, 15]],
}, # },
] # ]
), # ),
) # )
new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42) # new2 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, rate=42)
batches.append(new2.items[0].batch_no) # batches.append(new2.items[0].batch_no)
self.assertSLEs( # self.assertSLEs(
new2, # new2,
update_invariants( # update_invariants(
[ # [
{ # {
"actual_qty": 10, # "actual_qty": 10,
"stock_value_difference": 10 * 42, # "stock_value_difference": 10 * 42,
"stock_queue": [[10, 10], [10, 20], [5, 15]], # "stock_queue": [[10, 10], [10, 20], [5, 15]],
}, # },
] # ]
), # ),
) # )
# consume old batch as per FIFO # # consume old batch as per FIFO
consume_old1 = make_stock_entry( # consume_old1 = make_stock_entry(
item_code=item_code, source=warehouse, qty=15, batch_no=batches[0] # item_code=item_code, source=warehouse, qty=15, batch_no=batches[0]
) # )
self.assertSLEs( # self.assertSLEs(
consume_old1, # consume_old1,
update_invariants( # update_invariants(
[ # [
{ # {
"actual_qty": -15, # "actual_qty": -15,
"stock_value_difference": -10 * 10 - 5 * 20, # "stock_value_difference": -10 * 10 - 5 * 20,
"stock_queue": [[5, 20], [5, 15]], # "stock_queue": [[5, 20], [5, 15]],
}, # },
] # ]
), # ),
) # )
# consume new batch as per batch # # consume new batch as per batch
consume_new2 = make_stock_entry( # consume_new2 = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1] # item_code=item_code, source=warehouse, qty=10, batch_no=batches[-1]
) # )
self.assertSLEs( # self.assertSLEs(
consume_new2, # consume_new2,
update_invariants( # update_invariants(
[ # [
{"actual_qty": -10, "stock_value_difference": -10 * 42, "stock_queue": [[5, 20], [5, 15]]}, # {"actual_qty": -10, "stock_value_difference": -10 * 42, "stock_queue": [[5, 20], [5, 15]]},
] # ]
), # ),
) # )
# finish all old batches # # finish all old batches
consume_old2 = make_stock_entry( # consume_old2 = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batches[1] # item_code=item_code, source=warehouse, qty=10, batch_no=batches[1]
) # )
self.assertSLEs( # self.assertSLEs(
consume_old2, # consume_old2,
update_invariants( # update_invariants(
[ # [
{"actual_qty": -10, "stock_value_difference": -5 * 20 - 5 * 15, "stock_queue": []}, # {"actual_qty": -10, "stock_value_difference": -5 * 20 - 5 * 15, "stock_queue": []},
] # ]
), # ),
) # )
# finish all new batches # # finish all new batches
consume_new1 = make_stock_entry( # consume_new1 = make_stock_entry(
item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2] # item_code=item_code, source=warehouse, qty=10, batch_no=batches[-2]
) # )
self.assertSLEs( # self.assertSLEs(
consume_new1, # consume_new1,
update_invariants( # update_invariants(
[ # [
{"actual_qty": -10, "stock_value_difference": -10 * 40, "stock_queue": []}, # {"actual_qty": -10, "stock_value_difference": -10 * 40, "stock_queue": []},
] # ]
), # ),
) # )
def test_fifo_dependent_consumption(self): def test_fifo_dependent_consumption(self):
item = make_item("_TestFifoTransferRates") item = make_item("_TestFifoTransferRates")
@ -1400,6 +1407,23 @@ def create_delivery_note_entries_for_batchwise_item_valuation_test(dn_entry_list
) )
dn = make_delivery_note(so.name) dn = make_delivery_note(so.name)
dn.items[0].serial_and_batch_bundle = make_serial_batch_bundle(
frappe._dict(
{
"item_code": dn.items[0].item_code,
"qty": dn.items[0].qty * (-1 if not dn.is_return else 1),
"batches": frappe._dict({batch_no: qty}),
"type_of_transaction": "Outward",
"warehouse": dn.items[0].warehouse,
"posting_date": dn.posting_date,
"posting_time": dn.posting_time,
"voucher_type": "Delivery Note",
"do_not_submit": dn.name,
}
)
).name
dn.items[0].batch_no = batch_no dn.items[0].batch_no = batch_no
dn.insert() dn.insert()
dn.submit() dn.submit()

View File

@ -5,6 +5,10 @@ frappe.provide("erpnext.stock");
frappe.provide("erpnext.accounts.dimensions"); frappe.provide("erpnext.accounts.dimensions");
frappe.ui.form.on("Stock Reconciliation", { frappe.ui.form.on("Stock Reconciliation", {
setup(frm) {
frm.ignore_doctypes_on_cancel_all = ['Serial and Batch Bundle'];
},
onload: function(frm) { onload: function(frm) {
frm.add_fetch("item_code", "item_name", "item_name"); frm.add_fetch("item_code", "item_name", "item_name");

View File

@ -11,7 +11,10 @@ from frappe.utils import cint, cstr, flt
import erpnext import erpnext
from erpnext.accounts.utils import get_company_default from erpnext.accounts.utils import get_company_default
from erpnext.controllers.stock_controller import StockController from erpnext.controllers.stock_controller import StockController
from erpnext.stock.doctype.batch.batch import get_batch_qty from erpnext.stock.doctype.batch.batch import get_available_batches, get_batch_qty
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_available_serial_nos,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.utils import get_stock_balance from erpnext.stock.utils import get_stock_balance
@ -37,6 +40,8 @@ class StockReconciliation(StockController):
if not self.cost_center: if not self.cost_center:
self.cost_center = frappe.get_cached_value("Company", self.company, "cost_center") self.cost_center = frappe.get_cached_value("Company", self.company, "cost_center")
self.validate_posting_time() self.validate_posting_time()
self.set_current_serial_and_batch_bundle()
self.set_new_serial_and_batch_bundle()
self.remove_items_with_no_change() self.remove_items_with_no_change()
self.validate_data() self.validate_data()
self.validate_expense_account() self.validate_expense_account()
@ -48,38 +53,155 @@ class StockReconciliation(StockController):
if self._action == "submit": if self._action == "submit":
self.validate_reserved_stock() self.validate_reserved_stock()
self.make_batches("warehouse")
def on_update(self):
self.set_serial_and_batch_bundle(ignore_validate=True)
def on_submit(self): def on_submit(self):
self.update_stock_ledger() self.update_stock_ledger()
self.make_gl_entries() self.make_gl_entries()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
from erpnext.stock.doctype.serial_no.serial_no import update_serial_nos_after_submit
update_serial_nos_after_submit(self, "items")
def on_cancel(self): def on_cancel(self):
self.ignore_linked_doctypes = ("GL Entry", "Stock Ledger Entry", "Repost Item Valuation")
self.validate_reserved_stock() self.validate_reserved_stock()
self.ignore_linked_doctypes = (
"GL Entry",
"Stock Ledger Entry",
"Repost Item Valuation",
"Serial and Batch Bundle",
)
self.make_sle_on_cancel() self.make_sle_on_cancel()
self.make_gl_entries_on_cancel() self.make_gl_entries_on_cancel()
self.repost_future_sle_and_gle() self.repost_future_sle_and_gle()
self.delete_auto_created_batches() self.delete_auto_created_batches()
def set_current_serial_and_batch_bundle(self):
"""Set Serial and Batch Bundle for each item"""
for item in self.items:
item_details = frappe.get_cached_value(
"Item", item.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
)
if not (item_details.has_serial_no or item_details.has_batch_no):
continue
if not item.current_serial_and_batch_bundle:
serial_and_batch_bundle = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"voucher_type": self.doctype,
"type_of_transaction": "Outward",
}
)
else:
serial_and_batch_bundle = frappe.get_doc(
"Serial and Batch Bundle", item.current_serial_and_batch_bundle
)
serial_and_batch_bundle.set("entries", [])
if item_details.has_serial_no:
serial_nos_details = get_available_serial_nos(
frappe._dict(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
)
for serial_no_row in serial_nos_details:
serial_and_batch_bundle.append(
"entries",
{
"serial_no": serial_no_row.serial_no,
"qty": -1,
"warehouse": serial_no_row.warehouse,
"batch_no": serial_no_row.batch_no,
},
)
if item_details.has_batch_no:
batch_nos_details = get_available_batches(
frappe._dict(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
}
)
)
for batch_no, qty in batch_nos_details.items():
serial_and_batch_bundle.append(
"entries",
{
"batch_no": batch_no,
"qty": qty * -1,
"warehouse": item.warehouse,
},
)
if not serial_and_batch_bundle.entries:
continue
item.current_serial_and_batch_bundle = serial_and_batch_bundle.save().name
item.current_qty = abs(serial_and_batch_bundle.total_qty)
item.current_valuation_rate = abs(serial_and_batch_bundle.avg_rate)
def set_new_serial_and_batch_bundle(self):
for item in self.items:
if item.current_serial_and_batch_bundle and not item.serial_and_batch_bundle:
current_doc = frappe.get_doc("Serial and Batch Bundle", item.current_serial_and_batch_bundle)
item.qty = abs(current_doc.total_qty)
item.valuation_rate = abs(current_doc.avg_rate)
bundle_doc = frappe.copy_doc(current_doc)
bundle_doc.warehouse = item.warehouse
bundle_doc.type_of_transaction = "Inward"
for row in bundle_doc.entries:
if row.qty < 0:
row.qty = abs(row.qty)
if row.stock_value_difference < 0:
row.stock_value_difference = abs(row.stock_value_difference)
row.is_outward = 0
bundle_doc.calculate_qty_and_amount()
bundle_doc.flags.ignore_permissions = True
bundle_doc.save()
item.serial_and_batch_bundle = bundle_doc.name
elif item.serial_and_batch_bundle and not item.qty and not item.valuation_rate:
bundle_doc = frappe.get_doc("Serial and Batch Bundle", item.serial_and_batch_bundle)
item.qty = bundle_doc.total_qty
item.valuation_rate = bundle_doc.avg_rate
def remove_items_with_no_change(self): def remove_items_with_no_change(self):
"""Remove items if qty or rate is not changed""" """Remove items if qty or rate is not changed"""
self.difference_amount = 0.0 self.difference_amount = 0.0
def _changed(item): def _changed(item):
if item.current_serial_and_batch_bundle:
self.calculate_difference_amount(item, frappe._dict({}))
return True
item_dict = get_stock_balance_for( item_dict = get_stock_balance_for(
item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no item.item_code, item.warehouse, self.posting_date, self.posting_time, batch_no=item.batch_no
) )
if ( if (item.qty is None or item.qty == item_dict.get("qty")) and (
(item.qty is None or item.qty == item_dict.get("qty")) item.valuation_rate is None or item.valuation_rate == item_dict.get("rate")
and (item.valuation_rate is None or item.valuation_rate == item_dict.get("rate"))
and (not item.serial_no or (item.serial_no == item_dict.get("serial_nos")))
): ):
return False return False
else: else:
@ -90,18 +212,9 @@ class StockReconciliation(StockController):
if item.valuation_rate is None: if item.valuation_rate is None:
item.valuation_rate = item_dict.get("rate") item.valuation_rate = item_dict.get("rate")
if item_dict.get("serial_nos"):
item.current_serial_no = item_dict.get("serial_nos")
if self.purpose == "Stock Reconciliation" and not item.serial_no and item.qty:
item.serial_no = item.current_serial_no
item.current_qty = item_dict.get("qty") item.current_qty = item_dict.get("qty")
item.current_valuation_rate = item_dict.get("rate") item.current_valuation_rate = item_dict.get("rate")
self.difference_amount += flt(item.qty, item.precision("qty")) * flt( self.calculate_difference_amount(item, item_dict)
item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate")
) - flt(item_dict.get("qty"), item.precision("qty")) * flt(
item_dict.get("rate"), item.precision("valuation_rate")
)
return True return True
items = list(filter(lambda d: _changed(d), self.items)) items = list(filter(lambda d: _changed(d), self.items))
@ -118,6 +231,13 @@ class StockReconciliation(StockController):
item.idx = i + 1 item.idx = i + 1
frappe.msgprint(_("Removed items with no change in quantity or value.")) frappe.msgprint(_("Removed items with no change in quantity or value."))
def calculate_difference_amount(self, item, item_dict):
self.difference_amount += flt(item.qty, item.precision("qty")) * flt(
item.valuation_rate or item_dict.get("rate"), item.precision("valuation_rate")
) - flt(item_dict.get("qty"), item.precision("qty")) * flt(
item_dict.get("rate"), item.precision("valuation_rate")
)
def validate_data(self): def validate_data(self):
def _get_msg(row_num, msg): def _get_msg(row_num, msg):
return _("Row # {0}:").format(row_num + 1) + " " + msg return _("Row # {0}:").format(row_num + 1) + " " + msg
@ -210,16 +330,6 @@ class StockReconciliation(StockController):
validate_end_of_life(item_code, item.end_of_life, item.disabled) validate_end_of_life(item_code, item.end_of_life, item.disabled)
validate_is_stock_item(item_code, item.is_stock_item) validate_is_stock_item(item_code, item.is_stock_item)
# item should not be serialized
if item.has_serial_no and not row.serial_no and not item.serial_no_series:
raise frappe.ValidationError(
_("Serial no(s) required for serialized item {0}").format(item_code)
)
# item managed batch-wise not allowed
if item.has_batch_no and not row.batch_no and not item.create_new_batch:
raise frappe.ValidationError(_("Batch no is required for batched item {0}").format(item_code))
# docstatus should be < 2 # docstatus should be < 2
validate_cancelled_item(item_code, item.docstatus) validate_cancelled_item(item_code, item.docstatus)
@ -272,18 +382,15 @@ class StockReconciliation(StockController):
from erpnext.stock.stock_ledger import get_previous_sle from erpnext.stock.stock_ledger import get_previous_sle
sl_entries = [] sl_entries = []
has_serial_no = False
has_batch_no = False
for row in self.items: for row in self.items:
item = frappe.get_doc("Item", row.item_code) item = frappe.get_cached_value(
if item.has_batch_no: "Item", row.item_code, ["has_serial_no", "has_batch_no"], as_dict=1
has_batch_no = True )
if item.has_serial_no or item.has_batch_no: if item.has_serial_no or item.has_batch_no:
has_serial_no = True self.get_sle_for_serialized_items(row, sl_entries)
self.get_sle_for_serialized_items(row, sl_entries, item)
else: else:
if row.serial_no or row.batch_no: if row.serial_and_batch_bundle:
frappe.throw( frappe.throw(
_( _(
"Row #{0}: Item {1} is not a Serialized/Batched Item. It cannot have a Serial No/Batch No against it." "Row #{0}: Item {1} is not a Serialized/Batched Item. It cannot have a Serial No/Batch No against it."
@ -321,100 +428,34 @@ class StockReconciliation(StockController):
sl_entries.append(self.get_sle_for_items(row)) sl_entries.append(self.get_sle_for_items(row))
if sl_entries: if sl_entries:
if has_serial_no: allow_negative_stock = cint(
sl_entries = self.merge_similar_item_serial_nos(sl_entries) frappe.db.get_single_value("Stock Settings", "allow_negative_stock")
)
allow_negative_stock = False
if has_batch_no:
allow_negative_stock = True
self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock) self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock)
if has_serial_no and sl_entries: def get_sle_for_serialized_items(self, row, sl_entries):
self.update_valuation_rate_for_serial_no() if row.current_serial_and_batch_bundle:
def get_sle_for_serialized_items(self, row, sl_entries, item):
from erpnext.stock.stock_ledger import get_previous_sle
serial_nos = get_serial_nos(row.serial_no)
# To issue existing serial nos
if row.current_qty and (row.current_serial_no or row.batch_no):
args = self.get_sle_for_items(row) args = self.get_sle_for_items(row)
args.update( args.update(
{ {
"actual_qty": -1 * row.current_qty, "actual_qty": -1 * row.current_qty,
"serial_no": row.current_serial_no, "serial_and_batch_bundle": row.current_serial_and_batch_bundle,
"batch_no": row.batch_no,
"valuation_rate": row.current_valuation_rate, "valuation_rate": row.current_valuation_rate,
} }
) )
if row.current_serial_no:
args.update(
{
"qty_after_transaction": 0,
}
)
sl_entries.append(args) sl_entries.append(args)
qty_after_transaction = 0 args = self.get_sle_for_items(row)
for serial_no in serial_nos: args.update(
args = self.get_sle_for_items(row, [serial_no]) {
"actual_qty": row.qty,
"incoming_rate": row.valuation_rate,
"serial_and_batch_bundle": row.serial_and_batch_bundle,
}
)
previous_sle = get_previous_sle( sl_entries.append(args)
{
"item_code": row.item_code,
"posting_date": self.posting_date,
"posting_time": self.posting_time,
"serial_no": serial_no,
}
)
if previous_sle and row.warehouse != previous_sle.get("warehouse"):
# If serial no exists in different warehouse
warehouse = previous_sle.get("warehouse", "") or row.warehouse
if not qty_after_transaction:
qty_after_transaction = get_stock_balance(
row.item_code, warehouse, self.posting_date, self.posting_time
)
qty_after_transaction -= 1
new_args = args.copy()
new_args.update(
{
"actual_qty": -1,
"qty_after_transaction": qty_after_transaction,
"warehouse": warehouse,
"valuation_rate": previous_sle.get("valuation_rate"),
}
)
sl_entries.append(new_args)
if row.qty:
args = self.get_sle_for_items(row)
if item.has_serial_no and item.has_batch_no:
args["qty_after_transaction"] = row.qty
args.update(
{
"actual_qty": row.qty,
"incoming_rate": row.valuation_rate,
"valuation_rate": row.valuation_rate,
}
)
sl_entries.append(args)
if serial_nos == get_serial_nos(row.current_serial_no):
# update valuation rate
self.update_valuation_rate_for_serial_nos(row, serial_nos)
def update_valuation_rate_for_serial_no(self): def update_valuation_rate_for_serial_no(self):
for d in self.items: for d in self.items:
@ -452,8 +493,6 @@ class StockReconciliation(StockController):
"company": self.company, "company": self.company,
"stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"), "stock_uom": frappe.db.get_value("Item", row.item_code, "stock_uom"),
"is_cancelled": 1 if self.docstatus == 2 else 0, "is_cancelled": 1 if self.docstatus == 2 else 0,
"serial_no": "\n".join(serial_nos) if serial_nos else "",
"batch_no": row.batch_no,
"valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")), "valuation_rate": flt(row.valuation_rate, row.precision("valuation_rate")),
} }
) )
@ -461,17 +500,19 @@ class StockReconciliation(StockController):
if not row.batch_no: if not row.batch_no:
data.qty_after_transaction = flt(row.qty, row.precision("qty")) data.qty_after_transaction = flt(row.qty, row.precision("qty"))
if self.docstatus == 2 and not row.batch_no: if self.docstatus == 2:
if row.current_qty: if row.current_qty:
data.actual_qty = -1 * row.current_qty data.actual_qty = -1 * row.current_qty
data.qty_after_transaction = flt(row.current_qty) data.qty_after_transaction = flt(row.current_qty)
data.previous_qty_after_transaction = flt(row.qty) data.previous_qty_after_transaction = flt(row.qty)
data.valuation_rate = flt(row.current_valuation_rate) data.valuation_rate = flt(row.current_valuation_rate)
data.serial_and_batch_bundle = row.current_serial_and_batch_bundle
data.stock_value = data.qty_after_transaction * data.valuation_rate data.stock_value = data.qty_after_transaction * data.valuation_rate
data.stock_value_difference = -1 * flt(row.amount_difference) data.stock_value_difference = -1 * flt(row.amount_difference)
else: else:
data.actual_qty = row.qty data.actual_qty = row.qty
data.qty_after_transaction = 0.0 data.qty_after_transaction = 0.0
data.serial_and_batch_bundle = row.serial_and_batch_bundle
data.valuation_rate = flt(row.valuation_rate) data.valuation_rate = flt(row.valuation_rate)
data.stock_value_difference = -1 * flt(row.amount_difference) data.stock_value_difference = -1 * flt(row.amount_difference)
@ -484,15 +525,7 @@ class StockReconciliation(StockController):
has_serial_no = False has_serial_no = False
for row in self.items: for row in self.items:
if row.serial_no or row.batch_no or row.current_serial_no: sl_entries.append(self.get_sle_for_items(row))
has_serial_no = True
serial_nos = ""
if row.current_serial_no:
serial_nos = get_serial_nos(row.current_serial_no)
sl_entries.append(self.get_sle_for_items(row, serial_nos))
else:
sl_entries.append(self.get_sle_for_items(row))
if sl_entries: if sl_entries:
if has_serial_no: if has_serial_no:
@ -617,7 +650,14 @@ class StockReconciliation(StockController):
sl_entries = [] sl_entries = []
for row in self.items: for row in self.items:
if not (row.item_code == item_code and row.batch_no == batch_no): if (
not (row.item_code == item_code and row.batch_no == batch_no)
and not row.serial_and_batch_bundle
):
continue
if row.current_serial_and_batch_bundle:
self.recalculate_qty_for_serial_and_batch_bundle(row)
continue continue
current_qty = get_batch_qty_for_stock_reco( current_qty = get_batch_qty_for_stock_reco(
@ -651,6 +691,27 @@ class StockReconciliation(StockController):
if sl_entries: if sl_entries:
self.make_sl_entries(sl_entries) self.make_sl_entries(sl_entries)
def recalculate_qty_for_serial_and_batch_bundle(self, row):
doc = frappe.get_doc("Serial and Batch Bundle", row.current_serial_and_batch_bundle)
precision = doc.entries[0].precision("qty")
for d in doc.entries:
qty = (
get_batch_qty(
d.batch_no,
doc.warehouse,
posting_date=doc.posting_date,
posting_time=doc.posting_time,
ignore_voucher_nos=[doc.voucher_no],
)
or 0
) * -1
if flt(d.qty, precision) == flt(qty, precision):
continue
d.db_set("qty", qty)
def get_batch_qty_for_stock_reco( def get_batch_qty_for_stock_reco(
item_code, warehouse, batch_no, posting_date, posting_time, voucher_no item_code, warehouse, batch_no, posting_date, posting_time, voucher_no

View File

@ -12,6 +12,11 @@ from frappe.utils import add_days, cstr, flt, nowdate, nowtime, random_string
from erpnext.accounts.utils import get_stock_and_account_balance from erpnext.accounts.utils import get_stock_and_account_balance
from erpnext.stock.doctype.item.test_item import create_item from erpnext.stock.doctype.item.test_item import create_item
from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt
from erpnext.stock.doctype.serial_and_batch_bundle.test_serial_and_batch_bundle import (
get_batch_from_bundle,
get_serial_nos_from_bundle,
make_serial_batch_bundle,
)
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import ( from erpnext.stock.doctype.stock_reconciliation.stock_reconciliation import (
EmptyStockReconciliationItemsError, EmptyStockReconciliationItemsError,
@ -157,15 +162,18 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=serial_item_code, warehouse=serial_warehouse, qty=5, rate=200 item_code=serial_item_code, warehouse=serial_warehouse, qty=5, rate=200
) )
serial_nos = get_serial_nos(sr.items[0].serial_no) serial_nos = frappe.get_doc(
"Serial and Batch Bundle", sr.items[0].serial_and_batch_bundle
).get_serial_nos()
self.assertEqual(len(serial_nos), 5) self.assertEqual(len(serial_nos), 5)
args = { args = {
"item_code": serial_item_code, "item_code": serial_item_code,
"warehouse": serial_warehouse, "warehouse": serial_warehouse,
"posting_date": nowdate(), "qty": -5,
"posting_date": add_days(sr.posting_date, 1),
"posting_time": nowtime(), "posting_time": nowtime(),
"serial_no": sr.items[0].serial_no, "serial_and_batch_bundle": sr.items[0].serial_and_batch_bundle,
} }
valuation_rate = get_incoming_rate(args) valuation_rate = get_incoming_rate(args)
@ -174,18 +182,20 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
to_delete_records.append(sr.name) to_delete_records.append(sr.name)
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code=serial_item_code, warehouse=serial_warehouse, qty=5, rate=300 item_code=serial_item_code, warehouse=serial_warehouse, qty=5, rate=300, serial_no=serial_nos
) )
serial_nos1 = get_serial_nos(sr.items[0].serial_no) sn_doc = frappe.get_doc("Serial and Batch Bundle", sr.items[0].serial_and_batch_bundle)
self.assertEqual(len(serial_nos1), 5)
self.assertEqual(len(sn_doc.get_serial_nos()), 5)
args = { args = {
"item_code": serial_item_code, "item_code": serial_item_code,
"warehouse": serial_warehouse, "warehouse": serial_warehouse,
"posting_date": nowdate(), "qty": -5,
"posting_date": add_days(sr.posting_date, 1),
"posting_time": nowtime(), "posting_time": nowtime(),
"serial_no": sr.items[0].serial_no, "serial_and_batch_bundle": sr.items[0].serial_and_batch_bundle,
} }
valuation_rate = get_incoming_rate(args) valuation_rate = get_incoming_rate(args)
@ -198,66 +208,32 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
stock_doc = frappe.get_doc("Stock Reconciliation", d) stock_doc = frappe.get_doc("Stock Reconciliation", d)
stock_doc.cancel() stock_doc.cancel()
def test_stock_reco_for_merge_serialized_item(self):
to_delete_records = []
# Add new serial nos
serial_item_code = "Stock-Reco-Serial-Item-2"
serial_warehouse = "_Test Warehouse for Stock Reco1 - _TC"
sr = create_stock_reconciliation(
item_code=serial_item_code,
serial_no=random_string(6),
warehouse=serial_warehouse,
qty=1,
rate=100,
do_not_submit=True,
purpose="Opening Stock",
)
for i in range(3):
sr.append(
"items",
{
"item_code": serial_item_code,
"warehouse": serial_warehouse,
"qty": 1,
"valuation_rate": 100,
"serial_no": random_string(6),
},
)
sr.save()
sr.submit()
sle_entries = frappe.get_all(
"Stock Ledger Entry", filters={"voucher_no": sr.name}, fields=["name", "incoming_rate"]
)
self.assertEqual(len(sle_entries), 1)
self.assertEqual(sle_entries[0].incoming_rate, 100)
to_delete_records.append(sr.name)
to_delete_records.reverse()
for d in to_delete_records:
stock_doc = frappe.get_doc("Stock Reconciliation", d)
stock_doc.cancel()
def test_stock_reco_for_batch_item(self): def test_stock_reco_for_batch_item(self):
to_delete_records = [] to_delete_records = []
# Add new serial nos # Add new serial nos
item_code = "Stock-Reco-batch-Item-1" item_code = "Stock-Reco-batch-Item-123"
warehouse = "_Test Warehouse for Stock Reco2 - _TC" warehouse = "_Test Warehouse for Stock Reco2 - _TC"
self.make_item(
item_code,
frappe._dict(
{
"is_stock_item": 1,
"has_batch_no": 1,
"create_new_batch": 1,
"batch_number_series": "SRBI123-.#####",
}
),
)
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code=item_code, warehouse=warehouse, qty=5, rate=200, do_not_save=1 item_code=item_code, warehouse=warehouse, qty=5, rate=200, do_not_save=1
) )
sr.save() sr.save()
sr.submit() sr.submit()
sr.load_from_db()
batch_no = sr.items[0].batch_no batch_no = get_batch_from_bundle(sr.items[0].serial_and_batch_bundle)
self.assertTrue(batch_no) self.assertTrue(batch_no)
to_delete_records.append(sr.name) to_delete_records.append(sr.name)
@ -270,7 +246,7 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
"warehouse": warehouse, "warehouse": warehouse,
"posting_date": nowdate(), "posting_date": nowdate(),
"posting_time": nowtime(), "posting_time": nowtime(),
"batch_no": batch_no, "serial_and_batch_bundle": sr1.items[0].serial_and_batch_bundle,
} }
valuation_rate = get_incoming_rate(args) valuation_rate = get_incoming_rate(args)
@ -303,16 +279,15 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sr = create_stock_reconciliation(item_code=item.item_code, warehouse=warehouse, qty=1, rate=100) sr = create_stock_reconciliation(item_code=item.item_code, warehouse=warehouse, qty=1, rate=100)
batch_no = sr.items[0].batch_no batch_no = get_batch_from_bundle(sr.items[0].serial_and_batch_bundle)
serial_nos = get_serial_nos(sr.items[0].serial_no) serial_nos = get_serial_nos_from_bundle(sr.items[0].serial_and_batch_bundle)
self.assertEqual(len(serial_nos), 1) self.assertEqual(len(serial_nos), 1)
self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "batch_no"), batch_no) self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "batch_no"), batch_no)
sr.cancel() sr.cancel()
self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "status"), "Inactive") self.assertEqual(frappe.db.get_value("Serial No", serial_nos[0], "warehouse"), None)
self.assertEqual(frappe.db.exists("Batch", batch_no), None)
def test_stock_reco_for_serial_and_batch_item_with_future_dependent_entry(self): def test_stock_reco_for_serial_and_batch_item_with_future_dependent_entry(self):
""" """
@ -339,13 +314,13 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
stock_reco = create_stock_reconciliation( stock_reco = create_stock_reconciliation(
item_code=item.item_code, warehouse=warehouse, qty=1, rate=100 item_code=item.item_code, warehouse=warehouse, qty=1, rate=100
) )
batch_no = stock_reco.items[0].batch_no batch_no = get_batch_from_bundle(stock_reco.items[0].serial_and_batch_bundle)
reco_serial_no = get_serial_nos(stock_reco.items[0].serial_no)[0] reco_serial_no = get_serial_nos_from_bundle(stock_reco.items[0].serial_and_batch_bundle)[0]
stock_entry = make_stock_entry( stock_entry = make_stock_entry(
item_code=item.item_code, target=warehouse, qty=1, basic_rate=100, batch_no=batch_no item_code=item.item_code, target=warehouse, qty=1, basic_rate=100, batch_no=batch_no
) )
serial_no_2 = get_serial_nos(stock_entry.items[0].serial_no)[0] serial_no_2 = get_serial_nos_from_bundle(stock_entry.items[0].serial_and_batch_bundle)[0]
# Check Batch qty after 2 transactions # Check Batch qty after 2 transactions
batch_qty = get_batch_qty(batch_no, warehouse, item.item_code) batch_qty = get_batch_qty(batch_no, warehouse, item.item_code)
@ -360,11 +335,10 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
# Check if Serial No from Stock Reconcilation is intact # Check if Serial No from Stock Reconcilation is intact
self.assertEqual(frappe.db.get_value("Serial No", reco_serial_no, "batch_no"), batch_no) self.assertEqual(frappe.db.get_value("Serial No", reco_serial_no, "batch_no"), batch_no)
self.assertEqual(frappe.db.get_value("Serial No", reco_serial_no, "status"), "Active") self.assertTrue(frappe.db.get_value("Serial No", reco_serial_no, "warehouse"))
# Check if Serial No from Stock Entry is Unlinked and Inactive # Check if Serial No from Stock Entry is Unlinked and Inactive
self.assertEqual(frappe.db.get_value("Serial No", serial_no_2, "batch_no"), None) self.assertFalse(frappe.db.get_value("Serial No", serial_no_2, "warehouse"))
self.assertEqual(frappe.db.get_value("Serial No", serial_no_2, "status"), "Inactive")
stock_reco.cancel() stock_reco.cancel()
@ -579,10 +553,24 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
def test_valid_batch(self): def test_valid_batch(self):
create_batch_item_with_batch("Testing Batch Item 1", "001") create_batch_item_with_batch("Testing Batch Item 1", "001")
create_batch_item_with_batch("Testing Batch Item 2", "002") create_batch_item_with_batch("Testing Batch Item 2", "002")
sr = create_stock_reconciliation(
item_code="Testing Batch Item 1", qty=1, rate=100, batch_no="002", do_not_submit=True doc = frappe.get_doc(
{
"doctype": "Serial and Batch Bundle",
"item_code": "Testing Batch Item 1",
"warehouse": "_Test Warehouse - _TC",
"voucher_type": "Stock Reconciliation",
"entries": [
{
"batch_no": "002",
"qty": 1,
"incoming_rate": 100,
}
],
}
) )
self.assertRaises(frappe.ValidationError, sr.submit)
self.assertRaises(frappe.ValidationError, doc.save)
def test_serial_no_cancellation(self): def test_serial_no_cancellation(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@ -590,18 +578,17 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1) item = create_item("Stock-Reco-Serial-Item-9", is_stock_item=1)
if not item.has_serial_no: if not item.has_serial_no:
item.has_serial_no = 1 item.has_serial_no = 1
item.serial_no_series = "SRS9.####" item.serial_no_series = "PSRS9.####"
item.save() item.save()
item_code = item.name item_code = item.name
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
se1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=700) se1 = make_stock_entry(item_code=item_code, target=warehouse, qty=10, basic_rate=700)
serial_nos = get_serial_nos_from_bundle(se1.items[0].serial_and_batch_bundle)
serial_nos = get_serial_nos(se1.items[0].serial_no)
# reduce 1 item # reduce 1 item
serial_nos.pop() serial_nos.pop()
new_serial_nos = "\n".join(serial_nos) new_serial_nos = serial_nos
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code=item.name, warehouse=warehouse, serial_no=new_serial_nos, qty=9 item_code=item.name, warehouse=warehouse, serial_no=new_serial_nos, qty=9
@ -623,10 +610,19 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code = item.name item_code = item.name
warehouse = "_Test Warehouse - _TC" warehouse = "_Test Warehouse - _TC"
if not frappe.db.exists("Serial No", "SR-CREATED-SR-NO"):
frappe.get_doc(
{
"doctype": "Serial No",
"item_code": item_code,
"serial_no": "SR-CREATED-SR-NO",
}
).insert()
sr = create_stock_reconciliation( sr = create_stock_reconciliation(
item_code=item.name, item_code=item.name,
warehouse=warehouse, warehouse=warehouse,
serial_no="SR-CREATED-SR-NO", serial_no=["SR-CREATED-SR-NO"],
qty=1, qty=1,
do_not_submit=True, do_not_submit=True,
rate=100, rate=100,
@ -698,10 +694,12 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code, posting_time="09:00:00", target=warehouse, qty=100, basic_rate=700 item_code=item_code, posting_time="09:00:00", target=warehouse, qty=100, basic_rate=700
) )
batch_no = get_batch_from_bundle(se1.items[0].serial_and_batch_bundle)
# Removed 50 Qty, Balace Qty 50 # Removed 50 Qty, Balace Qty 50
se2 = make_stock_entry( se2 = make_stock_entry(
item_code=item_code, item_code=item_code,
batch_no=se1.items[0].batch_no, batch_no=batch_no,
posting_time="10:00:00", posting_time="10:00:00",
source=warehouse, source=warehouse,
qty=50, qty=50,
@ -713,15 +711,23 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
item_code=item_code, item_code=item_code,
posting_time="11:00:00", posting_time="11:00:00",
warehouse=warehouse, warehouse=warehouse,
batch_no=se1.items[0].batch_no, batch_no=batch_no,
qty=100, qty=100,
rate=100, rate=100,
) )
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)},
fields=["actual_qty"],
)
self.assertEqual(flt(sle[0].actual_qty), flt(-50.0))
# Removed 50 Qty, Balace Qty 50 # Removed 50 Qty, Balace Qty 50
make_stock_entry( make_stock_entry(
item_code=item_code, item_code=item_code,
batch_no=se1.items[0].batch_no, batch_no=batch_no,
posting_time="12:00:00", posting_time="12:00:00",
source=warehouse, source=warehouse,
qty=50, qty=50,
@ -745,12 +751,20 @@ class TestStockReconciliation(FrappeTestCase, StockTestMixin):
sle = frappe.get_all( sle = frappe.get_all(
"Stock Ledger Entry", "Stock Ledger Entry",
filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0}, filters={"item_code": item_code, "warehouse": warehouse, "is_cancelled": 0},
fields=["qty_after_transaction"], fields=["qty_after_transaction", "actual_qty", "voucher_type", "voucher_no"],
order_by="posting_time desc, creation desc", order_by="posting_time desc, creation desc",
) )
self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0)) self.assertEqual(flt(sle[0].qty_after_transaction), flt(50.0))
sle = frappe.get_all(
"Stock Ledger Entry",
filters={"is_cancelled": 0, "voucher_no": stock_reco.name, "actual_qty": ("<", 0)},
fields=["actual_qty"],
)
self.assertEqual(flt(sle[0].actual_qty), flt(-100.0))
def test_update_stock_reconciliation_while_reposting(self): def test_update_stock_reconciliation_while_reposting(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry from erpnext.stock.doctype.stock_entry.test_stock_entry import make_stock_entry
@ -895,6 +909,31 @@ def create_stock_reconciliation(**args):
or frappe.get_cached_value("Cost Center", filters={"is_group": 0, "company": sr.company}) or frappe.get_cached_value("Cost Center", filters={"is_group": 0, "company": sr.company})
) )
bundle_id = None
if args.batch_no or args.serial_no:
batches = frappe._dict({})
if args.batch_no:
batches[args.batch_no] = args.qty
bundle_id = make_serial_batch_bundle(
frappe._dict(
{
"item_code": args.item_code or "_Test Item",
"warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": args.qty,
"voucher_type": "Stock Reconciliation",
"batches": batches,
"rate": args.rate,
"serial_nos": args.serial_no,
"posting_date": sr.posting_date,
"posting_time": sr.posting_time,
"type_of_transaction": "Inward" if args.qty > 0 else "Outward",
"company": args.company or "_Test Company",
"do_not_submit": True,
}
)
).name
sr.append( sr.append(
"items", "items",
{ {
@ -902,8 +941,7 @@ def create_stock_reconciliation(**args):
"warehouse": args.warehouse or "_Test Warehouse - _TC", "warehouse": args.warehouse or "_Test Warehouse - _TC",
"qty": args.qty, "qty": args.qty,
"valuation_rate": args.rate, "valuation_rate": args.rate,
"serial_no": args.serial_no, "serial_and_batch_bundle": bundle_id,
"batch_no": args.batch_no,
}, },
) )
@ -914,6 +952,9 @@ def create_stock_reconciliation(**args):
sr.submit() sr.submit()
except EmptyStockReconciliationItemsError: except EmptyStockReconciliationItemsError:
pass pass
sr.load_from_db()
return sr return sr

View File

@ -17,8 +17,11 @@
"amount", "amount",
"allow_zero_valuation_rate", "allow_zero_valuation_rate",
"serial_no_and_batch_section", "serial_no_and_batch_section",
"add_serial_batch_bundle",
"serial_and_batch_bundle",
"batch_no", "batch_no",
"column_break_11", "column_break_11",
"current_serial_and_batch_bundle",
"serial_no", "serial_no",
"section_break_3", "section_break_3",
"current_qty", "current_qty",
@ -168,7 +171,8 @@
"fieldname": "batch_no", "fieldname": "batch_no",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Batch No", "label": "Batch No",
"options": "Batch" "options": "Batch",
"read_only": 1
}, },
{ {
"default": "0", "default": "0",
@ -185,11 +189,31 @@
"fieldtype": "Data", "fieldtype": "Data",
"label": "Has Item Scanned", "label": "Has Item Scanned",
"read_only": 1 "read_only": 1
},
{
"fieldname": "serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Serial / Batch Bundle",
"no_copy": 1,
"options": "Serial and Batch Bundle",
"print_hide": 1
},
{
"fieldname": "current_serial_and_batch_bundle",
"fieldtype": "Link",
"label": "Current Serial / Batch Bundle",
"options": "Serial and Batch Bundle",
"read_only": 1
},
{
"fieldname": "add_serial_batch_bundle",
"fieldtype": "Button",
"label": "Add Serial / Batch No"
} }
], ],
"istable": 1, "istable": 1,
"links": [], "links": [],
"modified": "2023-05-09 18:42:19.224916", "modified": "2023-05-27 17:35:31.026852",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Reconciliation Item", "name": "Stock Reconciliation Item",

View File

@ -297,6 +297,7 @@ def create_material_receipt(
se.set_stock_entry_type() se.set_stock_entry_type()
se.insert() se.insert()
se.submit() se.submit()
se.reload()
return se return se

View File

@ -38,9 +38,9 @@
"allow_partial_reservation", "allow_partial_reservation",
"serial_and_batch_item_settings_tab", "serial_and_batch_item_settings_tab",
"section_break_7", "section_break_7",
"automatically_set_serial_nos_based_on_fifo", "auto_create_serial_and_batch_bundle_for_outward",
"set_qty_in_transactions_based_on_serial_no_input", "pick_serial_and_batch_based_on",
"column_break_10", "column_break_mhzc",
"disable_serial_no_and_batch_selector", "disable_serial_no_and_batch_selector",
"use_naming_series", "use_naming_series",
"naming_series_prefix", "naming_series_prefix",
@ -149,22 +149,6 @@
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Negative Stock" "label": "Allow Negative Stock"
}, },
{
"fieldname": "column_break_10",
"fieldtype": "Column Break"
},
{
"default": "1",
"fieldname": "automatically_set_serial_nos_based_on_fifo",
"fieldtype": "Check",
"label": "Automatically Set Serial Nos Based on FIFO"
},
{
"default": "1",
"fieldname": "set_qty_in_transactions_based_on_serial_no_input",
"fieldtype": "Check",
"label": "Set Qty in Transactions Based on Serial No Input"
},
{ {
"fieldname": "auto_material_request", "fieldname": "auto_material_request",
"fieldtype": "Section Break", "fieldtype": "Section Break",
@ -376,6 +360,29 @@
"fieldname": "allow_partial_reservation", "fieldname": "allow_partial_reservation",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Allow Partial Reservation" "label": "Allow Partial Reservation"
},
{
"fieldname": "section_break_plhx",
"fieldtype": "Section Break"
},
{
"fieldname": "column_break_mhzc",
"fieldtype": "Column Break"
},
{
"default": "FIFO",
"depends_on": "auto_create_serial_and_batch_bundle_for_outward",
"fieldname": "pick_serial_and_batch_based_on",
"fieldtype": "Select",
"label": "Pick Serial / Batch Based On",
"mandatory_depends_on": "auto_create_serial_and_batch_bundle_for_outward",
"options": "FIFO\nLIFO\nExpiry"
},
{
"default": "1",
"fieldname": "auto_create_serial_and_batch_bundle_for_outward",
"fieldtype": "Check",
"label": "Auto Create Serial and Batch Bundle For Outward"
} }
], ],
"icon": "icon-cog", "icon": "icon-cog",
@ -383,7 +390,7 @@
"index_web_pages_for_search": 1, "index_web_pages_for_search": 1,
"issingle": 1, "issingle": 1,
"links": [], "links": [],
"modified": "2023-05-29 15:09:54.959411", "modified": "2023-05-29 15:10:54.959411",
"modified_by": "Administrator", "modified_by": "Administrator",
"module": "Stock", "module": "Stock",
"name": "Stock Settings", "name": "Stock Settings",

View File

@ -8,7 +8,7 @@ import frappe
from frappe import _, throw from frappe import _, throw
from frappe.model import child_table_fields, default_fields from frappe.model import child_table_fields, default_fields
from frappe.model.meta import get_field_precision from frappe.model.meta import get_field_precision
from frappe.query_builder.functions import CombineDatetime, IfNull, Sum from frappe.query_builder.functions import IfNull, Sum
from frappe.utils import add_days, add_months, cint, cstr, flt, getdate from frappe.utils import add_days, add_months, cint, cstr, flt, getdate
from erpnext import get_company_currency from erpnext import get_company_currency
@ -19,7 +19,6 @@ from erpnext.accounts.doctype.pricing_rule.pricing_rule import (
from erpnext.setup.doctype.brand.brand import get_brand_defaults from erpnext.setup.doctype.brand.brand import get_brand_defaults
from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults from erpnext.setup.doctype.item_group.item_group import get_item_group_defaults
from erpnext.setup.utils import get_exchange_rate from erpnext.setup.utils import get_exchange_rate
from erpnext.stock.doctype.batch.batch import get_batch_no
from erpnext.stock.doctype.item.item import get_item_defaults, get_uom_conv_factor from erpnext.stock.doctype.item.item import get_item_defaults, get_uom_conv_factor
from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_manufacturer_part_no from erpnext.stock.doctype.item_manufacturer.item_manufacturer import get_item_manufacturer_part_no
from erpnext.stock.doctype.price_list.price_list import get_price_list_details from erpnext.stock.doctype.price_list.price_list import get_price_list_details
@ -128,8 +127,6 @@ def get_item_details(args, doc=None, for_validate=False, overwrite_warehouse=Tru
out.update(data) out.update(data)
update_stock(args, out)
if args.transaction_date and item.lead_time_days: if args.transaction_date and item.lead_time_days:
out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days) out.schedule_date = out.lead_time_date = add_days(args.transaction_date, item.lead_time_days)
@ -151,35 +148,6 @@ def remove_standard_fields(details):
return details return details
def update_stock(args, out):
if (
(
args.get("doctype") == "Delivery Note"
or (args.get("doctype") == "Sales Invoice" and args.get("update_stock"))
)
and out.warehouse
and out.stock_qty > 0
):
if out.has_batch_no and not args.get("batch_no"):
out.batch_no = get_batch_no(out.item_code, out.warehouse, out.qty)
actual_batch_qty = get_batch_qty(out.batch_no, out.warehouse, out.item_code)
if actual_batch_qty:
out.update(actual_batch_qty)
if out.has_serial_no and args.get("batch_no"):
reserved_so = get_so_reservation_for_item(args)
out.batch_no = args.get("batch_no")
out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so)
elif out.has_serial_no:
reserved_so = get_so_reservation_for_item(args)
out.serial_no = get_serial_no(out, args.serial_no, sales_order=reserved_so)
if not out.serial_no:
out.pop("serial_no", None)
def set_valuation_rate(out, args): def set_valuation_rate(out, args):
if frappe.db.exists("Product Bundle", args.item_code, cache=True): if frappe.db.exists("Product Bundle", args.item_code, cache=True):
valuation_rate = 0.0 valuation_rate = 0.0
@ -1121,28 +1089,6 @@ def get_pos_profile(company, pos_profile=None, user=None):
return pos_profile and pos_profile[0] or None return pos_profile and pos_profile[0] or None
def get_serial_nos_by_fifo(args, sales_order=None):
if frappe.db.get_single_value("Stock Settings", "automatically_set_serial_nos_based_on_fifo"):
sn = frappe.qb.DocType("Serial No")
query = (
frappe.qb.from_(sn)
.select(sn.name)
.where((sn.item_code == args.item_code) & (sn.warehouse == args.warehouse))
.orderby(CombineDatetime(sn.purchase_date, sn.purchase_time))
.limit(abs(cint(args.stock_qty)))
)
if sales_order:
query = query.where(sn.sales_order == sales_order)
if args.batch_no:
query = query.where(sn.batch_no == args.batch_no)
serial_nos = query.run(as_list=True)
serial_nos = [s[0] for s in serial_nos]
return "\n".join(serial_nos)
@frappe.whitelist() @frappe.whitelist()
def get_conversion_factor(item_code, uom): def get_conversion_factor(item_code, uom):
variant_of = frappe.db.get_value("Item", item_code, "variant_of", cache=True) variant_of = frappe.db.get_value("Item", item_code, "variant_of", cache=True)
@ -1208,51 +1154,6 @@ def get_company_total_stock(item_code, company):
).run()[0][0] ).run()[0][0]
@frappe.whitelist()
def get_serial_no_details(item_code, warehouse, stock_qty, serial_no):
args = frappe._dict(
{"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty, "serial_no": serial_no}
)
serial_no = get_serial_no(args)
return {"serial_no": serial_no}
@frappe.whitelist()
def get_bin_details_and_serial_nos(
item_code, warehouse, has_batch_no=None, stock_qty=None, serial_no=None
):
bin_details_and_serial_nos = {}
bin_details_and_serial_nos.update(get_bin_details(item_code, warehouse))
if flt(stock_qty) > 0:
if has_batch_no:
args = frappe._dict({"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty})
serial_no = get_serial_no(args)
bin_details_and_serial_nos.update({"serial_no": serial_no})
return bin_details_and_serial_nos
bin_details_and_serial_nos.update(
get_serial_no_details(item_code, warehouse, stock_qty, serial_no)
)
return bin_details_and_serial_nos
@frappe.whitelist()
def get_batch_qty_and_serial_no(batch_no, stock_qty, warehouse, item_code, has_serial_no):
batch_qty_and_serial_no = {}
batch_qty_and_serial_no.update(get_batch_qty(batch_no, warehouse, item_code))
if (flt(batch_qty_and_serial_no.get("actual_batch_qty")) >= flt(stock_qty)) and has_serial_no:
args = frappe._dict(
{"item_code": item_code, "warehouse": warehouse, "stock_qty": stock_qty, "batch_no": batch_no}
)
serial_no = get_serial_no(args)
batch_qty_and_serial_no.update({"serial_no": serial_no})
return batch_qty_and_serial_no
@frappe.whitelist() @frappe.whitelist()
def get_batch_qty(batch_no, warehouse, item_code): def get_batch_qty(batch_no, warehouse, item_code):
from erpnext.stock.doctype.batch import batch from erpnext.stock.doctype.batch import batch
@ -1427,32 +1328,8 @@ def get_gross_profit(out):
@frappe.whitelist() @frappe.whitelist()
def get_serial_no(args, serial_nos=None, sales_order=None): def get_serial_no(args, serial_nos=None, sales_order=None):
serial_no = None serial_nos = serial_nos or []
if isinstance(args, str): return serial_nos
args = json.loads(args)
args = frappe._dict(args)
if args.get("doctype") == "Sales Invoice" and not args.get("update_stock"):
return ""
if args.get("warehouse") and args.get("stock_qty") and args.get("item_code"):
has_serial_no = frappe.get_value("Item", {"item_code": args.item_code}, "has_serial_no")
if args.get("batch_no") and has_serial_no == 1:
return get_serial_nos_by_fifo(args, sales_order)
elif has_serial_no == 1:
args = json.dumps(
{
"item_code": args.get("item_code"),
"warehouse": args.get("warehouse"),
"stock_qty": args.get("stock_qty"),
}
)
args = process_args(args)
serial_no = get_serial_nos_by_fifo(args, sales_order)
if not serial_no and serial_nos:
# For POS
serial_no = serial_nos
return serial_no
def update_party_blanket_order(args, out): def update_party_blanket_order(args, out):
@ -1498,41 +1375,3 @@ def get_blanket_order_details(args):
blanket_order_details = blanket_order_details[0] if blanket_order_details else "" blanket_order_details = blanket_order_details[0] if blanket_order_details else ""
return blanket_order_details return blanket_order_details
def get_so_reservation_for_item(args):
reserved_so = None
if args.get("against_sales_order"):
if get_reserved_qty_for_so(args.get("against_sales_order"), args.get("item_code")):
reserved_so = args.get("against_sales_order")
elif args.get("against_sales_invoice"):
sales_order = frappe.db.get_all(
"Sales Invoice Item",
filters={
"parent": args.get("against_sales_invoice"),
"item_code": args.get("item_code"),
"docstatus": 1,
},
fields="sales_order",
)
if sales_order and sales_order[0]:
if get_reserved_qty_for_so(sales_order[0].sales_order, args.get("item_code")):
reserved_so = sales_order[0]
elif args.get("sales_order"):
if get_reserved_qty_for_so(args.get("sales_order"), args.get("item_code")):
reserved_so = args.get("sales_order")
return reserved_so
def get_reserved_qty_for_so(sales_order, item_code):
reserved_qty = frappe.db.get_value(
"Sales Order Item",
filters={
"parent": sales_order,
"item_code": item_code,
"ensure_delivery_based_on_produced_serial_no": 1,
},
fieldname="sum(qty)",
)
return reserved_qty or 0

View File

@ -5,6 +5,7 @@
import frappe import frappe
from frappe import _ from frappe import _
from frappe.utils import cint, flt, getdate from frappe.utils import cint, flt, getdate
from frappe.utils.deprecations import deprecated
from pypika import functions as fn from pypika import functions as fn
from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter from erpnext.stock.doctype.warehouse.warehouse import apply_warehouse_filter
@ -67,8 +68,15 @@ def get_columns(filters):
return columns return columns
# get all details
def get_stock_ledger_entries(filters): def get_stock_ledger_entries(filters):
entries = get_stock_ledger_entries_for_batch_no(filters)
entries += get_stock_ledger_entries_for_batch_bundle(filters)
return entries
@deprecated
def get_stock_ledger_entries_for_batch_no(filters):
if not filters.get("from_date"): if not filters.get("from_date"):
frappe.throw(_("'From Date' is required")) frappe.throw(_("'From Date' is required"))
if not filters.get("to_date"): if not filters.get("to_date"):
@ -99,7 +107,43 @@ def get_stock_ledger_entries(filters):
if filters.get(field): if filters.get(field):
query = query.where(sle[field] == filters.get(field)) query = query.where(sle[field] == filters.get(field))
return query.run(as_dict=True) return query.run(as_dict=True) or []
def get_stock_ledger_entries_for_batch_bundle(filters):
sle = frappe.qb.DocType("Stock Ledger Entry")
batch_package = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(sle)
.inner_join(batch_package)
.on(batch_package.parent == sle.serial_and_batch_bundle)
.select(
sle.item_code,
sle.warehouse,
batch_package.batch_no,
sle.posting_date,
fn.Sum(batch_package.qty).as_("actual_qty"),
)
.where(
(sle.docstatus < 2)
& (sle.is_cancelled == 0)
& (sle.has_batch_no == 1)
& (sle.posting_date <= filters["to_date"])
)
.groupby(batch_package.batch_no, batch_package.warehouse)
.orderby(sle.item_code, sle.warehouse)
)
query = apply_warehouse_filter(query, sle, filters)
for field in ["item_code", "batch_no", "company"]:
if filters.get(field):
if field == "batch_no":
query = query.where(batch_package[field] == filters.get(field))
else:
query = query.where(sle[field] == filters.get(field))
return query.run(as_dict=True) or []
def get_item_warehouse_batch_map(filters, float_precision): def get_item_warehouse_batch_map(filters, float_precision):

View File

@ -18,13 +18,6 @@ frappe.query_reports["Serial No Ledger"] = {
} }
} }
}, },
{
'label': __('Serial No'),
'fieldtype': 'Link',
'fieldname': 'serial_no',
'options': 'Serial No',
'reqd': 1
},
{ {
'label': __('Warehouse'), 'label': __('Warehouse'),
'fieldtype': 'Link', 'fieldtype': 'Link',
@ -42,11 +35,36 @@ frappe.query_reports["Serial No Ledger"] = {
} }
} }
}, },
{
'label': __('Serial No'),
'fieldtype': 'Link',
'fieldname': 'serial_no',
'options': 'Serial No',
get_query: function() {
let item_code = frappe.query_report.get_filter_value('item_code');
let warehouse = frappe.query_report.get_filter_value('warehouse');
let query_filters = {'item_code': item_code};
if (warehouse) {
query_filters['warehouse'] = warehouse;
}
return {
filters: query_filters
}
}
},
{ {
'label': __('As On Date'), 'label': __('As On Date'),
'fieldtype': 'Date', 'fieldtype': 'Date',
'fieldname': 'posting_date', 'fieldname': 'posting_date',
'default': frappe.datetime.get_today() 'default': frappe.datetime.get_today()
}, },
{
'label': __('Posting Time'),
'fieldtype': 'Time',
'fieldname': 'posting_time',
'default': frappe.datetime.get_time()
},
] ]
}; };

View File

@ -1,7 +1,7 @@
# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors
# For license information, please see license.txt # For license information, please see license.txt
import frappe
from frappe import _ from frappe import _
from erpnext.stock.stock_ledger import get_stock_ledger_entries from erpnext.stock.stock_ledger import get_stock_ledger_entries
@ -22,28 +22,41 @@ def get_columns(filters):
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "voucher_type", "fieldname": "voucher_type",
"options": "DocType", "options": "DocType",
"width": 220, "width": 160,
}, },
{ {
"label": _("Voucher No"), "label": _("Voucher No"),
"fieldtype": "Dynamic Link", "fieldtype": "Dynamic Link",
"fieldname": "voucher_no", "fieldname": "voucher_no",
"options": "voucher_type", "options": "voucher_type",
"width": 220, "width": 180,
}, },
{ {
"label": _("Company"), "label": _("Company"),
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "company", "fieldname": "company",
"options": "Company", "options": "Company",
"width": 220, "width": 150,
}, },
{ {
"label": _("Warehouse"), "label": _("Warehouse"),
"fieldtype": "Link", "fieldtype": "Link",
"fieldname": "warehouse", "fieldname": "warehouse",
"options": "Warehouse", "options": "Warehouse",
"width": 220, "width": 150,
},
{
"label": _("Serial No"),
"fieldtype": "Link",
"fieldname": "serial_no",
"options": "Serial No",
"width": 150,
},
{
"label": _("Valuation Rate"),
"fieldtype": "Float",
"fieldname": "valuation_rate",
"width": 150,
}, },
] ]
@ -51,4 +64,65 @@ def get_columns(filters):
def get_data(filters): def get_data(filters):
return get_stock_ledger_entries(filters, "<=", order="asc") or [] stock_ledgers = get_stock_ledger_entries(filters, "<=", order="asc", check_serial_no=False)
if not stock_ledgers:
return []
data = []
serial_bundle_ids = [
d.serial_and_batch_bundle for d in stock_ledgers if d.serial_and_batch_bundle
]
bundle_wise_serial_nos = get_serial_nos(filters, serial_bundle_ids)
for row in stock_ledgers:
args = frappe._dict(
{
"posting_date": row.posting_date,
"posting_time": row.posting_time,
"voucher_type": row.voucher_type,
"voucher_no": row.voucher_no,
"company": row.company,
"warehouse": row.warehouse,
}
)
serial_nos = bundle_wise_serial_nos.get(row.serial_and_batch_bundle, [])
for index, bundle_data in enumerate(serial_nos):
if index == 0:
args.serial_no = bundle_data.get("serial_no")
args.valuation_rate = bundle_data.get("valuation_rate")
data.append(args)
else:
data.append(
{
"serial_no": bundle_data.get("serial_no"),
"valuation_rate": bundle_data.get("valuation_rate"),
}
)
return data
def get_serial_nos(filters, serial_bundle_ids):
bundle_wise_serial_nos = {}
bundle_filters = {"parent": ["in", serial_bundle_ids]}
if filters.get("serial_no"):
bundle_filters["serial_no"] = filters.get("serial_no")
for d in frappe.get_all(
"Serial and Batch Entry",
fields=["serial_no", "parent", "stock_value_difference as valuation_rate"],
filters=bundle_filters,
order_by="idx asc",
):
bundle_wise_serial_nos.setdefault(d.parent, []).append(
{
"serial_no": d.serial_no,
"valuation_rate": abs(d.valuation_rate),
}
)
return bundle_wise_serial_nos

View File

@ -25,18 +25,3 @@ class TestStockLedgerReeport(FrappeTestCase):
def tearDown(self) -> None: def tearDown(self) -> None:
frappe.db.rollback() frappe.db.rollback()
def test_serial_balance(self):
item_code = "_Test Stock Report Serial Item"
# Checks serials which were added through stock in entry.
columns, data = execute(self.filters)
self.assertEqual(data[0].in_qty, 2)
serials_added = get_serial_nos(data[0].serial_no)
self.assertEqual(len(serials_added), 2)
# Stock out entry for one of the serials.
dn = create_delivery_note(item=item_code, serial_no=serials_added[1])
self.filters.voucher_no = dn.name
columns, data = execute(self.filters)
self.assertEqual(data[0].out_qty, -1)
self.assertEqual(data[0].serial_no, serials_added[1])
self.assertEqual(data[0].balance_serial_no, serials_added[0])

View File

@ -0,0 +1,921 @@
from collections import defaultdict
from typing import List
import frappe
from frappe import _, bold
from frappe.model.naming import make_autoname
from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import cint, flt, now, nowtime, today
from erpnext.stock.deprecated_serial_batch import (
DeprecatedBatchNoValuation,
DeprecatedSerialNoValuation,
)
from erpnext.stock.valuation import round_off_if_near_zero
class SerialBatchBundle:
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
self.set_item_details()
self.process_serial_and_batch_bundle()
if self.sle.is_cancelled:
self.delink_serial_and_batch_bundle()
self.post_process()
def process_serial_and_batch_bundle(self):
if self.item_details.has_serial_no:
self.process_serial_no()
elif self.item_details.has_batch_no:
self.process_batch_no()
def set_item_details(self):
fields = [
"has_batch_no",
"has_serial_no",
"item_name",
"item_group",
"serial_no_series",
"create_new_batch",
"batch_number_series",
]
self.item_details = frappe.get_cached_value("Item", self.sle.item_code, fields, as_dict=1)
def process_serial_no(self):
if (
not self.sle.is_cancelled
and not self.sle.serial_and_batch_bundle
and self.item_details.has_serial_no == 1
):
self.make_serial_batch_no_bundle()
elif not self.sle.is_cancelled:
self.validate_item_and_warehouse()
def make_serial_batch_no_bundle(self):
self.validate_item()
sn_doc = SerialBatchCreation(
{
"item_code": self.item_code,
"warehouse": self.warehouse,
"posting_date": self.sle.posting_date,
"posting_time": self.sle.posting_time,
"voucher_type": self.sle.voucher_type,
"voucher_no": self.sle.voucher_no,
"voucher_detail_no": self.sle.voucher_detail_no,
"qty": self.sle.actual_qty,
"avg_rate": self.sle.incoming_rate,
"total_amount": flt(self.sle.actual_qty) * flt(self.sle.incoming_rate),
"type_of_transaction": "Inward" if self.sle.actual_qty > 0 else "Outward",
"company": self.company,
"is_rejected": self.is_rejected_entry(),
}
).make_serial_and_batch_bundle()
self.set_serial_and_batch_bundle(sn_doc)
def validate_actual_qty(self, sn_doc):
precision = sn_doc.precision("total_qty")
if flt(sn_doc.total_qty, precision) != flt(self.sle.actual_qty, precision):
msg = f"Total qty {flt(sn_doc.total_qty, precision)} of Serial and Batch Bundle {sn_doc.name} is not equal to Actual Qty {flt(self.sle.actual_qty, precision)} in the {self.sle.voucher_type} {self.sle.voucher_no}"
frappe.throw(_(msg))
def validate_item(self):
msg = ""
if self.sle.actual_qty > 0:
if not self.item_details.has_batch_no and not self.item_details.has_serial_no:
msg = f"Item {self.item_code} is not a batch or serial no item"
if self.item_details.has_serial_no and not self.item_details.serial_no_series:
msg += f". If you want auto pick serial bundle, then kindly set Serial No Series in Item {self.item_code}"
if (
self.item_details.has_batch_no
and not self.item_details.batch_number_series
and not frappe.db.get_single_value("Stock Settings", "naming_series_prefix")
):
msg += f". If you want auto pick batch bundle, then kindly set Batch Number Series in Item {self.item_code}"
elif self.sle.actual_qty < 0:
if not frappe.db.get_single_value(
"Stock Settings", "auto_create_serial_and_batch_bundle_for_outward"
):
msg += ". If you want auto pick serial/batch bundle, then kindly enable 'Auto Create Serial and Batch Bundle' in Stock Settings."
if msg:
error_msg = (
f"Serial and Batch Bundle not set for item {self.item_code} in warehouse {self.warehouse}."
+ msg
)
frappe.throw(_(error_msg))
def set_serial_and_batch_bundle(self, sn_doc):
self.sle.db_set("serial_and_batch_bundle", sn_doc.name)
if sn_doc.is_rejected:
frappe.db.set_value(
self.child_doctype, self.sle.voucher_detail_no, "rejected_serial_and_batch_bundle", sn_doc.name
)
else:
frappe.db.set_value(
self.child_doctype, self.sle.voucher_detail_no, "serial_and_batch_bundle", sn_doc.name
)
@property
def child_doctype(self):
child_doctype = self.sle.voucher_type + " Item"
if self.sle.voucher_type == "Stock Entry":
child_doctype = "Stock Entry Detail"
if self.sle.voucher_type == "Asset Capitalization":
child_doctype = "Asset Capitalization Stock Item"
if self.sle.voucher_type == "Asset Repair":
child_doctype = "Asset Repair Consumed Item"
return child_doctype
def is_rejected_entry(self):
return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse)
def process_batch_no(self):
if (
not self.sle.is_cancelled
and not self.sle.serial_and_batch_bundle
and self.item_details.has_batch_no == 1
and self.item_details.create_new_batch
):
self.make_serial_batch_no_bundle()
elif not self.sle.is_cancelled:
self.validate_item_and_warehouse()
def validate_item_and_warehouse(self):
if self.sle.serial_and_batch_bundle and not frappe.db.exists(
"Serial and Batch Bundle",
{
"name": self.sle.serial_and_batch_bundle,
"item_code": self.item_code,
"warehouse": self.warehouse,
"voucher_no": self.sle.voucher_no,
},
):
msg = f"""
The Serial and Batch Bundle
{bold(self.sle.serial_and_batch_bundle)}
does not belong to Item {bold(self.item_code)}
or Warehouse {bold(self.warehouse)}
or {self.sle.voucher_type} no {bold(self.sle.voucher_no)}
"""
frappe.throw(_(msg))
def delink_serial_and_batch_bundle(self):
update_values = {
"serial_and_batch_bundle": "",
}
if is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse):
update_values["rejected_serial_and_batch_bundle"] = ""
frappe.db.set_value(self.child_doctype, self.sle.voucher_detail_no, update_values)
frappe.db.set_value(
"Serial and Batch Bundle",
{"voucher_no": self.sle.voucher_no, "voucher_type": self.sle.voucher_type},
{"is_cancelled": 1, "voucher_no": ""},
)
if self.sle.serial_and_batch_bundle:
frappe.get_cached_doc(
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle
).validate_serial_and_batch_inventory()
def post_process(self):
if not self.sle.serial_and_batch_bundle:
return
docstatus = frappe.get_cached_value(
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "docstatus"
)
if docstatus != 1:
self.submit_serial_and_batch_bundle()
if self.item_details.has_serial_no == 1:
self.set_warehouse_and_status_in_serial_nos()
if (
self.sle.actual_qty > 0
and self.item_details.has_serial_no == 1
and self.item_details.has_batch_no == 1
):
self.set_batch_no_in_serial_nos()
if self.item_details.has_batch_no == 1:
self.update_batch_qty()
def submit_serial_and_batch_bundle(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.sle.serial_and_batch_bundle)
self.validate_actual_qty(doc)
doc.flags.ignore_voucher_validation = True
doc.submit()
def set_warehouse_and_status_in_serial_nos(self):
serial_nos = get_serial_nos(self.sle.serial_and_batch_bundle)
warehouse = self.warehouse if self.sle.actual_qty > 0 else None
if not serial_nos:
return
sn_table = frappe.qb.DocType("Serial No")
(
frappe.qb.update(sn_table)
.set(sn_table.warehouse, warehouse)
.set(sn_table.status, "Active" if warehouse else "Inactive")
.where(sn_table.name.isin(serial_nos))
).run()
def set_batch_no_in_serial_nos(self):
entries = frappe.get_all(
"Serial and Batch Entry",
fields=["serial_no", "batch_no"],
filters={"parent": self.sle.serial_and_batch_bundle},
)
batch_serial_nos = {}
for ledger in entries:
batch_serial_nos.setdefault(ledger.batch_no, []).append(ledger.serial_no)
for batch_no, serial_nos in batch_serial_nos.items():
sn_table = frappe.qb.DocType("Serial No")
(
frappe.qb.update(sn_table)
.set(sn_table.batch_no, batch_no)
.where(sn_table.name.isin(serial_nos))
).run()
def update_batch_qty(self):
from erpnext.stock.doctype.batch.batch import get_available_batches
batches = get_batch_nos(self.sle.serial_and_batch_bundle)
batches_qty = get_available_batches(
frappe._dict(
{"item_code": self.item_code, "warehouse": self.warehouse, "batch_no": list(batches.keys())}
)
)
for batch_no in batches:
frappe.db.set_value("Batch", batch_no, "batch_qty", batches_qty.get(batch_no, 0))
def get_serial_nos(serial_and_batch_bundle, serial_nos=None):
if not serial_and_batch_bundle:
return []
filters = {"parent": serial_and_batch_bundle, "serial_no": ("is", "set")}
if isinstance(serial_and_batch_bundle, list):
filters = {"parent": ("in", serial_and_batch_bundle)}
if serial_nos:
filters["serial_no"] = ("in", serial_nos)
entries = frappe.get_all("Serial and Batch Entry", fields=["serial_no"], filters=filters)
if not entries:
return []
return [d.serial_no for d in entries if d.serial_no]
def get_serial_nos_from_bundle(serial_and_batch_bundle, serial_nos=None):
return get_serial_nos(serial_and_batch_bundle, serial_nos=serial_nos)
def get_serial_or_batch_nos(bundle):
return frappe.get_all("Serial and Batch Entry", fields=["*"], filters={"parent": bundle})
class SerialNoValuation(DeprecatedSerialNoValuation):
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
self.calculate_stock_value_change()
self.calculate_valuation_rate()
def calculate_stock_value_change(self):
if flt(self.sle.actual_qty) > 0:
self.stock_value_change = frappe.get_cached_value(
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount"
)
else:
entries = self.get_serial_no_ledgers()
self.serial_no_incoming_rate = defaultdict(float)
self.stock_value_change = 0.0
for ledger in entries:
self.stock_value_change += ledger.incoming_rate
self.serial_no_incoming_rate[ledger.serial_no] += ledger.incoming_rate
self.calculate_stock_value_from_deprecarated_ledgers()
def get_serial_no_ledgers(self):
serial_nos = self.get_serial_nos()
bundle = frappe.qb.DocType("Serial and Batch Bundle")
bundle_child = frappe.qb.DocType("Serial and Batch Entry")
query = (
frappe.qb.from_(bundle)
.inner_join(bundle_child)
.on(bundle.name == bundle_child.parent)
.select(
bundle.name,
bundle_child.serial_no,
(bundle_child.incoming_rate * bundle_child.qty).as_("incoming_rate"),
)
.where(
(bundle.is_cancelled == 0)
& (bundle.docstatus == 1)
& (bundle_child.serial_no.isin(serial_nos))
& (bundle.type_of_transaction.isin(["Inward", "Outward"]))
& (bundle.item_code == self.sle.item_code)
& (bundle_child.warehouse == self.sle.warehouse)
)
.orderby(bundle.posting_date, bundle.posting_time, bundle.creation)
)
# Important to exclude the current voucher
if self.sle.voucher_no:
query = query.where(bundle.voucher_no != self.sle.voucher_no)
if self.sle.posting_date:
if self.sle.posting_time is None:
self.sle.posting_time = nowtime()
timestamp_condition = CombineDatetime(
bundle.posting_date, bundle.posting_time
) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time)
query = query.where(timestamp_condition)
return query.run(as_dict=True)
def get_serial_nos(self):
if self.sle.get("serial_nos"):
return self.sle.serial_nos
return get_serial_nos(self.sle.serial_and_batch_bundle)
def calculate_valuation_rate(self):
if not hasattr(self, "wh_data"):
return
new_stock_qty = self.wh_data.qty_after_transaction + self.sle.actual_qty
if new_stock_qty > 0:
new_stock_value = (
self.wh_data.qty_after_transaction * self.wh_data.valuation_rate
) + self.stock_value_change
if new_stock_value >= 0:
# calculate new valuation rate only if stock value is positive
# else it remains the same as that of previous entry
self.wh_data.valuation_rate = new_stock_value / new_stock_qty
if (
not self.wh_data.valuation_rate and self.sle.voucher_detail_no and not self.is_rejected_entry()
):
allow_zero_rate = self.sle_self.check_if_allow_zero_valuation_rate(
self.sle.voucher_type, self.sle.voucher_detail_no
)
if not allow_zero_rate:
self.wh_data.valuation_rate = self.sle_self.get_fallback_rate(self.sle)
self.wh_data.qty_after_transaction += self.sle.actual_qty
self.wh_data.stock_value = flt(self.wh_data.qty_after_transaction) * flt(
self.wh_data.valuation_rate
)
def is_rejected_entry(self):
return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse)
def get_incoming_rate(self):
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
def get_incoming_rate_of_serial_no(self, serial_no):
return self.serial_no_incoming_rate.get(serial_no, 0.0)
def is_rejected(voucher_type, voucher_detail_no, warehouse):
if voucher_type in ["Purchase Receipt", "Purchase Invoice"]:
return warehouse == frappe.get_cached_value(
voucher_type + " Item", voucher_detail_no, "rejected_warehouse"
)
return False
class BatchNoValuation(DeprecatedBatchNoValuation):
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
self.batch_nos = self.get_batch_nos()
self.prepare_batches()
self.calculate_avg_rate()
self.calculate_valuation_rate()
def calculate_avg_rate(self):
if flt(self.sle.actual_qty) > 0:
self.stock_value_change = frappe.get_cached_value(
"Serial and Batch Bundle", self.sle.serial_and_batch_bundle, "total_amount"
)
else:
entries = self.get_batch_no_ledgers()
self.stock_value_change = 0.0
self.batch_avg_rate = defaultdict(float)
self.available_qty = defaultdict(float)
self.stock_value_differece = defaultdict(float)
for ledger in entries:
self.stock_value_differece[ledger.batch_no] += flt(ledger.incoming_rate)
self.available_qty[ledger.batch_no] += flt(ledger.qty)
self.calculate_avg_rate_from_deprecarated_ledgers()
self.calculate_avg_rate_for_non_batchwise_valuation()
self.set_stock_value_difference()
def get_batch_no_ledgers(self) -> List[dict]:
if not self.batchwise_valuation_batches:
return []
parent = frappe.qb.DocType("Serial and Batch Bundle")
child = frappe.qb.DocType("Serial and Batch Entry")
timestamp_condition = ""
if self.sle.posting_date and self.sle.posting_time:
timestamp_condition = CombineDatetime(
parent.posting_date, parent.posting_time
) <= CombineDatetime(self.sle.posting_date, self.sle.posting_time)
query = (
frappe.qb.from_(parent)
.inner_join(child)
.on(parent.name == child.parent)
.select(
child.batch_no,
Sum(child.stock_value_difference).as_("incoming_rate"),
Sum(child.qty).as_("qty"),
)
.where(
(child.batch_no.isin(self.batchwise_valuation_batches))
& (parent.warehouse == self.sle.warehouse)
& (parent.item_code == self.sle.item_code)
& (parent.docstatus == 1)
& (parent.is_cancelled == 0)
& (parent.type_of_transaction.isin(["Inward", "Outward"]))
)
.groupby(child.batch_no)
)
# Important to exclude the current voucher
if self.sle.voucher_no:
query = query.where(parent.voucher_no != self.sle.voucher_no)
if timestamp_condition:
query = query.where(timestamp_condition)
return query.run(as_dict=True)
def prepare_batches(self):
self.batches = self.batch_nos
if isinstance(self.batch_nos, dict):
self.batches = list(self.batch_nos.keys())
self.batchwise_valuation_batches = []
self.non_batchwise_valuation_batches = []
batches = frappe.get_all(
"Batch", filters={"name": ("in", self.batches), "use_batchwise_valuation": 1}, fields=["name"]
)
for batch in batches:
self.batchwise_valuation_batches.append(batch.name)
self.non_batchwise_valuation_batches = list(
set(self.batches) - set(self.batchwise_valuation_batches)
)
def get_batch_nos(self) -> list:
if self.sle.get("batch_nos"):
return self.sle.batch_nos
return get_batch_nos(self.sle.serial_and_batch_bundle)
def set_stock_value_difference(self):
for batch_no, ledger in self.batch_nos.items():
if batch_no in self.non_batchwise_valuation_batches:
continue
if not self.available_qty[batch_no]:
continue
self.batch_avg_rate[batch_no] = (
self.stock_value_differece[batch_no] / self.available_qty[batch_no]
)
# New Stock Value Difference
stock_value_change = self.batch_avg_rate[batch_no] * ledger.qty
self.stock_value_change += stock_value_change
frappe.db.set_value(
"Serial and Batch Entry",
ledger.name,
{
"stock_value_difference": stock_value_change,
"incoming_rate": self.batch_avg_rate[batch_no],
},
)
def calculate_valuation_rate(self):
if not hasattr(self, "wh_data"):
return
self.wh_data.stock_value = round_off_if_near_zero(
self.wh_data.stock_value + self.stock_value_change
)
self.wh_data.qty_after_transaction += self.sle.actual_qty
if self.wh_data.qty_after_transaction:
self.wh_data.valuation_rate = self.wh_data.stock_value / self.wh_data.qty_after_transaction
def get_incoming_rate(self):
if not self.sle.actual_qty:
self.sle.actual_qty = self.get_actual_qty()
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
def get_actual_qty(self):
total_qty = 0.0
for batch_no in self.available_qty:
total_qty += self.available_qty[batch_no]
return total_qty
def get_batch_nos(serial_and_batch_bundle):
if not serial_and_batch_bundle:
return frappe._dict({})
entries = frappe.get_all(
"Serial and Batch Entry",
fields=["batch_no", "qty", "name"],
filters={"parent": serial_and_batch_bundle, "batch_no": ("is", "set")},
order_by="idx",
)
if not entries:
return frappe._dict({})
return {d.batch_no: d for d in entries}
def get_empty_batches_based_work_order(work_order, item_code):
batches = get_batches_from_work_order(work_order, item_code)
if not batches:
return batches
entries = get_batches_from_stock_entries(work_order, item_code)
if not entries:
return batches
ids = [d.serial_and_batch_bundle for d in entries if d.serial_and_batch_bundle]
if ids:
set_batch_details_from_package(ids, batches)
# Will be deprecated in v16
for d in entries:
if not d.batch_no:
continue
batches[d.batch_no] -= d.qty
return batches
def get_batches_from_work_order(work_order, item_code):
return frappe._dict(
frappe.get_all(
"Batch",
fields=["name", "qty_to_produce"],
filters={"reference_name": work_order, "item": item_code},
as_list=1,
)
)
def get_batches_from_stock_entries(work_order, item_code):
entries = frappe.get_all(
"Stock Entry",
filters={"work_order": work_order, "docstatus": 1, "purpose": "Manufacture"},
fields=["name"],
)
return frappe.get_all(
"Stock Entry Detail",
fields=["batch_no", "qty", "serial_and_batch_bundle"],
filters={
"parent": ("in", [d.name for d in entries]),
"is_finished_item": 1,
"item_code": item_code,
},
)
def set_batch_details_from_package(ids, batches):
entries = frappe.get_all(
"Serial and Batch Entry",
filters={"parent": ("in", ids), "is_outward": 0},
fields=["batch_no", "qty"],
)
for d in entries:
batches[d.batch_no] -= d.qty
class SerialBatchCreation:
def __init__(self, args):
self.set(args)
self.set_item_details()
self.set_other_details()
def set(self, args):
self.__dict__ = {}
for key, value in args.items():
setattr(self, key, value)
self.__dict__[key] = value
def get(self, key):
return self.__dict__.get(key)
def set_item_details(self):
fields = [
"has_batch_no",
"has_serial_no",
"item_name",
"item_group",
"serial_no_series",
"create_new_batch",
"batch_number_series",
"description",
]
item_details = frappe.get_cached_value("Item", self.item_code, fields, as_dict=1)
for key, value in item_details.items():
setattr(self, key, value)
self.__dict__.update(item_details)
def set_other_details(self):
if not self.get("posting_date"):
setattr(self, "posting_date", today())
self.__dict__["posting_date"] = self.posting_date
if not self.get("actual_qty"):
qty = self.get("qty") or self.get("total_qty")
setattr(self, "actual_qty", qty)
self.__dict__["actual_qty"] = self.actual_qty
def duplicate_package(self):
if not self.serial_and_batch_bundle:
return
id = self.serial_and_batch_bundle
package = frappe.get_doc("Serial and Batch Bundle", id)
new_package = frappe.copy_doc(package)
if self.get("returned_serial_nos"):
self.remove_returned_serial_nos(new_package)
new_package.docstatus = 0
new_package.type_of_transaction = self.type_of_transaction
new_package.returned_against = self.get("returned_against")
new_package.save()
self.serial_and_batch_bundle = new_package.name
def remove_returned_serial_nos(self, package):
remove_list = []
for d in package.entries:
if d.serial_no in self.returned_serial_nos:
remove_list.append(d)
for d in remove_list:
package.remove(d)
def make_serial_and_batch_bundle(self):
doc = frappe.new_doc("Serial and Batch Bundle")
valid_columns = doc.meta.get_valid_columns()
for key, value in self.__dict__.items():
if key in valid_columns:
doc.set(key, value)
if self.type_of_transaction == "Outward":
self.set_auto_serial_batch_entries_for_outward()
elif self.type_of_transaction == "Inward":
self.set_auto_serial_batch_entries_for_inward()
self.add_serial_nos_for_batch_item()
self.set_serial_batch_entries(doc)
if not doc.get("entries"):
return frappe._dict({})
doc.save()
if not hasattr(self, "do_not_submit") or not self.do_not_submit:
doc.flags.ignore_voucher_validation = True
doc.submit()
return doc
def add_serial_nos_for_batch_item(self):
if not (self.has_serial_no and self.has_batch_no):
return
if not self.get("serial_nos") and self.get("batches"):
batches = list(self.get("batches").keys())
if len(batches) == 1:
self.batch_no = batches[0]
self.serial_nos = self.get_auto_created_serial_nos()
def update_serial_and_batch_entries(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle)
doc.type_of_transaction = self.type_of_transaction
doc.set("entries", [])
self.set_auto_serial_batch_entries_for_outward()
self.set_serial_batch_entries(doc)
if not doc.get("entries"):
return frappe._dict({})
doc.save()
return doc
def set_auto_serial_batch_entries_for_outward(self):
from erpnext.stock.doctype.batch.batch import get_available_batches
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos_for_outward
kwargs = frappe._dict(
{
"item_code": self.item_code,
"warehouse": self.warehouse,
"qty": abs(self.actual_qty) if self.actual_qty else 0,
"based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
}
)
if self.get("ignore_serial_nos"):
kwargs["ignore_serial_nos"] = self.ignore_serial_nos
if self.has_serial_no and not self.get("serial_nos"):
self.serial_nos = get_serial_nos_for_outward(kwargs)
elif not self.has_serial_no and self.has_batch_no and not self.get("batches"):
self.batches = get_available_batches(kwargs)
def set_auto_serial_batch_entries_for_inward(self):
if (self.get("batches") and self.has_batch_no) or (
self.get("serial_nos") and self.has_serial_no
):
return
self.batch_no = None
if self.has_batch_no:
self.batch_no = self.create_batch()
if self.has_serial_no:
self.serial_nos = self.get_auto_created_serial_nos()
else:
self.batches = frappe._dict({self.batch_no: abs(self.actual_qty)})
def set_serial_batch_entries(self, doc):
if self.get("serial_nos"):
serial_no_wise_batch = frappe._dict({})
if self.has_batch_no:
serial_no_wise_batch = self.get_serial_nos_batch(self.serial_nos)
qty = -1 if self.type_of_transaction == "Outward" else 1
for serial_no in self.serial_nos:
doc.append(
"entries",
{
"serial_no": serial_no,
"qty": qty,
"batch_no": serial_no_wise_batch.get(serial_no) or self.get("batch_no"),
"incoming_rate": self.get("incoming_rate"),
},
)
elif self.get("batches"):
for batch_no, batch_qty in self.batches.items():
doc.append(
"entries",
{
"batch_no": batch_no,
"qty": batch_qty * (-1 if self.type_of_transaction == "Outward" else 1),
"incoming_rate": self.get("incoming_rate"),
},
)
def get_serial_nos_batch(self, serial_nos):
return frappe._dict(
frappe.get_all(
"Serial No",
fields=["name", "batch_no"],
filters={"name": ("in", serial_nos)},
as_list=1,
)
)
def create_batch(self):
from erpnext.stock.doctype.batch.batch import make_batch
return make_batch(
frappe._dict(
{
"item": self.get("item_code"),
"reference_doctype": self.get("voucher_type"),
"reference_name": self.get("voucher_no"),
}
)
)
def get_auto_created_serial_nos(self):
sr_nos = []
serial_nos_details = []
if not self.serial_no_series:
msg = f"Please set Serial No Series in the item {self.item_code} or create Serial and Batch Bundle manually."
frappe.throw(_(msg))
for i in range(abs(cint(self.actual_qty))):
serial_no = make_autoname(self.serial_no_series, "Serial No")
sr_nos.append(serial_no)
serial_nos_details.append(
(
serial_no,
serial_no,
now(),
now(),
frappe.session.user,
frappe.session.user,
self.warehouse,
self.company,
self.item_code,
self.item_name,
self.description,
"Active",
self.batch_no,
)
)
if serial_nos_details:
fields = [
"name",
"serial_no",
"creation",
"modified",
"owner",
"modified_by",
"warehouse",
"company",
"item_code",
"item_name",
"description",
"status",
"batch_no",
]
frappe.db.bulk_insert("Serial No", fields=fields, values=set(serial_nos_details))
return sr_nos
def get_serial_or_batch_items(items):
serial_or_batch_items = frappe.get_all(
"Item",
filters={"name": ("in", [d.item_code for d in items])},
or_filters={"has_serial_no": 1, "has_batch_no": 1},
)
if not serial_or_batch_items:
return
else:
serial_or_batch_items = [d.name for d in serial_or_batch_items]
return serial_or_batch_items

View File

@ -295,19 +295,3 @@ def set_stock_balance_as_per_serial_no(
"posting_time": posting_time, "posting_time": posting_time,
} }
) )
def reset_serial_no_status_and_warehouse(serial_nos=None):
if not serial_nos:
serial_nos = frappe.db.sql_list("""select name from `tabSerial No` where docstatus = 0""")
for serial_no in serial_nos:
try:
sr = frappe.get_doc("Serial No", serial_no)
last_sle = sr.get_last_sle()
if flt(last_sle.actual_qty) > 0:
sr.warehouse = last_sle.warehouse
sr.via_stock_ledger = True
sr.save()
except Exception:
pass

Some files were not shown because too many files have changed in this diff Show More