feat: serial and batch bundle for POS

This commit is contained in:
Rohit Waghchaure 2023-03-23 15:13:45 +05:30
parent 467046436b
commit 0eaf6de5de
8 changed files with 178 additions and 188 deletions

View File

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

View File

@ -184,6 +184,8 @@ class POSInvoiceMergeLog(Document):
item.base_amount = item.base_net_amount
item.price_list_rate = 0
si_item = map_child_doc(item, invoice, {"doctype": "Sales Invoice Item"})
if item.serial_and_batch_bundle:
si_item.serial_and_batch_bundle = item.serial_and_batch_bundle
items.append(si_item)
for tax in doc.get("taxes"):

View File

@ -408,6 +408,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
{
"type_of_transaction": type_of_transaction,
"serial_and_batch_bundle": source_doc.serial_and_batch_bundle,
"returned_against": source_doc.name,
}
)
@ -429,6 +430,7 @@ def make_return_doc(doctype: str, source_name: str, target_doc=None):
{
"type_of_transaction": type_of_transaction,
"serial_and_batch_bundle": source_doc.rejected_serial_and_batch_bundle,
"returned_against": source_doc.name,
}
)

View File

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

View File

@ -31,6 +31,7 @@
"column_break_aouy",
"posting_date",
"posting_time",
"returned_against",
"section_break_wzou",
"is_cancelled",
"is_rejected",
@ -232,12 +233,18 @@
"fieldtype": "Table",
"options": "Serial and Batch Entry",
"reqd": 1
},
{
"fieldname": "returned_against",
"fieldtype": "Data",
"label": "Returned Against",
"read_only": 1
}
],
"index_web_pages_for_search": 1,
"is_submittable": 1,
"links": [],
"modified": "2023-03-22 18:56:37.035516",
"modified": "2023-03-23 13:39:17.843812",
"modified_by": "Administrator",
"module": "Stock",
"name": "Serial and Batch Bundle",

View File

@ -2,6 +2,7 @@
# For license information, please see license.txt
import collections
from collections import defaultdict
from typing import Dict, List
import frappe
@ -31,10 +32,10 @@ class SerialandBatchBundle(Document):
self.check_future_entries_exists()
self.validate_serial_nos_inventory()
self.set_is_outward()
self.validate_qty_and_stock_value_difference()
self.calculate_qty_and_amount()
self.set_warehouse()
self.set_incoming_rate()
self.validate_qty_and_stock_value_difference()
def validate_serial_nos_inventory(self):
if not (self.has_serial_no and self.type_of_transaction == "Outward"):
@ -100,7 +101,7 @@ class SerialandBatchBundle(Document):
d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0))
else:
d.incoming_rate = abs(sn_obj.batch_avg_rate.get(d.batch_no))
available_qty = flt(sn_obj.batch_available_qty.get(d.batch_no)) + flt(d.qty)
available_qty = flt(sn_obj.available_qty.get(d.batch_no)) + flt(d.qty)
self.validate_negative_batch(d.batch_no, available_qty)
@ -417,6 +418,7 @@ class SerialandBatchBundle(Document):
frappe.throw(_(msg))
def on_trash(self):
self.validate_voucher_no_docstatus()
self.delink_refernce_from_voucher()
self.delink_reference_from_batch()
self.clear_table()
@ -439,25 +441,48 @@ def item_query(doctype, txt, searchfield, start, page_len, filters, as_dict=Fals
@frappe.whitelist()
def get_serial_batch_ledgers(item_code, voucher_no, name=None):
def get_serial_batch_ledgers(item_code, docstatus=None, voucher_no=None, name=None):
filters = get_filters_for_bundle(item_code, docstatus=docstatus, voucher_no=voucher_no, name=name)
return frappe.get_all(
"Serial and Batch Bundle",
fields=[
"`tabSerial and Batch Entry`.`name`",
"`tabSerial and Batch Bundle`.`name`",
"`tabSerial and Batch Entry`.`qty`",
"`tabSerial and Batch Entry`.`warehouse`",
"`tabSerial and Batch Entry`.`batch_no`",
"`tabSerial and Batch Entry`.`serial_no`",
],
filters=[
["Serial and Batch Bundle", "item_code", "=", item_code],
["Serial and Batch Entry", "parent", "=", name],
["Serial and Batch Bundle", "voucher_no", "=", voucher_no],
["Serial and Batch Bundle", "docstatus", "!=", 2],
],
filters=filters,
)
def get_filters_for_bundle(item_code, docstatus=None, voucher_no=None, name=None):
filters = [
["Serial and Batch Bundle", "item_code", "=", item_code],
["Serial and Batch Bundle", "is_cancelled", "=", 0],
]
if not docstatus:
docstatus = [0, 1]
if isinstance(docstatus, list):
filters.append(["Serial and Batch Bundle", "docstatus", "in", docstatus])
else:
filters.append(["Serial and Batch Bundle", "docstatus", "=", docstatus])
if voucher_no:
filters.append(["Serial and Batch Bundle", "voucher_no", "=", voucher_no])
if name:
if isinstance(name, list):
filters.append(["Serial and Batch Entry", "parent", "in", name])
else:
filters.append(["Serial and Batch Entry", "parent", "=", name])
return filters
@frappe.whitelist()
def add_serial_batch_ledgers(entries, child_row, doc) -> object:
if isinstance(child_row, str):
@ -603,15 +628,52 @@ def get_auto_serial_nos(kwargs):
elif kwargs.based_on == "Expiry":
order_by = "amc_expiry_date asc"
ignore_serial_nos = get_reserved_serial_nos_for_pos(kwargs)
return frappe.get_all(
"Serial No",
fields=fields,
filters={"item_code": kwargs.item_code, "warehouse": kwargs.warehouse},
filters={
"item_code": kwargs.item_code,
"warehouse": kwargs.warehouse,
"name": ("not in", ignore_serial_nos),
},
limit=cint(kwargs.qty),
order_by=order_by,
)
def get_reserved_serial_nos_for_pos(kwargs):
from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
ignore_serial_nos = []
pos_invoices = frappe.get_all(
"POS Invoice",
fields=["`tabPOS Invoice Item`.serial_no", "`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):
ignore_serial_nos.append(d.serial_no)
# Will be deprecated in v16
for pos_invoice in pos_invoices:
if pos_invoice.serial_no:
ignore_serial_nos.extend(get_serial_nos(pos_invoice.serial_no))
return ignore_serial_nos
def get_auto_batch_nos(kwargs):
available_batches = get_available_batches(kwargs)
@ -619,6 +681,10 @@ def get_auto_batch_nos(kwargs):
batches = []
reserved_batches = get_reserved_batches_for_pos(kwargs)
if reserved_batches:
remove_batches_reserved_for_pos(available_batches, reserved_batches)
for batch in available_batches:
if qty > 0:
batch_qty = flt(batch.qty)
@ -642,6 +708,51 @@ def get_auto_batch_nos(kwargs):
return batches
def get_reserved_batches_for_pos(kwargs):
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:
if batch.batch_no in reserved_batches:
available_batches[batch.batch_no] -= reserved_batches[batch.batch_no]
def get_available_batches(kwargs):
stock_ledger_entry = frappe.qb.DocType("Stock Ledger Entry")
batch_ledger = frappe.qb.DocType("Serial and Batch Entry")
@ -655,9 +766,7 @@ def get_available_batches(kwargs):
.on(batch_ledger.batch_no == batch_table.name)
.select(
batch_ledger.batch_no,
Sum(
Case().when(stock_ledger_entry.actual_qty > 0, batch_ledger.qty).else_(batch_ledger.qty * -1)
).as_("qty"),
Sum(batch_ledger.qty).as_("qty"),
)
.where(
(stock_ledger_entry.item_code == kwargs.item_code)
@ -699,7 +808,7 @@ def get_voucher_wise_serial_batch_from_bundle(**kwargs) -> Dict[str, Dict]:
if key not in group_by_voucher:
group_by_voucher.setdefault(
key,
frappe._dict({"serial_nos": [], "batch_nos": collections.defaultdict(float), "item_row": row}),
frappe._dict({"serial_nos": [], "batch_nos": defaultdict(float), "item_row": row}),
)
child_row = group_by_voucher[key]
@ -771,7 +880,7 @@ def get_available_serial_nos(item_code, warehouse):
def get_available_batch_nos(item_code, warehouse):
sl_entries = get_stock_ledger_entries(item_code, warehouse)
batchwise_qty = collections.defaultdict(float)
batchwise_qty = defaultdict(float)
precision = frappe.get_precision("Stock Ledger Entry", "qty")
for entry in sl_entries:

View File

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

View File

@ -642,6 +642,8 @@ class SerialBatchCreation:
package = frappe.get_doc("Serial and Batch Bundle", id)
new_package = frappe.copy_doc(package)
new_package.type_of_transaction = self.type_of_transaction
new_package.returned_against = self.returned_against
print(new_package.voucher_type, new_package.voucher_no)
new_package.save()
self.serial_and_batch_bundle = new_package.name