diff --git a/tailbone/static/js/tailbone.buefy.autocomplete.js b/tailbone/static/js/tailbone.buefy.autocomplete.js index fc64a073..0d61ca79 100644 --- a/tailbone/static/js/tailbone.buefy.autocomplete.js +++ b/tailbone/static/js/tailbone.buefy.autocomplete.js @@ -8,6 +8,8 @@ const TailboneAutocomplete = { serviceUrl: String, value: String, initialLabel: String, + assignedValue: String, + assignedLabel: String, }, data() { @@ -43,7 +45,7 @@ const TailboneAutocomplete = { this.value = null if (focus) { this.$nextTick(function() { - this.$refs.autocomplete.focus() + this.focus() }) } @@ -51,6 +53,10 @@ const TailboneAutocomplete = { // $('#' + oid + '-textbox').trigger('autocompletevaluecleared'); }, + focus() { + this.$refs.autocomplete.focus() + }, + getDisplayText() { if (this.selected) { return this.selected.label diff --git a/tailbone/templates/autocomplete.mako b/tailbone/templates/autocomplete.mako index c9de4507..0ab9f49c 100644 --- a/tailbone/templates/autocomplete.mako +++ b/tailbone/templates/autocomplete.mako @@ -64,7 +64,7 @@ - - {{ selected.label }} (click to change) + {{ assignedLabel || selected.label }} (click to change) diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako index 24245b7a..88c902e9 100644 --- a/tailbone/templates/custorders/create.mako +++ b/tailbone/templates/custorders/create.mako @@ -22,24 +22,32 @@ <%def name="order_form_buttons()"> -
- - Submit this Order - - - Start Over Entirely - - - Cancel this Order - +
+
+
+
+
+
+ + Submit this Order + + + Start Over Entirely + + + Cancel this Order + +
+
+
@@ -163,12 +171,157 @@ ## (for now we just always show caret-right instead) icon="caret-right"> - Items +
- TODO: items go here + + Add Item + + +
+
+ + + + + +
+ + Product is already in the system. + +
+ +
+ + + + + + + + + + + + + + Fetch + + + {{ productUPC }} (click to change) + + + + +
+ +
+ + Product is not yet in the system. + +
+ +
+ + + + + + + + + + + + + + +
+ +
+ + Cancel + + + {{ itemDialogSaveButtonText }} + +
+ +
+
+
+ + + +
@@ -191,10 +344,20 @@ const CustomerOrderCreator = { template: '#customer-order-creator-template', data() { + + ## TODO: these should come from handler + let defaultUnitChoices = [ + {key: '${enum.UNIT_OF_MEASURE_EACH}', value: "Each"}, + {key: '${enum.UNIT_OF_MEASURE_POUND}', value: "Pound"}, + {key: '${enum.UNIT_OF_MEASURE_CASE}', value: "Case"}, + ] + let defaultUOM = '${enum.UNIT_OF_MEASURE_CASE}' + return { batchAction: null, + batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n}, - customerPanelOpen: true, + customerPanelOpen: false, customerIsKnown: true, customerUUID: ${json.dumps(batch.customer_uuid)|n}, customerDisplay: ${json.dumps(six.text_type(batch.customer or ''))|n}, @@ -204,6 +367,20 @@ customerName: null, phoneNumber: null, + items: ${json.dumps(order_items)|n}, + editingItem: null, + showingItemDialog: false, + productIsKnown: true, + productUUID: null, + productDisplay: null, + productUPC: null, + productQuantity: null, + defaultUnitChoices: defaultUnitChoices, + productUnitChoices: defaultUnitChoices, + defaultUOM: defaultUOM, + productUOM: defaultUOM, + productCaseSize: null, + ## TODO: should find a better way to handle CSRF token csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}, } @@ -301,9 +478,28 @@ return { type: null, - text: "Everything seems to be okay here.", + text: "Customer info looks okay.", } }, + + itemsPanelHeader() { + let text = "Items" + + if (this.items.length) { + text = "Items: " + this.items.length.toString() + " for " + this.batchTotalPriceDisplay + } + + return text + }, + + itemDialogSaveButtonText() { + return this.editingItem ? "Update Item" : "Add Item" + }, + }, + mounted() { + if (this.customerStatusType) { + this.customerPanelOpen = true + } }, methods: { @@ -369,6 +565,12 @@ if (callback) { callback(response) } + }, response => { + this.$buefy.toast.open({ + message: "Unexpected error occurred", + type: 'is-danger', + duration: 2000, // 2 seconds + }) }) }, @@ -385,7 +587,24 @@ }, submitOrder() { - alert("okay then!") + let params = { + action: 'submit_new_order', + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Submit failed: " + response.data.error, + type: 'is-danger', + duration: 2000, // 2 seconds + }) + } else { + if (response.data.next_url) { + location.href = response.data.next_url + } else { + location.reload() + } + } + }) }, customerChanged(uuid) { @@ -414,6 +633,155 @@ this.setCustomerData() } }, + + showAddItemDialog() { + this.editingItem = null + this.productIsKnown = true + this.productUUID = null + this.productDisplay = null + this.productUPC = null + this.productQuantity = 1 + this.productUnitChoices = this.defaultUnitChoices + this.productUOM = this.defaultUOM + this.showingItemDialog = true + this.$nextTick(() => { + this.$refs.productDescriptionAutocomplete.focus() + }) + }, + + showEditItemDialog(index) { + row = this.items[index] + this.editingItem = row + this.productIsKnown = true // TODO + this.productUUID = row.product_uuid + this.productDisplay = row.product_full_description + this.productUPC = row.product_upc_pretty || row.product_upc + this.productQuantity = row.order_quantity + this.productUnitChoices = row.order_uom_choices + this.productUOM = row.order_uom + + this.showingItemDialog = true + }, + + deleteItem(index) { + if (!confirm("Are you sure you want to delete this item?")) { + return + } + + let params = { + action: 'delete_item', + uuid: this.items[index].uuid, + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Delete failed: " + response.data.error, + type: 'is-warning', + duration: 2000, // 2 seconds + }) + } else { + this.items.splice(index, 1) + this.batchTotalPriceDisplay = response.data.batch.total_price_display + } + }) + }, + + clearProduct(autofocus) { + this.productUUID = null + this.productDisplay = null + this.productUPC = null + this.productUnitChoices = this.defaultUnitChoices + if (autofocus) { + this.$nextTick(() => { + this.$refs.productUPCInput.focus() + }) + } + }, + + fetchProductByUPC() { + let params = { + action: 'find_product_by_upc', + upc: this.productUPC, + } + this.submitBatchData(params, response => { + if (response.data.error) { + this.$buefy.toast.open({ + message: "Fetch failed: " + response.data.error, + type: 'is-warning', + duration: 2000, // 2 seconds + }) + } else { + this.productUUID = response.data.uuid + this.productUPC = response.data.upc_pretty + this.productDisplay = response.data.full_description + } + }) + }, + + productChanged(uuid) { + if (uuid) { + this.productUUID = uuid + let params = { + action: 'get_product_info', + uuid: this.productUUID, + } + this.submitBatchData(params, response => { + this.productUPC = response.data.upc_pretty + this.productDisplay = response.data.full_description + this.productUnitChoices = response.data.uom_choices + + let found = false + for (let uom of this.productUnitChoices) { + if (this.productUOM == uom.key) { + found = true + break + } + } + if (!found) { + this.productUOM = this.productUnitChoices[0].key + } + }) + } else { + this.clearProduct() + } + }, + + itemDialogSave() { + + let params = { + product_is_known: this.productIsKnown, + product_uuid: this.productUUID, + order_quantity: this.productQuantity, + order_uom: this.productUOM, + } + + if (this.editingItem) { + params.action = 'update_item' + params.uuid = this.editingItem.uuid + } else { + params.action = 'add_item' + } + + this.submitBatchData(params, response => { + + if (params.action == 'add_item') { + this.items.push(response.data.row) + + } else { // update_item + // must update each value separately, instead of + // overwriting the item record, or else display will + // not update properly + for (let [key, value] of Object.entries(response.data.row)) { + this.editingItem[key] = value + } + } + + // also update the batch total price + this.batchTotalPriceDisplay = response.data.batch.total_price_display + + this.showingItemDialog = false + }) + }, }, } diff --git a/tailbone/views/custorders/batch.py b/tailbone/views/custorders/batch.py index c8b6280f..bfbb5c02 100644 --- a/tailbone/views/custorders/batch.py +++ b/tailbone/views/custorders/batch.py @@ -48,7 +48,8 @@ class CustomerOrderBatchView(BatchMasterView): grid_columns = [ 'id', 'customer', - 'rows', + 'rowcount', + 'total_price', 'created', 'created_by', ] @@ -61,13 +62,35 @@ class CustomerOrderBatchView(BatchMasterView): 'email_address', 'created', 'created_by', - 'rows', + 'rowcount', + 'total_price', + ] + + row_labels = { + 'product_upc': "UPC", + 'product_brand': "Brand", + 'product_description': "Description", + 'product_size': "Size", + 'order_uom': "Order UOM", + } + + row_grid_columns = [ + 'sequence', + 'product_upc', + 'product_brand', + 'product_description', + 'product_size', + 'order_quantity', + 'order_uom', + 'total_price', 'status_code', ] def configure_grid(self, g): super(CustomerOrderBatchView, self).configure_grid(g) + g.set_type('total_price', 'currency') + g.set_link('customer') g.set_link('created') g.set_link('created_by') @@ -120,3 +143,36 @@ class CustomerOrderBatchView(BatchMasterView): f.set_label('person_uuid', "Person") else: f.set_renderer('person', self.render_person) + + f.set_type('total_price', 'currency') + + def row_grid_extra_class(self, row, i): + if row.status_code == row.STATUS_PRODUCT_NOT_FOUND: + return 'warning' + + def configure_row_grid(self, g): + super(CustomerOrderBatchView, self).configure_row_grid(g) + + g.set_type('case_quantity', 'quantity') + g.set_type('cases_ordered', 'quantity') + g.set_type('units_ordered', 'quantity') + g.set_type('order_quantity', 'quantity') + g.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + g.set_type('unit_price', 'currency') + g.set_type('total_price', 'currency') + + g.set_link('product_upc') + g.set_link('product_description') + + def configure_row_form(self, f): + super(CustomerOrderBatchView, self).configure_row_form(f) + + f.set_renderer('product', self.render_product) + + f.set_type('case_quantity', 'quantity') + f.set_type('cases_ordered', 'quantity') + f.set_type('units_ordered', 'quantity') + f.set_type('order_quantity', 'quantity') + f.set_enum('order_uom', self.enum.UNIT_OF_MEASURE) + f.set_type('unit_price', 'currency') + f.set_type('total_price', 'currency') diff --git a/tailbone/views/custorders/orders.py b/tailbone/views/custorders/orders.py index 9ffc06c8..420ac892 100644 --- a/tailbone/views/custorders/orders.py +++ b/tailbone/views/custorders/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2020 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -26,10 +26,15 @@ Customer Order Views from __future__ import unicode_literals, absolute_import +import decimal + import six from sqlalchemy import orm -from rattail.db import model +from rattail import pod +from rattail.db import api, model +from rattail.util import pretty_quantity +from rattail.batch import get_batch_handler from webhelpers2.html import tags @@ -123,6 +128,10 @@ class CustomerOrdersView(MasterView): submits the order, at which point the batch is converted to a proper order. """ + self.handler = get_batch_handler( + self.rattail_config, 'custorder', + default='rattail.batch.custorder:CustomerOrderBatchHandler') + batch = self.get_current_batch() if self.request.method == 'POST': @@ -142,13 +151,22 @@ class CustomerOrdersView(MasterView): json_actions = [ 'get_customer_info', 'set_customer_data', + 'find_product_by_upc', + 'get_product_info', + 'add_item', + 'update_item', + 'delete_item', 'submit_new_order', ] if action in json_actions: result = getattr(self, action)(batch, data) return self.json_response(result) - context = {'batch': batch} + items = [self.normalize_row(row) + for row in batch.active_rows()] + context = {'batch': batch, + 'normalized_batch': self.normalize_batch(batch), + 'order_items': items} return self.render_to_response(template, context) def get_current_batch(self): @@ -161,13 +179,15 @@ class CustomerOrdersView(MasterView): batch = self.Session.query(model.CustomerOrderBatch)\ .filter(model.CustomerOrderBatch.mode == self.enum.CUSTORDER_BATCH_MODE_CREATING)\ .filter(model.CustomerOrderBatch.created_by == user)\ + .filter(model.CustomerOrderBatch.executed == None)\ .one() except orm.exc.NoResultFound: # no batch yet for this user, so make one - batch = model.CustomerOrderBatch() - batch.mode = self.enum.CUSTORDER_BATCH_MODE_CREATING - batch.created_by = user + + batch = self.handler.make_batch( + self.Session(), created_by=user, + mode=self.enum.CUSTORDER_BATCH_MODE_CREATING) self.Session.add(batch) self.Session.flush() @@ -236,9 +256,220 @@ class CustomerOrdersView(MasterView): self.Session.flush() return {'success': True} + def find_product_by_upc(self, batch, data): + upc = data.get('upc') + if not upc: + return {'error': "Must specify a product UPC"} + + product = api.get_product_by_upc(self.Session(), upc) + if not product: + return {'error': "Product not found"} + + return self.info_for_product(batch, data, product) + + def get_product_info(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + return self.info_for_product(batch, data, product) + + def uom_choices_for_product(self, product): + choices = [] + + # Each + if not product or not product.weighed: + unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_EACH] + choices.append({'key': self.enum.UNIT_OF_MEASURE_EACH, + 'value': unit_name}) + + # Pound + if not product or product.weighed: + unit_name = self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_POUND] + choices.append({ + 'key': self.enum.UNIT_OF_MEASURE_POUND, + 'value': unit_name, + }) + + # Case + case_text = None + if product.case_size is None: + case_text = "{} (× ?? {})".format( + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + unit_name) + elif product.case_size > 1: + case_text = "{} (× {} {})".format( + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + pretty_quantity(product.case_size), + unit_name) + if case_text: + choices.append({'key': self.enum.UNIT_OF_MEASURE_CASE, + 'value': case_text}) + + return choices + + def info_for_product(self, batch, data, product): + return { + 'uuid': product.uuid, + 'upc': six.text_type(product.upc), + 'upc_pretty': product.upc.pretty(), + 'full_description': product.full_description, + 'image_url': pod.get_image_url(self.rattail_config, product.upc), + 'uom_choices': self.uom_choices_for_product(product), + } + + def normalize_batch(self, batch): + return { + 'uuid': batch.uuid, + 'total_price': six.text_type(batch.total_price or 0), + 'total_price_display': "${:0.2f}".format(batch.total_price or 0), + 'status_code': batch.status_code, + 'status_text': batch.status_text, + } + + def normalize_row(self, row): + data = { + 'uuid': row.uuid, + 'sequence': row.sequence, + 'item_entry': row.item_entry, + 'product_uuid': row.product_uuid, + 'product_upc': six.text_type(row.product_upc or ''), + 'product_upc_pretty': row.product_upc.pretty() if row.product_upc else None, + 'product_brand': row.product_brand, + 'product_description': row.product_description, + 'product_size': row.product_size, + 'product_full_description': row.product.full_description if row.product else row.product_description, + 'product_weighed': row.product_weighed, + + 'case_quantity': pretty_quantity(row.case_quantity), + 'cases_ordered': pretty_quantity(row.cases_ordered), + 'units_ordered': pretty_quantity(row.units_ordered), + 'order_quantity': pretty_quantity(row.order_quantity), + 'order_uom': row.order_uom, + 'order_uom_choices': self.uom_choices_for_product(row.product), + + 'unit_price': six.text_type(row.unit_price) if row.unit_price is not None else None, + 'unit_price_display': "${:0.2f}".format(row.unit_price) if row.unit_price is not None else None, + 'total_price': six.text_type(row.total_price) if row.total_price is not None else None, + 'total_price_display': "${:0.2f}".format(row.total_price) if row.total_price is not None else None, + + 'status_code': row.status_code, + 'status_text': row.status_text, + } + + unit_uom = self.enum.UNIT_OF_MEASURE_POUND if data['product_weighed'] else self.enum.UNIT_OF_MEASURE_EACH + if row.order_uom == self.enum.UNIT_OF_MEASURE_CASE: + data.update({ + 'order_quantity_display': "{} {} (× {} {} = {} {})".format( + data['order_quantity'], + self.enum.UNIT_OF_MEASURE[self.enum.UNIT_OF_MEASURE_CASE], + data['case_quantity'], + self.enum.UNIT_OF_MEASURE[unit_uom], + pretty_quantity(row.order_quantity * row.case_quantity), + self.enum.UNIT_OF_MEASURE[unit_uom]), + }) + else: + data.update({ + 'order_quantity_display': "{} {}".format( + pretty_quantity(row.order_quantity), + self.enum.UNIT_OF_MEASURE[unit_uom]), + }) + + return data + + def add_item(self, batch, data): + if data.get('product_is_known'): + + uuid = data.get('product_uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + row = self.handler.make_row() + row.item_entry = product.uuid + row.product = product + row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + row.order_uom = data.get('order_uom') + self.handler.add_row(batch, row) + self.Session.flush() + self.Session.refresh(row) + + else: # product is not known + raise NotImplementedError # TODO + + return {'batch': self.normalize_batch(batch), + 'row': self.normalize_row(row)} + + def update_item(self, batch, data): + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row UUID"} + + row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + if not row: + return {'error': "Row not found"} + + if row not in batch.active_rows(): + return {'error': "Row is not active for the batch"} + + if data.get('product_is_known'): + + uuid = data.get('product_uuid') + if not uuid: + return {'error': "Must specify a product UUID"} + + product = self.Session.query(model.Product).get(uuid) + if not product: + return {'error': "Product not found"} + + row.item_entry = product.uuid + row.product = product + row.order_quantity = decimal.Decimal(data.get('order_quantity') or '0') + row.order_uom = data.get('order_uom') + self.handler.refresh_row(row) + self.Session.flush() + self.Session.refresh(row) + + else: # product is not known + raise NotImplementedError # TODO + + return {'batch': self.normalize_batch(batch), + 'row': self.normalize_row(row)} + + def delete_item(self, batch, data): + + uuid = data.get('uuid') + if not uuid: + return {'error': "Must specify a row UUID"} + + row = self.Session.query(model.CustomerOrderBatchRow).get(uuid) + if not row: + return {'error': "Row not found"} + + if row not in batch.active_rows(): + return {'error': "Row is not active for this batch"} + + self.handler.do_remove_row(row) + return {'ok': True, + 'batch': self.normalize_batch(batch)} + def submit_new_order(self, batch, data): - # TODO - return {'success': True} + result = self.handler.do_execute(batch, self.request.user) + if not result: + return {'error': "Batch failed to execute"} + + next_url = None + if isinstance(result, model.CustomerOrder): + next_url = self.get_action_url('view', result) + + return {'ok': True, 'next_url': next_url} def includeme(config): diff --git a/tailbone/views/products.py b/tailbone/views/products.py index 34887021..5ec4b9c5 100644 --- a/tailbone/views/products.py +++ b/tailbone/views/products.py @@ -862,6 +862,9 @@ class ProductsView(MasterView): else: f.set_readonly('brand') + # case_size + f.set_type('case_size', 'quantity') + # status_code f.set_label('status_code', "Status")