diff --git a/docs/conf.py b/docs/conf.py index bb7910f..a5e37a9 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,6 +30,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { 'pyramid': ('https://docs.pylonsproject.org/projects/pyramid/en/latest/', None), + 'python': ('https://docs.python.org/3/', None), 'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None), 'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None), } diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index 6295407..bfa04ea 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -80,6 +80,32 @@ class NewOrderBatchHandler(BatchHandler): return self.config.get_bool('sideshow.orders.allow_unknown_products', default=True) + def allow_item_discounts(self): + """ + Returns boolean indicating whether per-item discounts are + allowed when creating an order. + """ + return self.config.get_bool('sideshow.orders.allow_item_discounts', + default=False) + + def allow_item_discounts_if_on_sale(self): + """ + Returns boolean indicating whether per-item discounts are + allowed even when the item is already on sale. + """ + return self.config.get_bool('sideshow.orders.allow_item_discounts_if_on_sale', + default=False) + + def get_default_item_discount(self): + """ + Returns the default item discount percentage, e.g. 15. + + :rtype: :class:`~python:decimal.Decimal` or ``None`` + """ + discount = self.config.get('sideshow.orders.default_item_discount') + if discount: + return decimal.Decimal(discount) + def autocomplete_customers_external(self, session, term, user=None): """ Return autocomplete search results for :term:`external @@ -430,7 +456,8 @@ class NewOrderBatchHandler(BatchHandler): 'vendor_item_code': product.vendor_item_code, } - def add_item(self, batch, product_info, order_qty, order_uom, user=None): + def add_item(self, batch, product_info, order_qty, order_uom, + discount_percent=None, user=None): """ Add a new item/row to the batch, for given product and quantity. @@ -451,6 +478,10 @@ class NewOrderBatchHandler(BatchHandler): :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` value for the new row. + :param discount_percent: Sets the + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent` + for the row, if allowed. + :param user: :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who is performing the action. This is used to set @@ -518,12 +549,17 @@ class NewOrderBatchHandler(BatchHandler): row.order_qty = order_qty row.order_uom = order_uom + # discount + if self.allow_item_discounts(): + row.discount_percent = discount_percent or 0 + # add row to batch self.add_row(batch, row) session.flush() return row - def update_item(self, row, product_info, order_qty, order_uom, user=None): + def update_item(self, row, product_info, order_qty, order_uom, + discount_percent=None, user=None): """ Update an item/row, per given product and quantity. @@ -544,6 +580,10 @@ class NewOrderBatchHandler(BatchHandler): :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom` value for the row. + :param discount_percent: Sets the + :attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.discount_percent` + for the row, if allowed. + :param user: :class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who is performing the action. This is used to set @@ -608,6 +648,10 @@ class NewOrderBatchHandler(BatchHandler): row.order_qty = order_qty row.order_uom = order_uom + # discount + if self.allow_item_discounts(): + row.discount_percent = discount_percent or 0 + # nb. this may convert float to decimal etc. session.flush() session.refresh(row) @@ -675,12 +719,19 @@ class NewOrderBatchHandler(BatchHandler): # update row total price row.total_price = None if row.order_uom == enum.ORDER_UOM_CASE: + # TODO: why are we not using case price again? + # if row.case_price_quoted: + # row.total_price = row.case_price_quoted * row.order_qty if row.unit_price_quoted is not None and row.case_size is not None: row.total_price = row.unit_price_quoted * row.case_size * row.order_qty else: # ORDER_UOM_UNIT (or similar) if row.unit_price_quoted is not None: row.total_price = row.unit_price_quoted * row.order_qty if row.total_price is not None: + if row.discount_percent and self.allow_item_discounts(): + row.total_price = (float(row.total_price) + * (100 - float(row.discount_percent)) + / 100.0) row.total_price = decimal.Decimal(f'{row.total_price:0.2f}') # update batch if total price changed @@ -971,7 +1022,7 @@ class NewOrderBatchHandler(BatchHandler): 'unit_price_reg', 'unit_price_sale', 'sale_ends', - # 'discount_percent', + 'discount_percent', 'total_price', 'special_order', ] diff --git a/src/sideshow/web/templates/orders/configure.mako b/src/sideshow/web/templates/orders/configure.mako index 10d58fa..e247b2f 100644 --- a/src/sideshow/web/templates/orders/configure.mako +++ b/src/sideshow/web/templates/orders/configure.mako @@ -19,6 +19,36 @@

Products

+ + + Allow per-item discounts + + + + + + Allow discount even if item is on sale + + + +
+
Default item discount
+
+ +
+
%
+
+
- - + ## TODO: needs numeric-input component +
 % @@ -749,6 +749,13 @@ + % if allow_item_discounts: + <${b}-table-column label="Discount" + v-slot="props"> + {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }} + + % endif + <${b}-table-column label="Total" v-slot="props"> @@ -890,6 +897,11 @@ productUOM: defaultUOM, productCaseSize: null, + % if allow_item_discounts: + productDiscountPercent: ${json.dumps(default_item_discount)|n}, + allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, + % endif + pendingProduct: {}, pendingProductRequiredFields: ${json.dumps(pending_product_required_fields)|n}, ## TODO @@ -1011,6 +1023,19 @@ return text }, + % if allow_item_discounts: + + allowItemDiscount() { + if (!this.allowDiscountsIfOnSale) { + if (this.productSalePriceDisplay) { + return false + } + } + return true + }, + + % endif + pendingProductGrossMargin() { let cost = this.pendingProduct.unit_cost let price = this.pendingProduct.unit_price_reg @@ -1324,6 +1349,10 @@ this.productSalePriceDisplay = null this.productSaleEndsDisplay = null this.productUnitChoices = this.defaultUnitChoices + + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif }, productChanged(productID) { @@ -1360,6 +1389,10 @@ this.productSalePriceDisplay = data.unit_price_sale_display this.productSaleEndsDisplay = data.sale_ends_display + % if allow_item_discounts: + this.productDiscountPercent = this.allowItemDiscount ? data.default_item_discount : null + % endif + // this.setProductUnitChoices(data.uom_choices) % if request.use_oruga: @@ -1434,6 +1467,10 @@ this.productUnitChoices = this.defaultUnitChoices this.productUOM = this.defaultUOM + % if allow_item_discounts: + this.productDiscountPercent = ${json.dumps(default_item_discount)|n} + % endif + % if request.use_oruga: this.itemDialogTab = 'product' % else: @@ -1488,6 +1525,10 @@ this.productUnitChoices = row.order_uom_choices this.productUOM = row.order_uom + % if allow_item_discounts: + this.productDiscountPercent = row.discount_percent + % endif + // nb. hack to force refresh for vue3 this.refreshProductDescription += 1 this.refreshTotalPrice += 1 @@ -1538,6 +1579,10 @@ params.product_info = this.pendingProduct } + % if allow_item_discounts: + params.discount_percent = this.productDiscountPercent + % endif + if (this.editItemRow) { params.action = 'update_item' params.uuid = this.editItemRow.uuid diff --git a/src/sideshow/web/views/batch/neworder.py b/src/sideshow/web/views/batch/neworder.py index 5e45da1..fd7fbe3 100644 --- a/src/sideshow/web/views/batch/neworder.py +++ b/src/sideshow/web/views/batch/neworder.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -121,6 +121,7 @@ class NewOrderBatchView(BatchMasterView): 'case_price_quoted', 'order_qty', 'order_uom', + 'discount_percent', 'total_price', 'status_code', ] @@ -167,6 +168,10 @@ class NewOrderBatchView(BatchMasterView): g.set_label('case_price_quoted', "Case Price", column_only=True) g.set_renderer('case_price_quoted', 'currency') + # discount_percent + g.set_renderer('discount_percent', 'percent') + g.set_label('discount_percent', "Disc. %", column_only=True) + # total_price g.set_renderer('total_price', 'currency') diff --git a/src/sideshow/web/views/orders.py b/src/sideshow/web/views/orders.py index cf0c054..9783de7 100644 --- a/src/sideshow/web/views/orders.py +++ b/src/sideshow/web/views/orders.py @@ -2,7 +2,7 @@ ################################################################################ # # Sideshow -- Case/Special Order Tracker -# Copyright © 2024 Lance Edgar +# Copyright © 2024-2025 Lance Edgar # # This file is part of Sideshow. # @@ -135,6 +135,7 @@ class OrderView(MasterView): 'special_order', 'order_qty', 'order_uom', + 'discount_percent', 'total_price', 'status_code', ] @@ -284,10 +285,19 @@ class OrderView(MasterView): for row in batch.rows], 'default_uom_choices': self.get_default_uom_choices(), 'default_uom': None, # TODO? + 'allow_item_discounts': self.batch_handler.allow_item_discounts(), 'allow_unknown_products': (self.batch_handler.allow_unknown_products() and self.has_perm('create_unknown_product')), 'pending_product_required_fields': self.get_pending_product_required_fields(), }) + + if context['allow_item_discounts']: + context['allow_item_discounts_if_on_sale'] = self.batch_handler\ + .allow_item_discounts_if_on_sale() + # nb. render quantity so that '10.0' => '10' + context['default_item_discount'] = self.app.render_quantity( + self.batch_handler.get_default_item_discount()) + return self.render_to_response('create', context) def get_current_batch(self): @@ -564,11 +574,15 @@ class OrderView(MasterView): if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: data['case_price_quoted_display'] = self.app.render_currency(data['case_price_quoted']) + if 'default_item_discount' not in data: + data['default_item_discount'] = self.batch_handler.get_default_item_discount() + decimal_fields = [ 'case_size', 'unit_price_reg', 'unit_price_quoted', 'case_price_quoted', + 'default_item_discount', ] for field in decimal_fields: @@ -589,8 +603,11 @@ class OrderView(MasterView): * :meth:`update_item()` * :meth:`delete_item()` """ + kw = {'user': self.request.user} + if 'discount_percent' in data and self.batch_handler.allow_item_discounts(): + kw['discount_percent'] = data['discount_percent'] row = self.batch_handler.add_item(batch, data['product_info'], - data['order_qty'], data['order_uom']) + data['order_qty'], data['order_uom'], **kw) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -619,8 +636,11 @@ class OrderView(MasterView): if row.batch is not batch: return {'error': "Row is for wrong batch"} + kw = {'user': self.request.user} + if 'discount_percent' in data and self.batch_handler.allow_item_discounts(): + kw['discount_percent'] = data['discount_percent'] self.batch_handler.update_item(row, data['product_info'], - data['order_qty'], data['order_uom']) + data['order_qty'], data['order_uom'], **kw) return {'batch': self.normalize_batch(batch), 'row': self.normalize_row(row)} @@ -715,6 +735,7 @@ class OrderView(MasterView): 'order_qty': float(row.order_qty), 'order_uom': row.order_uom, 'order_uom_choices': self.get_default_uom_choices(), + 'discount_percent': self.app.render_quantity(row.discount_percent), 'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None, 'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted), 'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None, @@ -857,6 +878,10 @@ class OrderView(MasterView): # order_uom #g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM) + # discount_percent + g.set_renderer('discount_percent', 'percent') + g.set_label('discount_percent', "Disc. %", column_only=True) + # total_price g.set_renderer('total_price', g.render_currency) @@ -895,6 +920,12 @@ class OrderView(MasterView): 'default': 'true'}, # products + {'name': 'sideshow.orders.allow_item_discounts', + 'type': bool}, + {'name': 'sideshow.orders.allow_item_discounts_if_on_sale', + 'type': bool}, + {'name': 'sideshow.orders.default_item_discount', + 'type': float}, {'name': 'sideshow.orders.use_local_products', # nb. this is really a bool but we present as string in config UI #'type': bool, diff --git a/tests/batch/test_neworder.py b/tests/batch/test_neworder.py index 42e42dc..56a3efd 100644 --- a/tests/batch/test_neworder.py +++ b/tests/batch/test_neworder.py @@ -20,36 +20,66 @@ class TestNewOrderBatchHandler(DataTestCase): def make_handler(self): return mod.NewOrderBatchHandler(self.config) - def tets_use_local_customers(self): + def test_use_local_customers(self): handler = self.make_handler() # true by default self.assertTrue(handler.use_local_customers()) # config can disable - config.setdefault('sideshow.orders.use_local_customers', 'false') + self.config.setdefault('sideshow.orders.use_local_customers', 'false') self.assertFalse(handler.use_local_customers()) - def tets_use_local_products(self): + def test_use_local_products(self): handler = self.make_handler() # true by default self.assertTrue(handler.use_local_products()) # config can disable - config.setdefault('sideshow.orders.use_local_products', 'false') + self.config.setdefault('sideshow.orders.use_local_products', 'false') self.assertFalse(handler.use_local_products()) - def tets_allow_unknown_products(self): + def test_allow_unknown_products(self): handler = self.make_handler() # true by default self.assertTrue(handler.allow_unknown_products()) # config can disable - config.setdefault('sideshow.orders.allow_unknown_products', 'false') + self.config.setdefault('sideshow.orders.allow_unknown_products', 'false') self.assertFalse(handler.allow_unknown_products()) + def test_allow_item_discounts(self): + handler = self.make_handler() + + # false by default + self.assertFalse(handler.allow_item_discounts()) + + # config can enable + self.config.setdefault('sideshow.orders.allow_item_discounts', 'true') + self.assertTrue(handler.allow_item_discounts()) + + def test_allow_item_discounts_if_on_sale(self): + handler = self.make_handler() + + # false by default + self.assertFalse(handler.allow_item_discounts_if_on_sale()) + + # config can enable + self.config.setdefault('sideshow.orders.allow_item_discounts_if_on_sale', 'true') + self.assertTrue(handler.allow_item_discounts_if_on_sale()) + + def test_get_default_item_discount(self): + handler = self.make_handler() + + # null by default + self.assertIsNone(handler.get_default_item_discount()) + + # config can define + self.config.setdefault('sideshow.orders.default_item_discount', '15') + self.assertEqual(handler.get_default_item_discount(), decimal.Decimal('15.00')) + def test_autocomplete_customers_external(self): handler = self.make_handler() self.assertRaises(NotImplementedError, handler.autocomplete_customers_external, @@ -327,7 +357,7 @@ class TestNewOrderBatchHandler(DataTestCase): self.config.setdefault('sideshow.orders.allow_unknown_products', 'false') self.assertRaises(TypeError, handler.add_item, batch, kw, 1, enum.ORDER_UOM_UNIT) - # local product + # local product w/ discount local = model.LocalProduct(scancode='07430500002', description='Vinegar', size='2oz', @@ -335,7 +365,9 @@ class TestNewOrderBatchHandler(DataTestCase): case_size=12) self.session.add(local) self.session.flush() - row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE) + with patch.object(handler, 'allow_item_discounts', return_value=True): + row = handler.add_item(batch, local.uuid.hex, 1, enum.ORDER_UOM_CASE, + discount_percent=15) self.session.flush() self.session.refresh(row) self.session.refresh(local) @@ -359,7 +391,8 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(row.unit_price_reg, decimal.Decimal('2.99')) self.assertEqual(row.unit_price_quoted, decimal.Decimal('2.99')) self.assertEqual(row.case_price_quoted, decimal.Decimal('35.88')) - self.assertEqual(row.total_price, decimal.Decimal('35.88')) + self.assertEqual(row.discount_percent, decimal.Decimal('15.00')) + self.assertEqual(row.total_price, decimal.Decimal('30.50')) # local product, not found mock_uuid = self.app.make_true_uuid() @@ -511,8 +544,10 @@ class TestNewOrderBatchHandler(DataTestCase): self.config.setdefault('sideshow.orders.allow_unknown_products', 'false') self.assertRaises(TypeError, handler.update_item, row, kw, 1, enum.ORDER_UOM_UNIT) - # update w/ local product - handler.update_item(row, local.uuid.hex, 1, enum.ORDER_UOM_CASE) + # update w/ local product and discount percent + with patch.object(handler, 'allow_item_discounts', return_value=True): + handler.update_item(row, local.uuid.hex, 1, enum.ORDER_UOM_CASE, + discount_percent=15) self.assertIsNone(row.product_id) # nb. pending remains intact here self.assertIsNotNone(row.pending_product) @@ -536,7 +571,8 @@ class TestNewOrderBatchHandler(DataTestCase): self.assertEqual(row.case_price_quoted, decimal.Decimal('47.88')) self.assertEqual(row.order_qty, 1) self.assertEqual(row.order_uom, enum.ORDER_UOM_CASE) - self.assertEqual(row.total_price, decimal.Decimal('47.88')) + self.assertEqual(row.discount_percent, decimal.Decimal('15.00')) + self.assertEqual(row.total_price, decimal.Decimal('40.70')) # update w/ local, not found mock_uuid = self.app.make_true_uuid() diff --git a/tests/web/views/test_orders.py b/tests/web/views/test_orders.py index 3d2a8e4..e4425ab 100644 --- a/tests/web/views/test_orders.py +++ b/tests/web/views/test_orders.py @@ -52,6 +52,7 @@ class TestOrderView(WebTestCase): self.pyramid_config.include('sideshow.web.views') self.config.setdefault('wutta.batch.neworder.handler.spec', 'sideshow.batch.neworder:NewOrderBatchHandler') + self.config.setdefault('sideshow.orders.allow_item_discounts', 'true') model = self.app.model enum = self.app.enum view = self.make_view() @@ -577,6 +578,7 @@ class TestOrderView(WebTestCase): def test_add_item(self): model = self.app.model enum = self.app.enum + self.config.setdefault('sideshow.orders.allow_item_discounts', 'true') handler = NewOrderBatchHandler(self.config) view = self.make_view() @@ -594,6 +596,7 @@ class TestOrderView(WebTestCase): }, 'order_qty': 1, 'order_uom': enum.ORDER_UOM_UNIT, + 'discount_percent': 10, } with patch.object(view, 'batch_handler', create=True, new=handler): @@ -620,6 +623,7 @@ class TestOrderView(WebTestCase): def test_update_item(self): model = self.app.model enum = self.app.enum + self.config.setdefault('sideshow.orders.allow_item_discounts', 'true') handler = NewOrderBatchHandler(self.config) view = self.make_view() @@ -638,6 +642,7 @@ class TestOrderView(WebTestCase): }, 'order_qty': 1, 'order_uom': enum.ORDER_UOM_CASE, + 'discount_percent': 15, } with patch.object(view, 'batch_handler', create=True, new=handler):