2d8363a983
* feat: add `Has Serial No` and `Has Batch No` fields in SRE * chore: set `Has Serial No` and `Has Batch No` while creating SRE * feat: add field `Reserved Serial and Batch` in SRE * fix(ux): hide `Amend` button on cancelled SRE * fix: add validation for SRE amended doc * fix(ux): hide `Reserved Serial and Batch` Table for non-serial/batch item * fix(ux): set `Display Depends On` for `Has Serial No` and `Has Batch No` in SRE * fix(ux): make `serial_no` and `batch_no` fields read-only based on `has_serial_no` and `has_batch_no` * chore: remove table `Serial and Batch Entry` fieldlabel * fix(ux): set warehouse for new row * fix(ux): make qty field read-only for serial item * fix(ux): set rows qty to `1` before making the field read-only * chore: add filters for serial no * chore: add filters for batch no * chore: don't show Serial NO if already selected * chore: hide rate related fields * feat: add field `Reservation Based On` in SRE * chore: make `Reserved Qty` field editable in SCR * chore: add method to get total reserved qty against a voucher * fix: add validation for `Reserved Qty` * fix: update SRE status and Voucher's Reserved Qty * chore: enable `Track Changes` in SRE * fix: add validation to prevent delivered SRE to get updated * fix(ux): make fields `Reserved Qty` and `Reservation Based On` read-only for delivered SRE * fix: consider voucher's delivered qty while calculating max reserved qty * fix: add UOM validation for SRE Reserved Qty * fix: SRE warehouse mismatch error in DN * fix: auto cancel SRE on update if item is fully delivered for the SO * fix: skip SRE creation for group warehouse * feat: add `Set Warehouse` field in SO stock reservation dialog * fix(ux): hide `Add Row` button in SO stock reservation dialog * fix: group warehouse validation in SO * fix(ux): don't show Batch No if already selected * feat: add field `Auto Reserve Serial and Batch Nos` in `Stock Settings` * refactor: SRE reserved qty validation * feat: auto serial and batch reservation * chore: add section for `Serial and Batch Reservation` in `Stock Settings` * fix: make SRE sb_entries warehouse mandatory * fix(ux): unreserved qty calculation * fix: add validation for `Reserved Qty` against `Batch` * refactor: combine `get_available_qty_to_reserve()` and `get_available_qty_to_reserve_batch()` * fix: validate disabled batch * fix: add validation to validate serial nos availability * fix: update row qty if `Partial Reservation` is enabled * fix: ignore reserved serial nos while getting available serial nos * fix: add validation to prevent repeat batches * fix(ux): add validation for duplicate Serial No * fix: don't allow to update SRE with delivered stock * fix: ignore reserved serial and batch if reservation based on is not Serial and Batch * fix(ux): stock un-reservation confirmation before `Update Items` * chore: return list instead os set * feat: add field `Delivered Qty` in `Serial and Batch Entry` * feat: option to get SO reserved stock in Delivery Note * fix: ignore reserved batches while getting available batches * chore: `conflicts` * fix: incorrect available qty * fix: 'str' object has no attribute 'nodes_' * fix: `linter` * fix(ux): hide `Get Items From > Stock Reservation` if Stock Reservation is disabled * fix(ux): add `depends_on` for `Auto Reserve Serial and Batch Nos` * fix(ux): hide Stock Reservation field description in submitted SO * fix(ux): confirm before unreserve stock * feat: option to create DN for reserved stock from SO * fix: update delivered qty in SRE sb_entries * fix: Delivery Note (Reserved Stock) based on Delivery Date * fix(ux): SO `Update Items` confirmation on `Update` button click * feat: add dialog box to select SRE to unreserve * fix: `ZeroDivisionError` while saving the DN (Reserved Stock) * fix: don't allow to create Pick List if stock is reserved against SO * fix(ux): hide Create > Pick List button for SO with reserved stock * refactor: map reserved stock by default in DN * refactor: code cleanup and comments * fix: don't allow Stock Reservation against SO having Pick List * refactor: `create_stock_reservation_entries()` * feat: add fields to hold Pick List ref in SRE * feat: add field `Stock Reserved Qty` in Pick List Item * feat: provision to reserve stock from Pick List against Sales Order * fix: don't allow to update SRE if created against a Pick List * fix(ux): confirm before unreserve stock in Pick List * fix: don't allow to update Pick List having reserved stock * fix: circular dependency while cancelling the DN created from Pick List with Reserved Stock * chore: update `Max Reserve Qty` err msg to be more descriptive * refactor: rename field `Reserve Stock on Sales Order Submission` * fix: msg on partial reservation if disabled in stock settings * chore: add field description for `Enable Stock Reservation` * fix(test): `test_stock_reservation_against_sales_order` * fix(test): `test_stock_reservation_against_sales_order` * test: add test cases for serial and batch reservation * fix: batch stock levels qty * refactor: method `get_sre_reserved_qty_for_item_and_warehouse` * feat: show `Reserved Stock` in item master stock levels * feat: Reserved Stock Report * fix(ux): SO stock reservation dialogs width * refactor: get previous values from `_doc_before_save` instead of db * fix(ux): make `Reservation Based On` read-only if created against Pick List * feat: option to open `Reserved Stock` report from Sales Order * fix(ux): Sales Order - Reserve and Unreserve dialog box * fix: decrease SRE Delivered Qty on DN cancel * fix(ux): hide `Unreserve` button once reserved stock is delivered * chore: `linter` * fix(test): `test_reserved_stock_report` * test: add test case for DN cancellation * chore: rename field `Auto Reserve Stock on Sales Order Submission` * fix: `Insufficient Stock` error msg
978 lines
28 KiB
Python
978 lines
28 KiB
Python
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, get_link_to_form, 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):
|
|
link = get_link_to_form("Serial and Batch Bundle", sn_doc.name)
|
|
|
|
condition = {
|
|
"Inward": self.sle.actual_qty > 0,
|
|
"Outward": self.sle.actual_qty < 0,
|
|
}.get(sn_doc.type_of_transaction)
|
|
|
|
if not condition:
|
|
correct_type = "Inward"
|
|
if sn_doc.type_of_transaction == "Inward":
|
|
correct_type = "Outward"
|
|
|
|
msg = f"The type of transaction of Serial and Batch Bundle {link} is {bold(sn_doc.type_of_transaction)} but as per the Actual Qty {self.sle.actual_qty} for the item {bold(self.sle.item_code)} in the {self.sle.voucher_type} {self.sle.voucher_no} the type of transaction should be {bold(correct_type)}"
|
|
frappe.throw(_(msg), title=_("Incorrect Type of Transaction"))
|
|
|
|
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 {link} 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):
|
|
# For print format
|
|
|
|
bundle_data = frappe.get_cached_value(
|
|
"Serial and Batch Bundle", bundle, ["has_serial_no", "has_batch_no"], as_dict=True
|
|
)
|
|
|
|
fields = []
|
|
if bundle_data.has_serial_no:
|
|
fields.append("serial_no")
|
|
|
|
if bundle_data.has_batch_no:
|
|
fields.extend(["batch_no", "qty"])
|
|
|
|
data = frappe.get_all("Serial and Batch Entry", fields=fields, filters={"parent": bundle})
|
|
|
|
if bundle_data.has_serial_no and not bundle_data.has_batch_no:
|
|
return ", ".join([d.serial_no for d in data])
|
|
|
|
elif bundle_data.has_batch_no:
|
|
html = "<table class= 'table table-borderless' style='margin-top: 0px;margin-bottom: 0px;'>"
|
|
for d in data:
|
|
if d.serial_no:
|
|
html += f"<tr><td>{d.batch_no}</th><th>{d.serial_no}</th ><th>{abs(d.qty)}</th></tr>"
|
|
else:
|
|
html += f"<tr><td>{d.batch_no}</td><td>{abs(d.qty)}</td></tr>"
|
|
|
|
html += "</table>"
|
|
|
|
return html
|
|
|
|
|
|
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()
|
|
self.validate_qty(doc)
|
|
|
|
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 validate_qty(self, doc):
|
|
if doc.type_of_transaction == "Outward":
|
|
precision = doc.precision("total_qty")
|
|
|
|
total_qty = abs(flt(doc.total_qty, precision))
|
|
required_qty = abs(flt(self.actual_qty, precision))
|
|
|
|
if required_qty - total_qty > 0:
|
|
msg = f"For the item {bold(doc.item_code)}, the Avaliable qty {bold(total_qty)} is less than the Required Qty {bold(required_qty)} in the warehouse {bold(doc.warehouse)}. Please add sufficient qty in the warehouse."
|
|
frappe.throw(msg, title=_("Insufficient Stock"))
|
|
|
|
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 = 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 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
|
|
|
|
|
|
def get_serial_nos_batch(serial_nos):
|
|
return frappe._dict(
|
|
frappe.get_all(
|
|
"Serial No",
|
|
fields=["name", "batch_no"],
|
|
filters={"name": ("in", serial_nos)},
|
|
as_list=1,
|
|
)
|
|
)
|