feat: allow basic support for item discounts

This commit is contained in:
Lance Edgar 2025-01-25 23:33:49 -06:00
parent f8f745c243
commit bdf9e46be5
8 changed files with 229 additions and 25 deletions

View file

@ -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),
}

View file

@ -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',
]

View file

@ -19,6 +19,36 @@
<h3 class="block is-size-3">Products</h3>
<div class="block" style="padding-left: 2rem;">
<b-field>
<b-checkbox name="sideshow.orders.allow_item_discounts"
v-model="simpleSettings['sideshow.orders.allow_item_discounts']"
native-value="true"
@input="settingsNeedSaved = true">
Allow per-item discounts
</b-checkbox>
</b-field>
<b-field v-show="simpleSettings['sideshow.orders.allow_item_discounts']">
<b-checkbox name="sideshow.orders.allow_item_discounts_if_on_sale"
v-model="simpleSettings['sideshow.orders.allow_item_discounts_if_on_sale']"
native-value="true"
@input="settingsNeedSaved = true">
Allow discount even if item is on sale
</b-checkbox>
</b-field>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
class="level-left block">
<div class="level-item">Default item discount</div>
<div class="level-item">
<b-input name="sideshow.orders.default_item_discount"
v-model="simpleSettings['sideshow.orders.default_item_discount']"
@input="settingsNeedSaved = true"
style="width: 5rem;" />
</div>
<div class="level-item">%</div>
</div>
<b-field label="Product Source">
<b-select name="sideshow.orders.use_local_products"
v-model="simpleSettings['sideshow.orders.use_local_products']"

View file

@ -663,11 +663,11 @@
<b-field label="Discount" horizontal>
<div class="level">
<div class="level-item">
<numeric-input v-model="productDiscountPercent"
## TODO: needs numeric-input component
<b-input v-model="productDiscountPercent"
@input="refreshTotalPrice += 1"
style="width: 5rem;"
:disabled="!allowItemDiscount">
</numeric-input>
:disabled="!allowItemDiscount" />
</div>
<div class="level-item">
<span>&nbsp;%</span>
@ -749,6 +749,13 @@
</span>
</${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">
<span :class="props.row.pricing_reflects_sale ? 'has-background-warning' : null">
@ -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

View file

@ -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')

View file

@ -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,

View file

@ -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()

View file

@ -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):