-
-
+ ## TODO: needs numeric-input component
+
%
@@ -749,6 +749,13 @@
${b}-table-column>
+ % if allow_item_discounts:
+ <${b}-table-column label="Discount"
+ v-slot="props">
+ {{ props.row.discount_percent }}{{ props.row.discount_percent ? " %" : "" }}
+ ${b}-table-column>
+ % 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):