feat: auto create serial and batch bundle

This commit is contained in:
Rohit Waghchaure 2023-03-28 12:16:27 +05:30
parent c1132d1e6d
commit 648efca940
18 changed files with 556 additions and 642 deletions

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

@ -36,7 +36,6 @@ 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 get_delivery_note_serial_no, get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_delivery_note_serial_no, get_serial_nos
@ -125,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 = (

View File

@ -372,6 +372,16 @@ class StockController(AccountsController):
row.db_set("serial_and_batch_bundle", None) row.db_set("serial_and_batch_bundle", None)
def set_serial_and_batch_bundle(self, table_name=None):
if not table_name:
table_name = "items"
for row in self.get(table_name):
if row.get("serial_and_batch_bundle"):
frappe.get_doc(
"Serial and Batch Bundle", row.serial_and_batch_bundle
).set_serial_and_batch_values(self, row)
def make_package_for_transfer( def make_package_for_transfer(
self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None self, serial_and_batch_bundle, warehouse, type_of_transaction=None, do_not_submit=None
): ):
@ -749,16 +759,6 @@ class StockController(AccountsController):
message = self.prepare_over_receipt_message(rule, values) message = self.prepare_over_receipt_message(rule, values)
frappe.throw(msg=message, title=_("Over Receipt")) frappe.throw(msg=message, title=_("Over Receipt"))
def set_serial_and_batch_bundle(self, table_name=None):
if not table_name:
table_name = "items"
for row in self.get(table_name):
if row.get("serial_and_batch_bundle"):
frappe.get_doc(
"Serial and Batch Bundle", row.serial_and_batch_bundle
).set_serial_and_batch_values(self, row)
def prepare_over_receipt_message(self, rule, values): def prepare_over_receipt_message(self, rule, values):
message = _( message = _(
"{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}." "{0} qty of Item {1} is being received into Warehouse {2} with capacity {3}."

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);
@ -388,38 +316,6 @@ erpnext.selling.SellingController = class SellingController extends erpnext.Tran
} }
} }
/* Determine appropriate batch number and set it in the form.
* @param {string} cdt - Document Doctype
* @param {string} cdn - Document name
*/
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) {
if (doc.batch_no) {
return
}
let args = {'item_code': doc.item_code, 'warehouse': doc.warehouse, 'qty': flt(doc.qty) * flt(doc.conversion_factor)};
if (doc.has_serial_no && doc.serial_no) {
args['serial_no'] = doc.serial_no
}
return frappe.call({
method: 'erpnext.stock.doctype.batch.batch.get_batch_no',
args: args,
callback: function(r) {
if(r.message) {
frappe.model.set_value(doc.doctype, doc.name, 'batch_no', r.message);
}
}
});
}
pick_serial_and_batch(doc, cdt, cdn) { pick_serial_and_batch(doc, cdt, cdn) {
let item = locals[cdt][cdn]; let item = locals[cdt][cdn];
let me = this; let me = this;

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

@ -2,6 +2,8 @@
# 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
@ -257,54 +259,6 @@ 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"):
"""Automatically select `batch_no` for outgoing items in item table"""
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:
pass
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)
)
@frappe.whitelist()
def get_batch_no(item_code, warehouse, qty=1, throw=False, serial_no=None):
"""
Get batch number using First Expiring First Out method.
:param item_code: `item_code` of Item Document
:param warehouse: name of Warehouse to check
:param qty: quantity of Items
:return: String represent batch number of batch with sufficient quantity else an empty String
"""
batch_no = None
batches = get_batches(item_code, warehouse, qty, throw, serial_no)
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:
raise UnableToSelectBatchError
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):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
@ -398,3 +352,17 @@ 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

View File

@ -137,6 +137,7 @@ 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
@ -187,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:

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
@ -79,6 +83,7 @@ class PickList(Document):
) )
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()
@ -90,7 +95,29 @@ class PickList(Document):
self.update_reference_qty() self.update_reference_qty()
self.update_sales_order_picking_status() self.update_sales_order_picking_status()
def update_status(self, status=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 +219,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:
@ -476,18 +504,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,
} }
) )
) )
@ -553,23 +576,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)
@ -620,31 +626,50 @@ 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())
bundle_doc = SerialBatchCreation(
{
"item_code": item_code,
"warehouse": warehouse,
"voucher_type": "Pick List",
"total_qty": qty,
"batches": batches,
"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
def get_available_item_locations_for_serial_and_batched_item( def get_available_item_locations_for_serial_and_batched_item(

View File

@ -10,7 +10,6 @@ from frappe import _, bold
from frappe.model.document import Document from frappe.model.document import Document
from frappe.query_builder.functions import CombineDatetime, Sum from frappe.query_builder.functions import CombineDatetime, Sum
from frappe.utils import add_days, cint, flt, get_link_to_form, today from frappe.utils import add_days, cint, flt, get_link_to_form, today
from pypika import Case
from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation from erpnext.stock.serial_batch_bundle import BatchNoValuation, SerialNoValuation
@ -24,8 +23,6 @@ class SerialandBatchBundle(Document):
self.validate_serial_and_batch_no() self.validate_serial_and_batch_no()
self.validate_duplicate_serial_and_batch_no() self.validate_duplicate_serial_and_batch_no()
self.validate_voucher_no() self.validate_voucher_no()
def before_save(self):
if self.type_of_transaction == "Maintenance": if self.type_of_transaction == "Maintenance":
return return
@ -168,13 +165,16 @@ class SerialandBatchBundle(Document):
if not self.voucher_no or self.voucher_no != row.parent: if not self.voucher_no or self.voucher_no != row.parent:
values_to_set["voucher_no"] = row.parent values_to_set["voucher_no"] = row.parent
if self.voucher_type != parent.doctype:
values_to_set["voucher_type"] = parent.doctype
if not self.voucher_detail_no or self.voucher_detail_no != row.name: if not self.voucher_detail_no or self.voucher_detail_no != row.name:
values_to_set["voucher_detail_no"] = row.name values_to_set["voucher_detail_no"] = row.name
if parent.get("posting_date") and ( if parent.get("posting_date") and (
not self.posting_date or self.posting_date != parent.posting_date not self.posting_date or self.posting_date != parent.posting_date
): ):
values_to_set["posting_date"] = parent.posting_date values_to_set["posting_date"] = parent.posting_date or today()
if parent.get("posting_time") and ( if parent.get("posting_time") and (
not self.posting_time or self.posting_time != parent.posting_time not self.posting_time or self.posting_time != parent.posting_time
@ -222,6 +222,9 @@ class SerialandBatchBundle(Document):
if self.voucher_no and not frappe.db.exists(self.voucher_type, self.voucher_no): if self.voucher_no and not frappe.db.exists(self.voucher_type, self.voucher_no):
frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} does not exist")) frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} does not exist"))
if frappe.get_cached_value(self.voucher_type, self.voucher_no, "docstatus") != 1:
frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} should be submit first."))
def check_future_entries_exists(self): def check_future_entries_exists(self):
if not self.has_serial_no: if not self.has_serial_no:
return return
@ -681,73 +684,43 @@ def get_auto_batch_nos(kwargs):
batches = [] batches = []
reserved_batches = get_reserved_batches_for_pos(kwargs) stock_ledgers_batches = get_stock_ledgers_batches(kwargs)
if reserved_batches: if stock_ledgers_batches:
remove_batches_reserved_for_pos(available_batches, reserved_batches) update_available_batches(available_batches, stock_ledgers_batches)
if not qty:
return batches
for batch in available_batches: for batch in available_batches:
if qty > 0: if qty > 0:
batch_qty = flt(batch.qty) batch_qty = flt(batch.qty)
if qty > batch_qty: if qty > batch_qty:
batches.append( batches.append(
frappe._dict(
{ {
"batch_no": batch.batch_no, "batch_no": batch.batch_no,
"qty": batch_qty, "qty": batch_qty,
"warehouse": batch.warehouse,
} }
) )
)
qty -= batch_qty qty -= batch_qty
else: else:
batches.append( batches.append(
frappe._dict(
{ {
"batch_no": batch.batch_no, "batch_no": batch.batch_no,
"qty": qty, "qty": qty,
"warehouse": batch.warehouse,
} }
) )
)
qty = 0 qty = 0
return batches return batches
def get_reserved_batches_for_pos(kwargs): def update_available_batches(available_batches, reserved_batches):
reserved_batches = defaultdict(float)
pos_invoices = frappe.get_all(
"POS Invoice",
fields=[
"`tabPOS Invoice Item`.batch_no",
"`tabPOS Invoice Item`.qty",
"`tabPOS Invoice Item`.serial_and_batch_bundle",
],
filters=[
["POS Invoice", "consolidated_invoice", "is", "not set"],
["POS Invoice", "docstatus", "=", 1],
["POS Invoice Item", "item_code", "=", kwargs.item_code],
],
)
ids = [
pos_invoice.serial_and_batch_bundle
for pos_invoice in pos_invoices
if pos_invoice.serial_and_batch_bundle
]
for d in get_serial_batch_ledgers(ids, docstatus=1, name=ids):
if not d.batch_no:
continue
reserved_batches[d.batch_no] += flt(d.qty)
# Will be deprecated in v16
for pos_invoice in pos_invoices:
if not pos_invoice.batch_no:
continue
reserved_batches[pos_invoice.batch_no] += flt(pos_invoice.qty)
return reserved_batches
def remove_batches_reserved_for_pos(available_batches, reserved_batches):
for batch in available_batches: for batch in available_batches:
if batch.batch_no in reserved_batches: if batch.batch_no in reserved_batches:
available_batches[batch.batch_no] -= reserved_batches[batch.batch_no] available_batches[batch.batch_no] -= reserved_batches[batch.batch_no]
@ -766,16 +739,28 @@ def get_available_batches(kwargs):
.on(batch_ledger.batch_no == batch_table.name) .on(batch_ledger.batch_no == batch_table.name)
.select( .select(
batch_ledger.batch_no, batch_ledger.batch_no,
batch_ledger.warehouse,
Sum(batch_ledger.qty).as_("qty"), Sum(batch_ledger.qty).as_("qty"),
) )
.where( .where(((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull())))
(stock_ledger_entry.item_code == kwargs.item_code)
& (stock_ledger_entry.warehouse == kwargs.warehouse)
& ((batch_table.expiry_date >= today()) | (batch_table.expiry_date.isnull()))
)
.groupby(batch_ledger.batch_no) .groupby(batch_ledger.batch_no)
) )
for field in ["warehouse", "item_code"]:
if not kwargs.get(field):
continue
if isinstance(kwargs.get(field), list):
query = query.where(stock_ledger_entry[field].isin(kwargs.get(field)))
else:
query = query.where(stock_ledger_entry[field] == kwargs.get(field))
if kwargs.get("batch_no"):
if isinstance(kwargs.batch_no, list):
query = query.where(batch_ledger.name.isin(kwargs.batch_no))
else:
query = query.where(batch_ledger.name == kwargs.batch_no)
if kwargs.based_on == "LIFO": if kwargs.based_on == "LIFO":
query = query.orderby(batch_table.creation, order=frappe.qb.desc) query = query.orderby(batch_table.creation, order=frappe.qb.desc)
elif kwargs.based_on == "Expiry": elif kwargs.based_on == "Expiry":
@ -789,6 +774,7 @@ def get_available_batches(kwargs):
return data return data
# For work order and subcontracting
def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> Dict[str, Dict]: def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> Dict[str, Dict]:
data = get_ledgers_from_serial_batch_bundle(**kwargs) data = get_ledgers_from_serial_batch_bundle(**kwargs)
if not data: if not data:
@ -878,42 +864,34 @@ def get_available_serial_nos(item_code, warehouse):
return frappe.get_all("Serial No", filters=filters, fields=fields) return frappe.get_all("Serial No", filters=filters, fields=fields)
def get_available_batch_nos(item_code, warehouse): def get_stock_ledgers_batches(kwargs):
sl_entries = get_stock_ledger_entries(item_code, warehouse)
batchwise_qty = defaultdict(float)
precision = frappe.get_precision("Stock Ledger Entry", "qty")
for entry in sl_entries:
batchwise_qty[entry.batch_no] += flt(entry.qty, precision)
return batchwise_qty
def get_stock_ledger_entries(item_code, warehouse):
stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry") stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry")
batch_ledger = frappe.qb.DocType("Serial and Batch Entry")
return ( query = (
frappe.qb.from_(stock_ledger_entry) frappe.qb.from_(stock_ledger_entry)
.left_join(batch_ledger)
.on(stock_ledger_entry.serial_and_batch_bundle == batch_ledger.parent)
.select( .select(
stock_ledger_entry.warehouse, stock_ledger_entry.warehouse,
stock_ledger_entry.item_code, stock_ledger_entry.item_code,
Sum( Sum(stock_ledger_entry.actual_qty).as_("qty"),
Case() stock_ledger_entry.batch_no,
.when(stock_ledger_entry.serial_and_batch_bundle, batch_ledger.qty)
.else_(stock_ledger_entry.actual_qty)
.as_("qty")
),
Case()
.when(stock_ledger_entry.serial_and_batch_bundle, batch_ledger.batch_no)
.else_(stock_ledger_entry.batch_no)
.as_("batch_no"),
) )
.where( .where((stock_ledger_entry.is_cancelled == 0))
(stock_ledger_entry.item_code == item_code) .groupby(stock_ledger_entry.batch_no, stock_ledger_entry.warehouse)
& (stock_ledger_entry.warehouse == warehouse)
& (stock_ledger_entry.is_cancelled == 0)
) )
).run(as_dict=True)
for field in ["warehouse", "item_code"]:
if not kwargs.get(field):
continue
if isinstance(kwargs.get(field), list):
query = query.where(stock_ledger_entry[field].isin(kwargs.get(field)))
else:
query = query.where(stock_ledger_entry[field] == kwargs.get(field))
data = query.run(as_dict=True)
batches = defaultdict(float)
for d in data:
batches[d.batch_no] += d.qty
return batches

View File

@ -322,3 +322,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_auto_serial_nos,
)
serial_nos = get_auto_serial_nos(kwargs)
if not serial_nos:
return []
return [d.serial_no for d in serial_nos]

View File

@ -28,7 +28,7 @@ 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 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 (
@ -39,7 +39,11 @@ from erpnext.stock.get_item_details import (
get_conversion_factor, get_conversion_factor,
get_default_cost_center, get_default_cost_center,
) )
from erpnext.stock.serial_batch_bundle import get_empty_batches_based_work_order 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
@ -143,9 +147,6 @@ class StockEntry(StockController):
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":
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()
@ -242,6 +243,9 @@ 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): def on_update(self):
self.set_serial_and_batch_bundle() self.set_serial_and_batch_bundle()
@ -894,6 +898,30 @@ class StockEntry(StockController):
serial_nos.append(sn) serial_nos.append(sn)
def make_serial_and_batch_bundle_for_outward(self):
serial_or_batch_items = get_serial_or_batch_items(self.items)
for row in self.items:
if row.serial_and_batch_bundle or row.item_code not in serial_or_batch_items:
continue
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,
"total_qty": row.qty,
"type_of_transaction": "Outward",
"company": self.company,
"do_not_submit": True,
}
).make_serial_and_batch_bundle()
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
the raw materials supplied table""" the raw materials supplied table"""
@ -1445,15 +1473,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)

View File

@ -8,7 +8,7 @@ import frappe
from frappe import _ from frappe import _
from frappe.core.doctype.role.role import get_users from frappe.core.doctype.role.role import get_users
from frappe.model.document import Document from frappe.model.document import Document
from frappe.utils import add_days, cint, formatdate, get_datetime, get_link_to_form, getdate 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
@ -51,7 +51,6 @@ 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()
if not self.get("via_landed_cost_voucher"): if not self.get("via_landed_cost_voucher"):
SerialBatchBundle( SerialBatchBundle(
@ -63,18 +62,6 @@ class StockLedgerEntry(Document):
self.validate_serial_batch_no_bundle() self.validate_serial_batch_no_bundle()
def calculate_batch_qty(self):
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)
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"]
for k in mandatory: for k in mandatory:
@ -123,12 +110,15 @@ class StockLedgerEntry(Document):
) )
if bundle_data.docstatus != 1: if bundle_data.docstatus != 1:
link = get_link_to_form("Serial and Batch Bundle", self.serial_and_batch_bundle) self.submit_serial_and_batch_bundle()
frappe.throw(_(f"Serial and Batch Bundle {link} should be submitted first"))
if self.serial_and_batch_bundle and not (item_detail.has_serial_no or item_detail.has_batch_no): if self.serial_and_batch_bundle and not (item_detail.has_serial_no or item_detail.has_batch_no):
frappe.throw(_(f"Serial No and Batch No are not allowed for Item {self.item_code}")) frappe.throw(_(f"Serial No and Batch No are not allowed for Item {self.item_code}"))
def submit_serial_and_batch_bundle(self):
doc = frappe.get_doc("Serial and Batch Bundle", self.serial_and_batch_bundle)
doc.submit()
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

@ -13,7 +13,7 @@ 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_batch_qty
from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import ( from erpnext.stock.doctype.serial_and_batch_bundle.serial_and_batch_bundle import (
get_available_batch_nos, get_auto_batch_nos,
get_available_serial_nos, 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
@ -114,7 +114,14 @@ class StockReconciliation(StockController):
) )
if item_details.has_batch_no: if item_details.has_batch_no:
batch_nos_details = get_available_batch_nos(item.item_code, item.warehouse) batch_nos_details = get_auto_batch_nos(
frappe._dict(
{
"item_code": item.item_code,
"warehouse": item.warehouse,
}
)
)
for batch_no, qty in batch_nos_details.items(): for batch_no, qty in batch_nos_details.items():
serial_and_batch_bundle.append( serial_and_batch_bundle.append(

View File

@ -38,10 +38,11 @@
"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", "section_break_plhx",
"disable_serial_no_and_batch_selector", "disable_serial_no_and_batch_selector",
"column_break_mhzc",
"use_naming_series", "use_naming_series",
"naming_series_prefix", "naming_series_prefix",
"stock_planning_tab", "stock_planning_tab",
@ -149,22 +150,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 +361,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 +391,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
@ -1089,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)
@ -1176,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
@ -1395,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):

View File

@ -131,7 +131,7 @@ def get_stock_ledger_entries_for_batch_bundle(filters):
& (sle.has_batch_no == 1) & (sle.has_batch_no == 1)
& (sle.posting_date <= filters["to_date"]) & (sle.posting_date <= filters["to_date"])
) )
.groupby(batch_package.batch_no) .groupby(batch_package.batch_no, batch_package.warehouse)
.orderby(sle.item_code, sle.warehouse) .orderby(sle.item_code, sle.warehouse)
) )

View File

@ -49,103 +49,64 @@ class SerialBatchBundle:
if ( if (
not self.sle.is_cancelled not self.sle.is_cancelled
and not self.sle.serial_and_batch_bundle and not self.sle.serial_and_batch_bundle
and self.sle.actual_qty > 0
and self.item_details.has_serial_no == 1 and self.item_details.has_serial_no == 1
and self.item_details.serial_no_series
and self.allow_to_make_auto_bundle()
): ):
self.make_serial_batch_no_bundle() self.make_serial_batch_no_bundle()
elif not self.sle.is_cancelled: elif not self.sle.is_cancelled:
self.validate_item_and_warehouse() self.validate_item_and_warehouse()
def auto_create_serial_nos(self, batch_no=None):
sr_nos = []
serial_nos_details = []
for i in range(cint(self.sle.actual_qty)):
serial_no = make_autoname(self.item_details.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_details.item_name,
self.item_details.description,
"Active",
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 make_serial_batch_no_bundle(self): def make_serial_batch_no_bundle(self):
sn_doc = frappe.new_doc("Serial and Batch Bundle") self.validate_item()
sn_doc.item_code = self.item_code
sn_doc.warehouse = self.warehouse
sn_doc.item_name = self.item_details.item_name
sn_doc.item_group = self.item_details.item_group
sn_doc.has_serial_no = self.item_details.has_serial_no
sn_doc.has_batch_no = self.item_details.has_batch_no
sn_doc.voucher_type = self.sle.voucher_type
sn_doc.voucher_no = self.sle.voucher_no
sn_doc.voucher_detail_no = self.sle.voucher_detail_no
sn_doc.total_qty = self.sle.actual_qty
sn_doc.avg_rate = self.sle.incoming_rate
sn_doc.total_amount = flt(self.sle.actual_qty) * flt(self.sle.incoming_rate)
sn_doc.type_of_transaction = "Inward"
sn_doc.posting_date = self.sle.posting_date
sn_doc.posting_time = self.sle.posting_time
sn_doc.is_rejected = self.is_rejected_entry()
sn_doc.flags.ignore_mandatory = True sn_doc = SerialBatchCreation(
sn_doc.insert() {
"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,
"total_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()
batch_no = ""
if self.item_details.has_batch_no:
batch_no = self.create_batch()
incoming_rate = self.sle.incoming_rate
if not incoming_rate:
incoming_rate = frappe.get_cached_value(
self.child_doctype, self.sle.voucher_detail_no, "valuation_rate"
)
if self.item_details.has_serial_no:
sr_nos = self.auto_create_serial_nos(batch_no)
self.add_serial_no_to_bundle(sn_doc, sr_nos, incoming_rate, batch_no)
elif self.item_details.has_batch_no:
self.add_batch_no_to_bundle(sn_doc, batch_no, incoming_rate)
sn_doc.save()
sn_doc.submit()
self.set_serial_and_batch_bundle(sn_doc) self.set_serial_and_batch_bundle(sn_doc)
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): def set_serial_and_batch_bundle(self, sn_doc):
self.sle.db_set("serial_and_batch_bundle", sn_doc.name) self.sle.db_set("serial_and_batch_bundle", sn_doc.name)
@ -169,72 +130,19 @@ class SerialBatchBundle:
def is_rejected_entry(self): def is_rejected_entry(self):
return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse) return is_rejected(self.sle.voucher_type, self.sle.voucher_detail_no, self.sle.warehouse)
def add_serial_no_to_bundle(self, sn_doc, serial_nos, incoming_rate, batch_no=None):
for serial_no in serial_nos:
sn_doc.append(
"entries",
{
"serial_no": serial_no,
"qty": 1,
"incoming_rate": incoming_rate,
"batch_no": batch_no,
"warehouse": self.warehouse,
"is_outward": 0,
},
)
def add_batch_no_to_bundle(self, sn_doc, batch_no, incoming_rate):
stock_value_difference = flt(self.sle.actual_qty) * flt(incoming_rate)
if self.sle.actual_qty < 0:
stock_value_difference *= -1
sn_doc.append(
"entries",
{
"batch_no": batch_no,
"qty": self.sle.actual_qty,
"incoming_rate": incoming_rate,
"stock_value_difference": stock_value_difference,
},
)
def create_batch(self):
from erpnext.stock.doctype.batch.batch import make_batch
return make_batch(
frappe._dict(
{
"item": self.item_code,
"reference_doctype": self.sle.voucher_type,
"reference_name": self.sle.voucher_no,
}
)
)
def process_batch_no(self): def process_batch_no(self):
if ( if (
not self.sle.is_cancelled not self.sle.is_cancelled
and not self.sle.serial_and_batch_bundle and not self.sle.serial_and_batch_bundle
and self.sle.actual_qty > 0
and self.item_details.has_batch_no == 1 and self.item_details.has_batch_no == 1
and self.item_details.create_new_batch and self.item_details.create_new_batch
and self.item_details.batch_number_series and self.item_details.batch_number_series
and self.allow_to_make_auto_bundle()
): ):
self.make_serial_batch_no_bundle() self.make_serial_batch_no_bundle()
elif not self.sle.is_cancelled: elif not self.sle.is_cancelled:
self.validate_item_and_warehouse() self.validate_item_and_warehouse()
def validate_item_and_warehouse(self): def validate_item_and_warehouse(self):
data = frappe.db.get_value(
"Serial and Batch Bundle",
self.sle.serial_and_batch_bundle,
["item_code", "warehouse", "voucher_no", "name"],
as_dict=1,
)
if self.sle.serial_and_batch_bundle and not frappe.db.exists( if self.sle.serial_and_batch_bundle and not frappe.db.exists(
"Serial and Batch Bundle", "Serial and Batch Bundle",
{ {
@ -270,18 +178,6 @@ class SerialBatchBundle:
{"is_cancelled": 1, "voucher_no": ""}, {"is_cancelled": 1, "voucher_no": ""},
) )
def allow_to_make_auto_bundle(self):
if self.sle.voucher_type in ["Stock Entry", "Purchase Receipt", "Purchase Invoice"]:
if self.sle.voucher_type == "Stock Entry":
stock_entry_type = frappe.get_cached_value("Stock Entry", self.sle.voucher_no, "purpose")
if stock_entry_type in ["Material Receipt", "Manufacture", "Repack"]:
return True
return True
return False
def post_process(self): def post_process(self):
if not self.sle.serial_and_batch_bundle: if not self.sle.serial_and_batch_bundle:
return return
@ -296,6 +192,9 @@ class SerialBatchBundle:
): ):
self.set_batch_no_in_serial_nos() self.set_batch_no_in_serial_nos()
if self.item_details.has_batch_no == 1:
self.update_batch_qty()
def set_warehouse_and_status_in_serial_nos(self): def set_warehouse_and_status_in_serial_nos(self):
serial_nos = get_serial_nos(self.sle.serial_and_batch_bundle, check_outward=False) serial_nos = get_serial_nos(self.sle.serial_and_batch_bundle, check_outward=False)
warehouse = self.warehouse if self.sle.actual_qty > 0 else None warehouse = self.warehouse if self.sle.actual_qty > 0 else None
@ -330,6 +229,20 @@ class SerialBatchBundle:
.where(sn_table.name.isin(serial_nos)) .where(sn_table.name.isin(serial_nos))
).run() ).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, qty in batches_qty.items():
frappe.db.set_value("Batch", batch_no, "batch_qty", qty)
def get_serial_nos(serial_and_batch_bundle, check_outward=True): def get_serial_nos(serial_and_batch_bundle, check_outward=True):
filters = {"parent": serial_and_batch_bundle} filters = {"parent": serial_and_batch_bundle}
@ -489,6 +402,7 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
self.batch_avg_rate = defaultdict(float) self.batch_avg_rate = defaultdict(float)
self.available_qty = defaultdict(float) self.available_qty = defaultdict(float)
for ledger in entries: for ledger in entries:
self.batch_avg_rate[ledger.batch_no] += flt(ledger.incoming_rate) / flt(ledger.qty) self.batch_avg_rate[ledger.batch_no] += flt(ledger.incoming_rate) / flt(ledger.qty)
self.available_qty[ledger.batch_no] += flt(ledger.qty) self.available_qty[ledger.batch_no] += flt(ledger.qty)
@ -502,11 +416,13 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
batch_nos = list(self.batch_nos.keys()) batch_nos = list(self.batch_nos.keys())
timestamp_condition = ""
if self.sle.posting_date and self.sle.posting_time:
timestamp_condition = CombineDatetime( timestamp_condition = CombineDatetime(
parent.posting_date, parent.posting_time parent.posting_date, parent.posting_time
) < CombineDatetime(self.sle.posting_date, self.sle.posting_time) ) < CombineDatetime(self.sle.posting_date, self.sle.posting_time)
return ( query = (
frappe.qb.from_(parent) frappe.qb.from_(parent)
.inner_join(child) .inner_join(child)
.on(parent.name == child.parent) .on(parent.name == child.parent)
@ -524,21 +440,19 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
& (parent.is_cancelled == 0) & (parent.is_cancelled == 0)
& (parent.type_of_transaction != "Maintenance") & (parent.type_of_transaction != "Maintenance")
) )
.where(timestamp_condition)
.groupby(child.batch_no) .groupby(child.batch_no)
).run(as_dict=True) )
if timestamp_condition:
query.where(timestamp_condition)
return query.run(as_dict=True)
def get_batch_nos(self) -> list: def get_batch_nos(self) -> list:
if self.sle.get("batch_nos"): if self.sle.get("batch_nos"):
return self.sle.batch_nos return self.sle.batch_nos
entries = frappe.get_all( return get_batch_nos(self.sle.serial_and_batch_bundle)
"Serial and Batch Entry",
fields=["batch_no", "qty", "name"],
filters={"parent": self.sle.serial_and_batch_bundle, "is_outward": 1},
)
return {d.batch_no: d for d in entries}
def set_stock_value_difference(self): def set_stock_value_difference(self):
self.stock_value_change = 0 self.stock_value_change = 0
@ -566,6 +480,16 @@ class BatchNoValuation(DeprecatedBatchNoValuation):
return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty)) return abs(flt(self.stock_value_change) / flt(self.sle.actual_qty))
def get_batch_nos(serial_and_batch_bundle):
entries = frappe.get_all(
"Serial and Batch Entry",
fields=["batch_no", "qty", "name"],
filters={"parent": serial_and_batch_bundle, "is_outward": 1},
)
return {d.batch_no: d for d in entries}
def get_empty_batches_based_work_order(work_order, item_code): def get_empty_batches_based_work_order(work_order, item_code):
batches = get_batches_from_work_order(work_order, item_code) batches = get_batches_from_work_order(work_order, item_code)
if not batches: if not batches:
@ -631,8 +555,35 @@ def set_batch_details_from_package(ids, batches):
class SerialBatchCreation: class SerialBatchCreation:
def __init__(self, args): def __init__(self, args):
self.set(args)
self.set_item_details()
def set(self, args):
self.__dict__ = {}
for key, value in args.items(): for key, value in args.items():
setattr(self, key, value) 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 duplicate_package(self): def duplicate_package(self):
if not self.serial_and_batch_bundle: if not self.serial_and_batch_bundle:
@ -643,7 +594,167 @@ class SerialBatchCreation:
new_package = frappe.copy_doc(package) new_package = frappe.copy_doc(package)
new_package.type_of_transaction = self.type_of_transaction new_package.type_of_transaction = self.type_of_transaction
new_package.returned_against = self.returned_against new_package.returned_against = self.returned_against
print(new_package.voucher_type, new_package.voucher_no)
new_package.save() new_package.save()
self.serial_and_batch_bundle = new_package.name self.serial_and_batch_bundle = new_package.name
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.set_serial_batch_entries(doc)
doc.save()
if not hasattr(self, "do_not_submit") or not self.do_not_submit:
doc.submit()
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.total_qty),
"based_on": frappe.db.get_single_value("Stock Settings", "pick_serial_and_batch_based_on"),
}
)
if self.has_serial_no and not self.get("serial_nos"):
self.serial_nos = get_serial_nos_for_outward(kwargs)
elif self.has_batch_no and not self.get("batches"):
self.batches = get_available_batches(kwargs)
def set_auto_serial_batch_entries_for_inward(self):
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.total_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"),
},
)
if 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.item_code,
"reference_doctype": self.voucher_type,
"reference_name": self.voucher_no,
}
)
)
def get_auto_created_serial_nos(self):
sr_nos = []
serial_nos_details = []
for i in range(abs(cint(self.total_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