diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 245697880c5..2d6bef5bca5 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -43,7 +43,6 @@ class POSInvoice(SalesInvoice): self.validate_serialised_or_batched_item() self.validate_stock_availablility() self.validate_return_items_qty() - self.validate_non_stock_items() self.set_status() self.set_account_for_mode_of_payment() self.validate_pos() @@ -176,9 +175,11 @@ class POSInvoice(SalesInvoice): def validate_stock_availablility(self): if self.is_return or self.docstatus != 1: return - allow_negative_stock = frappe.db.get_single_value('Stock Settings', 'allow_negative_stock') for d in self.get('items'): + is_service_item = not (frappe.db.get_value('Item', d.get('item_code'), 'is_stock_item')) + if is_service_item: + return if d.serial_no: self.validate_pos_reserved_serial_nos(d) self.validate_delivered_serial_nos(d) @@ -189,7 +190,7 @@ class POSInvoice(SalesInvoice): if allow_negative_stock: return - available_stock = get_stock_availability(d.item_code, d.warehouse) + available_stock, is_stock_item = get_stock_availability(d.item_code, d.warehouse) item_code, warehouse, qty = frappe.bold(d.item_code), frappe.bold(d.warehouse), frappe.bold(d.qty) if flt(available_stock) <= 0: @@ -260,14 +261,6 @@ class POSInvoice(SalesInvoice): .format(d.idx, bold_serial_no, bold_return_against) ) - def validate_non_stock_items(self): - for d in self.get("items"): - is_stock_item = frappe.get_cached_value("Item", d.get("item_code"), "is_stock_item") - if not is_stock_item: - if not frappe.db.exists('Product Bundle', d.item_code): - frappe.throw(_("Row #{}: Item {} is a non stock item. You can only include stock items in a POS Invoice.") - .format(d.idx, frappe.bold(d.item_code)), title=_("Invalid Item")) - def validate_mode_of_payment(self): if len(self.payments) == 0: frappe.throw(_("At least one mode of payment is required for POS invoice.")) @@ -507,12 +500,18 @@ class POSInvoice(SalesInvoice): @frappe.whitelist() def get_stock_availability(item_code, warehouse): if frappe.db.get_value('Item', item_code, 'is_stock_item'): + is_stock_item = True bin_qty = get_bin_qty(item_code, warehouse) pos_sales_qty = get_pos_reserved_qty(item_code, warehouse) - return bin_qty - pos_sales_qty + return bin_qty - pos_sales_qty, is_stock_item else: + is_stock_item = False if frappe.db.exists('Product Bundle', item_code): - return get_bundle_availability(item_code, warehouse) + return get_bundle_availability(item_code, warehouse), is_stock_item + else: + # Is a service item + return 0, is_stock_item + def get_bundle_availability(bundle_item_code, warehouse): product_bundle = frappe.get_doc('Product Bundle', bundle_item_code) diff --git a/erpnext/controllers/buying_controller.py b/erpnext/controllers/buying_controller.py index a3d2502268e..e5f35e1b72e 100644 --- a/erpnext/controllers/buying_controller.py +++ b/erpnext/controllers/buying_controller.py @@ -70,9 +70,18 @@ class BuyingController(StockController, Subcontracting): # set contact and address details for supplier, if they are not mentioned if getattr(self, "supplier", None): - self.update_if_missing(get_party_details(self.supplier, party_type="Supplier", ignore_permissions=self.flags.ignore_permissions, - doctype=self.doctype, company=self.company, party_address=self.supplier_address, shipping_address=self.get('shipping_address'), - fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'))) + self.update_if_missing( + get_party_details( + self.supplier, + party_type="Supplier", + doctype=self.doctype, + company=self.company, + party_address=self.get("supplier_address"), + shipping_address=self.get('shipping_address'), + fetch_payment_terms_template= not self.get('ignore_default_payment_terms_template'), + ignore_permissions=self.flags.ignore_permissions + ) + ) self.set_missing_item_details(for_validate) diff --git a/erpnext/education/doctype/program_enrollment/program_enrollment.py b/erpnext/education/doctype/program_enrollment/program_enrollment.py index a23d49267e6..4d0f3a98011 100644 --- a/erpnext/education/doctype/program_enrollment/program_enrollment.py +++ b/erpnext/education/doctype/program_enrollment/program_enrollment.py @@ -6,6 +6,7 @@ import frappe from frappe import _, msgprint from frappe.desk.reportview import get_match_cond from frappe.model.document import Document +from frappe.query_builder.functions import Min from frappe.utils import comma_and, get_link_to_form, getdate @@ -60,8 +61,15 @@ class ProgramEnrollment(Document): frappe.throw(_("Student is already enrolled.")) def update_student_joining_date(self): - date = frappe.db.sql("select min(enrollment_date) from `tabProgram Enrollment` where student= %s", self.student) - frappe.db.set_value("Student", self.student, "joining_date", date) + table = frappe.qb.DocType('Program Enrollment') + date = ( + frappe.qb.from_(table) + .select(Min(table.enrollment_date).as_('enrollment_date')) + .where(table.student == self.student) + ).run(as_dict=True) + + if date: + frappe.db.set_value("Student", self.student, "joining_date", date[0].enrollment_date) def make_fee_records(self): from erpnext.education.api import get_fee_components diff --git a/erpnext/hr/doctype/employee/test_employee_reminders.py b/erpnext/hr/doctype/employee/test_employee_reminders.py index bdb51b008a9..a4097ab9d19 100644 --- a/erpnext/hr/doctype/employee/test_employee_reminders.py +++ b/erpnext/hr/doctype/employee/test_employee_reminders.py @@ -207,8 +207,8 @@ class TestEmployeeReminders(unittest.TestCase): # teardown: enable emp 2 frappe.db.set_value('Employee', self.test_employee_2.name, { - 'status': 'Left', - 'holiday_list': self.holiday_list_2 + 'status': 'Active', + 'holiday_list': self.holiday_list_2.name }) def test_advance_holiday_reminders_weekly(self): @@ -232,8 +232,8 @@ class TestEmployeeReminders(unittest.TestCase): # teardown: enable emp 2 frappe.db.set_value('Employee', self.test_employee_2.name, { - 'status': 'Left', - 'holiday_list': self.holiday_list_2 + 'status': 'Active', + 'holiday_list': self.holiday_list_2.name }) def test_reminder_not_sent_if_no_holdays(self): diff --git a/erpnext/selling/page/point_of_sale/point_of_sale.py b/erpnext/selling/page/point_of_sale/point_of_sale.py index 73ac3afb31b..216e35a3903 100644 --- a/erpnext/selling/page/point_of_sale/point_of_sale.py +++ b/erpnext/selling/page/point_of_sale/point_of_sale.py @@ -24,7 +24,7 @@ def search_by_term(search_term, warehouse, price_list): ["name as item_code", "item_name", "description", "stock_uom", "image as item_image", "is_stock_item"], as_dict=1) - item_stock_qty = get_stock_availability(item_code, warehouse) + item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) price_list_rate, currency = frappe.db.get_value('Item Price', { 'price_list': price_list, 'item_code': item_code @@ -99,7 +99,6 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te ), {'warehouse': warehouse}, as_dict=1) if items_data: - items_data = filter_service_items(items_data) items = [d.item_code for d in items_data] item_prices_data = frappe.get_all("Item Price", fields = ["item_code", "price_list_rate", "currency"], @@ -112,7 +111,7 @@ def get_items(start, page_length, price_list, item_group, pos_profile, search_te for item in items_data: item_code = item.item_code item_price = item_prices.get(item_code) or {} - item_stock_qty = get_stock_availability(item_code, warehouse) + item_stock_qty, is_stock_item = get_stock_availability(item_code, warehouse) row = {} row.update(item) @@ -144,14 +143,6 @@ def search_for_serial_or_batch_or_barcode_number(search_value): return {} -def filter_service_items(items): - for item in items: - if not item['is_stock_item']: - if not frappe.db.exists('Product Bundle', item['item_code']): - items.remove(item) - - return items - def get_conditions(search_term): condition = "(" condition += """item.name like {search_term} diff --git a/erpnext/selling/page/point_of_sale/pos_controller.js b/erpnext/selling/page/point_of_sale/pos_controller.js index ce74f6d0a58..ea8459f970b 100644 --- a/erpnext/selling/page/point_of_sale/pos_controller.js +++ b/erpnext/selling/page/point_of_sale/pos_controller.js @@ -248,7 +248,7 @@ erpnext.PointOfSale.Controller = class { numpad_event: (value, action) => this.update_item_field(value, action), - checkout: () => this.payment.checkout(), + checkout: () => this.save_and_checkout(), edit_cart: () => this.payment.edit_cart(), @@ -630,18 +630,24 @@ erpnext.PointOfSale.Controller = class { } async check_stock_availability(item_row, qty_needed, warehouse) { - const available_qty = (await this.get_available_stock(item_row.item_code, warehouse)).message; + const resp = (await this.get_available_stock(item_row.item_code, warehouse)).message; + const available_qty = resp[0]; + const is_stock_item = resp[1]; frappe.dom.unfreeze(); const bold_item_code = item_row.item_code.bold(); const bold_warehouse = warehouse.bold(); const bold_available_qty = available_qty.toString().bold() if (!(available_qty > 0)) { - frappe.model.clear_doc(item_row.doctype, item_row.name); - frappe.throw({ - title: __("Not Available"), - message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) - }) + if (is_stock_item) { + frappe.model.clear_doc(item_row.doctype, item_row.name); + frappe.throw({ + title: __("Not Available"), + message: __('Item Code: {0} is not available under warehouse {1}.', [bold_item_code, bold_warehouse]) + }); + } else { + return; + } } else if (available_qty < qty_needed) { frappe.throw({ message: __('Stock quantity not enough for Item Code: {0} under warehouse {1}. Available quantity {2}.', [bold_item_code, bold_warehouse, bold_available_qty]), @@ -675,8 +681,8 @@ erpnext.PointOfSale.Controller = class { }, callback(res) { if (!me.item_stock_map[item_code]) - me.item_stock_map[item_code] = {} - me.item_stock_map[item_code][warehouse] = res.message; + me.item_stock_map[item_code] = {}; + me.item_stock_map[item_code][warehouse] = res.message[0]; } }); } @@ -707,4 +713,9 @@ erpnext.PointOfSale.Controller = class { }) .catch(e => console.log(e)); } + + async save_and_checkout() { + this.frm.is_dirty() && await this.frm.save(); + this.payment.checkout(); + } }; diff --git a/erpnext/selling/page/point_of_sale/pos_item_cart.js b/erpnext/selling/page/point_of_sale/pos_item_cart.js index 4920584d95e..4a99f068cd5 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_cart.js +++ b/erpnext/selling/page/point_of_sale/pos_item_cart.js @@ -191,10 +191,10 @@ erpnext.PointOfSale.ItemCart = class { this.numpad_value = ''; }); - this.$component.on('click', '.checkout-btn', function() { + this.$component.on('click', '.checkout-btn', async function() { if ($(this).attr('style').indexOf('--blue-500') == -1) return; - me.events.checkout(); + await me.events.checkout(); me.toggle_checkout_btn(false); me.allow_discount_change && me.$add_discount_elem.removeClass("d-none"); @@ -985,6 +985,7 @@ erpnext.PointOfSale.ItemCart = class { $(frm.wrapper).off('refresh-fields'); $(frm.wrapper).on('refresh-fields', () => { if (frm.doc.items.length) { + this.$cart_items_wrapper.html(''); frm.doc.items.forEach(item => { this.update_item_html(item); }); diff --git a/erpnext/selling/page/point_of_sale/pos_item_selector.js b/erpnext/selling/page/point_of_sale/pos_item_selector.js index a30bcd7cf6d..1177615aee9 100644 --- a/erpnext/selling/page/point_of_sale/pos_item_selector.js +++ b/erpnext/selling/page/point_of_sale/pos_item_selector.js @@ -79,14 +79,20 @@ erpnext.PointOfSale.ItemSelector = class { const me = this; // eslint-disable-next-line no-unused-vars const { item_image, serial_no, batch_no, barcode, actual_qty, stock_uom, price_list_rate } = item; - const indicator_color = actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"; const precision = flt(price_list_rate, 2) % 1 != 0 ? 2 : 0; - + let indicator_color; let qty_to_display = actual_qty; - if (Math.round(qty_to_display) > 999) { - qty_to_display = Math.round(qty_to_display)/1000; - qty_to_display = qty_to_display.toFixed(1) + 'K'; + if (item.is_stock_item) { + indicator_color = (actual_qty > 10 ? "green" : actual_qty <= 0 ? "red" : "orange"); + + if (Math.round(qty_to_display) > 999) { + qty_to_display = Math.round(qty_to_display)/1000; + qty_to_display = qty_to_display.toFixed(1) + 'K'; + } + } else { + indicator_color = ''; + qty_to_display = ''; } function get_item_image_html() { diff --git a/erpnext/tests/test_point_of_sale.py b/erpnext/tests/test_point_of_sale.py new file mode 100644 index 00000000000..df2dc8b99a1 --- /dev/null +++ b/erpnext/tests/test_point_of_sale.py @@ -0,0 +1,53 @@ +# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors +# MIT License. See license.txt + + +from erpnext.accounts.doctype.pos_profile.test_pos_profile import make_pos_profile +from erpnext.selling.page.point_of_sale.point_of_sale import get_items +from erpnext.stock.doctype.item.test_item import make_item +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry +from erpnext.tests.utils import ERPNextTestCase + + +class TestPointOfSale(ERPNextTestCase): + def test_item_search(self): + """ + Test Stock and Service Item Search. + """ + + pos_profile = make_pos_profile() + item1 = make_item("Test Search Stock Item", {"is_stock_item": 1}) + make_stock_entry( + item_code="Test Search Stock Item", + qty=10, + to_warehouse="_Test Warehouse - _TC", + rate=500, + ) + + result = get_items( + start=0, + page_length=20, + price_list=None, + item_group=item1.item_group, + pos_profile=pos_profile.name, + search_term="Test Search Stock Item", + ) + filtered_items = result.get("items") + + self.assertEqual(len(filtered_items), 1) + self.assertEqual(filtered_items[0]["item_code"], item1.item_code) + self.assertEqual(filtered_items[0]["actual_qty"], 10) + + item2 = make_item("Test Search Service Item", {"is_stock_item": 0}) + result = get_items( + start=0, + page_length=20, + price_list=None, + item_group=item2.item_group, + pos_profile=pos_profile.name, + search_term="Test Search Service Item", + ) + filtered_items = result.get("items") + + self.assertEqual(len(filtered_items), 1) + self.assertEqual(filtered_items[0]["item_code"], item2.item_code)