feat: add per-department default item discount

This commit is contained in:
Lance Edgar 2025-01-30 21:45:10 -06:00
parent 7e1d68e2cf
commit aa31d23fc8
6 changed files with 363 additions and 25 deletions

View file

@ -234,7 +234,10 @@
<b-field horizontal label="Sold by Weight"> <b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span> <span>${app.render_boolean(item.product_weighed)}</span>
</b-field> </b-field>
<b-field horizontal label="Department"> <b-field horizontal label="Department ID">
<span>${item.department_id}</span>
</b-field>
<b-field horizontal label="Department Name">
<span>${item.department_name}</span> <span>${item.department_name}</span>
</b-field> </b-field>
<b-field horizontal label="Special Order"> <b-field horizontal label="Special Order">

View file

@ -107,15 +107,111 @@
</b-field> </b-field>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']" <div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
class="level-left block"> class="block"
<div class="level-item">Default item discount</div> style="display: flex; gap: 0.5rem; align-items: center;">
<div class="level-item"> <span>Global default item discount</span>
<b-input name="sideshow.orders.default_item_discount" <b-input name="sideshow.orders.default_item_discount"
v-model="simpleSettings['sideshow.orders.default_item_discount']" v-model="simpleSettings['sideshow.orders.default_item_discount']"
@input="settingsNeedSaved = true" @input="settingsNeedSaved = true"
style="width: 5rem;" /> style="width: 5rem;" />
<span>%</span>
</div> </div>
<div class="level-item">%</div>
<div v-show="simpleSettings['sideshow.orders.allow_item_discounts']"
style="width: 50%;">
<div style="display: flex; gap: 1rem; align-items: center;">
<p>Per-Department default item discounts</p>
<div>
<b-button type="is-primary"
@click="deptItemDiscountInit()"
icon-pack="fas"
icon-left="plus">
Add
</b-button>
<input type="hidden" name="dept_item_discounts" :value="JSON.stringify(deptItemDiscounts)" />
<${b}-modal has-modal-card
% if request.use_oruga:
v-model:active="deptItemDiscountShowDialog"
% else:
:active.sync="deptItemDiscountShowDialog"
% endif
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Default Discount for Department</p>
</header>
<section class="modal-card-body">
<div style="display: flex; gap: 1rem;">
<b-field label="Dept. ID"
:type="deptItemDiscountDeptID ? null : 'is-danger'">
<b-input v-model="deptItemDiscountDeptID"
ref="deptItemDiscountDeptID"
style="width: 6rem;;" />
</b-field>
<b-field label="Department Name"
:type="deptItemDiscountDeptName ? null : 'is-danger'"
style="flex-grow: 1;">
<b-input v-model="deptItemDiscountDeptName" />
</b-field>
<b-field label="Discount"
:type="deptItemDiscountPercent ? null : 'is-danger'">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<b-input v-model="deptItemDiscountPercent"
ref="deptItemDiscountPercent"
style="width: 6rem;" />
<span>%</span>
</div>
</b-field>
</div>
</section>
<footer class="modal-card-foot">
<b-button type="is-primary"
icon-pack="fas"
icon-left="save"
:disabled="deptItemDiscountSaveDisabled"
@click="deptItemDiscountSave()">
Save
</b-button>
<b-button @click="deptItemDiscountShowDialog = false">
Cancel
</b-button>
</footer>
</div>
</${b}-modal>
</div>
</div>
<${b}-table :data="deptItemDiscounts">
<${b}-table-column field="department_id"
label="Dept. ID"
v-slot="props">
{{ props.row.department_id }}
</${b}-table-column>
<${b}-table-column field="department_name"
label="Department Name"
v-slot="props">
{{ props.row.department_name }}
</${b}-table-column>
<${b}-table-column field="default_item_discount"
label="Discount"
v-slot="props">
{{ props.row.default_item_discount }} %
</${b}-table-column>
<${b}-table-column label="Actions"
v-slot="props">
<a href="#" @click.prevent="deptItemDiscountInit(props.row)">
<i class="fas fa-edit" />
Edit
</a>
<a href="#" @click.prevent="deptItemDiscountDelete(props.row)"
class="has-text-danger">
<i class="fas fa-trash" />
Delete
</a>
</${b}-table-column>
</${b}-table>
</div> </div>
</div> </div>
@ -145,5 +241,62 @@
ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n} ThisPageData.batchHandlers = ${json.dumps(batch_handlers)|n}
ThisPageData.deptItemDiscounts = ${json.dumps(dept_item_discounts)|n}
ThisPageData.deptItemDiscountShowDialog = false
ThisPageData.deptItemDiscountRow = null
ThisPageData.deptItemDiscountDeptID = null
ThisPageData.deptItemDiscountDeptName = null
ThisPageData.deptItemDiscountPercent = null
ThisPage.computed.deptItemDiscountSaveDisabled = function() {
if (!this.deptItemDiscountDeptID) {
return true
}
if (!this.deptItemDiscountDeptName) {
return true
}
if (!this.deptItemDiscountPercent) {
return true
}
return false
}
ThisPage.methods.deptItemDiscountDelete = function(row) {
const i = this.deptItemDiscounts.indexOf(row)
this.deptItemDiscounts.splice(i, 1)
this.settingsNeedSaved = true
}
ThisPage.methods.deptItemDiscountInit = function(row) {
this.deptItemDiscountRow = row
this.deptItemDiscountDeptID = row?.department_id
this.deptItemDiscountDeptName = row?.department_name
this.deptItemDiscountPercent = row?.default_item_discount
this.deptItemDiscountShowDialog = true
this.$nextTick(() => {
if (row) {
this.$refs.deptItemDiscountPercent.focus()
} else {
this.$refs.deptItemDiscountDeptID.focus()
}
})
}
ThisPage.methods.deptItemDiscountSave = function() {
if (this.deptItemDiscountRow) {
this.deptItemDiscountRow.department_id = this.deptItemDiscountDeptID
this.deptItemDiscountRow.department_name = this.deptItemDiscountDeptName
this.deptItemDiscountRow.default_item_discount = this.deptItemDiscountPercent
} else {
this.deptItemDiscounts.push({
department_id: this.deptItemDiscountDeptID,
department_name: this.deptItemDiscountDeptName,
default_item_discount: this.deptItemDiscountPercent,
})
}
this.deptItemDiscountShowDialog = false
this.settingsNeedSaved = true
}
</script> </script>
</%def> </%def>

View file

@ -504,7 +504,16 @@
<b-input v-model="pendingProduct.scancode" /> <b-input v-model="pendingProduct.scancode" />
</b-field> </b-field>
<b-field label="Department" <b-field label="Dept. ID"
% if 'department_id' in pending_product_required_fields:
:type="pendingProduct.department_id ? null : 'is-danger'"
% endif
style="width: 15rem;">
<b-input v-model="pendingProduct.department_id"
@input="updateDiscount" />
</b-field>
<b-field label="Department Name"
% if 'department_name' in pending_product_required_fields: % if 'department_name' in pending_product_required_fields:
:type="pendingProduct.department_name ? null : 'is-danger'" :type="pendingProduct.department_name ? null : 'is-danger'"
% endif % endif
@ -512,8 +521,7 @@
<b-input v-model="pendingProduct.department_name" /> <b-input v-model="pendingProduct.department_name" />
</b-field> </b-field>
<b-field label="Special Order" <b-field label="Special Order">
style="width: 100%;">
<b-checkbox v-model="pendingProduct.special_order" /> <b-checkbox v-model="pendingProduct.special_order" />
</b-field> </b-field>
@ -750,7 +758,7 @@
<${b}-table-column label="Department" <${b}-table-column label="Department"
v-slot="props"> v-slot="props">
{{ props.row.department_display }} {{ props.row.department_name }}
</${b}-table-column> </${b}-table-column>
<${b}-table-column label="Quantity" <${b}-table-column label="Quantity"
@ -922,8 +930,10 @@
productCaseSize: null, productCaseSize: null,
% if allow_item_discounts: % if allow_item_discounts:
productDiscountPercent: ${json.dumps(default_item_discount)|n}, defaultItemDiscount: ${json.dumps(default_item_discount)|n},
deptItemDiscounts: ${json.dumps(dept_item_discounts)|n},
allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n}, allowDiscountsIfOnSale: ${json.dumps(allow_item_discounts_if_on_sale)|n},
productDiscountPercent: null,
% endif % endif
pendingProduct: {}, pendingProduct: {},
@ -1259,6 +1269,21 @@
}) })
}, },
% if allow_item_discounts:
updateDiscount(deptID) {
// nb. our map requires ID is string
deptID = deptID.toString()
const i = Object.keys(this.deptItemDiscounts).indexOf(deptID)
if (i == -1) {
this.productDiscountPercent = this.defaultItemDiscount
} else {
this.productDiscountPercent = this.deptItemDiscounts[deptID]
}
},
% endif
editNewCustomerSave() { editNewCustomerSave() {
this.editNewCustomerSaving = true this.editNewCustomerSaving = true
@ -1397,7 +1422,7 @@
this.productUnitChoices = this.defaultUnitChoices this.productUnitChoices = this.defaultUnitChoices
% if allow_item_discounts: % if allow_item_discounts:
this.productDiscountPercent = ${json.dumps(default_item_discount)|n} this.productDiscountPercent = this.defaultItemDiscount
% endif % endif
}, },
@ -1436,7 +1461,15 @@
this.productSaleEndsDisplay = data.sale_ends_display this.productSaleEndsDisplay = data.sale_ends_display
% if allow_item_discounts: % if allow_item_discounts:
this.productDiscountPercent = this.allowItemDiscount ? data.default_item_discount : null if (this.allowItemDiscount) {
if (data?.default_item_discount != null) {
this.productDiscountPercent = data.default_item_discount
} else {
this.updateDiscount(data?.department_id)
}
} else {
this.productDiscountPercent = null
}
% endif % endif
// this.setProductUnitChoices(data.uom_choices) // this.setProductUnitChoices(data.uom_choices)
@ -1514,7 +1547,7 @@
this.productUOM = this.defaultUOM this.productUOM = this.defaultUOM
% if allow_item_discounts: % if allow_item_discounts:
this.productDiscountPercent = ${json.dumps(default_item_discount)|n} this.productDiscountPercent = this.defaultItemDiscount
% endif % endif
% if request.use_oruga: % if request.use_oruga:
@ -1615,7 +1648,7 @@
this.editItemLoading = true this.editItemLoading = true
const params = { const params = {
order_qty: this.productQuantity, order_qty: parseFloat(this.productQuantity),
order_uom: this.productUOM, order_uom: this.productUOM,
} }
@ -1626,7 +1659,9 @@
} }
% if allow_item_discounts: % if allow_item_discounts:
params.discount_percent = this.productDiscountPercent if (this.productDiscountPercent) {
params.discount_percent = parseFloat(this.productDiscountPercent)
}
% endif % endif
if (this.editItemRow) { if (this.editItemRow) {

View file

@ -25,7 +25,9 @@ Views for Orders
""" """
import decimal import decimal
import json
import logging import logging
import re
import colander import colander
import sqlalchemy as sa import sqlalchemy as sa
@ -144,6 +146,7 @@ class OrderView(MasterView):
'brand_name', 'brand_name',
'description', 'description',
'size', 'size',
'department_id',
'department_name', 'department_name',
'vendor_name', 'vendor_name',
'vendor_item_code', 'vendor_item_code',
@ -304,6 +307,8 @@ class OrderView(MasterView):
# nb. render quantity so that '10.0' => '10' # nb. render quantity so that '10.0' => '10'
context['default_item_discount'] = self.app.render_quantity( context['default_item_discount'] = self.app.render_quantity(
self.batch_handler.get_default_item_discount()) self.batch_handler.get_default_item_discount())
context['dept_item_discounts'] = dict([(d['department_id'], d['default_item_discount'])
for d in self.get_dept_item_discounts()])
return self.render_to_response('create', context) return self.render_to_response('create', context)
@ -401,6 +406,43 @@ class OrderView(MasterView):
required.append(field) required.append(field)
return required return required
def get_dept_item_discounts(self):
"""
Returns the list of per-department default item discount settings.
Each entry in the list will look like::
{
'department_id': '42',
'department_name': 'Grocery',
'default_item_discount': 10,
}
:returns: List of department settings as shown above.
"""
model = self.app.model
session = self.Session()
pattern = re.compile(r'^sideshow\.orders\.departments\.([^.]+)\.default_item_discount$')
dept_item_discounts = []
settings = session.query(model.Setting)\
.filter(model.Setting.name.like('sideshow.orders.departments.%.default_item_discount'))\
.all()
for setting in settings:
match = pattern.match(setting.name)
if not match:
log.warning("invalid setting name: %s", setting.name)
continue
deptid = match.group(1)
name = self.app.get_setting(session, f'sideshow.orders.departments.{deptid}.name')
dept_item_discounts.append({
'department_id': deptid,
'department_name': name,
'default_item_discount': setting.value,
})
dept_item_discounts.sort(key=lambda d: d['department_name'])
return dept_item_discounts
def start_over(self, batch): def start_over(self, batch):
""" """
This will delete the user's current batch, then redirect user This will delete the user's current batch, then redirect user
@ -598,9 +640,6 @@ class OrderView(MasterView):
if 'case_price_quoted' in data and 'case_price_quoted_display' not in data: 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']) 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 = [ decimal_fields = [
'case_size', 'case_size',
'unit_price_reg', 'unit_price_reg',
@ -753,7 +792,8 @@ class OrderView(MasterView):
row.product_description, row.product_description,
row.product_size), row.product_size),
'product_weighed': row.product_weighed, 'product_weighed': row.product_weighed,
'department_display': row.department_name, 'department_id': row.department_id,
'department_name': row.department_name,
'special_order': row.special_order, 'special_order': row.special_order,
'case_size': float(row.case_size) if row.case_size is not None else None, 'case_size': float(row.case_size) if row.case_size is not None else None,
'order_qty': float(row.order_qty), 'order_qty': float(row.order_qty),
@ -990,8 +1030,39 @@ class OrderView(MasterView):
handlers = [{'spec': spec} for spec in handlers] handlers = [{'spec': spec} for spec in handlers]
context['batch_handlers'] = handlers context['batch_handlers'] = handlers
context['dept_item_discounts'] = self.get_dept_item_discounts()
return context return context
def configure_gather_settings(self, data, simple_settings=None):
""" """
settings = super().configure_gather_settings(data, simple_settings=simple_settings)
for dept in json.loads(data['dept_item_discounts']):
deptid = dept['department_id']
settings.append({'name': f'sideshow.orders.departments.{deptid}.name',
'value': dept['department_name']})
settings.append({'name': f'sideshow.orders.departments.{deptid}.default_item_discount',
'value': dept['default_item_discount']})
return settings
def configure_remove_settings(self, **kwargs):
""" """
model = self.app.model
session = self.Session()
super().configure_remove_settings(**kwargs)
to_delete = session.query(model.Setting)\
.filter(sa.or_(
model.Setting.name.like('sideshow.orders.departments.%.name'),
model.Setting.name.like('sideshow.orders.departments.%.default_item_discount')))\
.all()
for setting in to_delete:
self.app.delete_setting(session, setting.name)
@classmethod @classmethod
def defaults(cls, config): def defaults(cls, config):
cls._order_defaults(config) cls._order_defaults(config)

View file

@ -235,6 +235,7 @@ class PendingProductView(MasterView):
url_prefix = '/pending/products' url_prefix = '/pending/products'
labels = { labels = {
'department_id': "Department ID",
'product_id': "Product ID", 'product_id': "Product ID",
} }

View file

@ -2,6 +2,7 @@
import datetime import datetime
import decimal import decimal
import json
from unittest.mock import patch from unittest.mock import patch
from sqlalchemy import orm from sqlalchemy import orm
@ -291,6 +292,50 @@ class TestOrderView(WebTestCase):
fields = view.get_pending_product_required_fields() fields = view.get_pending_product_required_fields()
self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg']) self.assertEqual(fields, ['brand_name', 'size', 'unit_price_reg'])
def test_get_dept_item_discounts(self):
model = self.app.model
view = self.make_view()
with patch.object(view, 'Session', return_value=self.session):
# empty list by default
discounts = view.get_dept_item_discounts()
self.assertEqual(discounts, [])
# mock settings
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# invalid setting
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.name', 'Bad News')
self.app.save_setting(self.session, 'sideshow.orders.departments.I.N.V.A.L.I.D.default_item_discount', '42')
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
def test_get_context_customer(self): def test_get_context_customer(self):
self.pyramid_config.add_route('orders', '/orders/') self.pyramid_config.add_route('orders', '/orders/')
model = self.app.model model = self.app.model
@ -1288,6 +1333,12 @@ class TestOrderView(WebTestCase):
model = self.app.model model = self.app.model
view = self.make_view() view = self.make_view()
self.app.save_setting(self.session, 'sideshow.orders.departments.5.name', 'Bulk')
self.app.save_setting(self.session, 'sideshow.orders.departments.5.default_item_discount', '15')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.name', 'Produce')
self.app.save_setting(self.session, 'sideshow.orders.departments.6.default_item_discount', '5')
self.session.commit()
with patch.object(view, 'Session', return_value=self.session): with patch.object(view, 'Session', return_value=self.session):
with patch.multiple(self.config, usedb=True, preferdb=True): with patch.multiple(self.config, usedb=True, preferdb=True):
@ -1295,7 +1346,19 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session) session=self.session)
self.assertIsNone(allowed) self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0) self.assertEqual(self.session.query(model.Setting).count(), 4)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 2)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Bulk',
'default_item_discount': '15',
})
self.assertEqual(discounts[1], {
'department_id': '6',
'department_name': 'Produce',
'default_item_discount': '5',
})
# fetch initial page # fetch initial page
response = view.configure() response = view.configure()
@ -1305,13 +1368,18 @@ class TestOrderView(WebTestCase):
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products', allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
session=self.session) session=self.session)
self.assertIsNone(allowed) self.assertIsNone(allowed)
self.assertEqual(self.session.query(model.Setting).count(), 0) self.assertEqual(self.session.query(model.Setting).count(), 4)
# post new settings # post new settings
with patch.multiple(self.request, create=True, with patch.multiple(self.request, create=True,
method='POST', method='POST',
POST={ POST={
'sideshow.orders.allow_unknown_products': 'true', 'sideshow.orders.allow_unknown_products': 'true',
'dept_item_discounts': json.dumps([{
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': 10,
}])
}): }):
response = view.configure() response = view.configure()
self.assertIsInstance(response, HTTPFound) self.assertIsInstance(response, HTTPFound)
@ -1320,6 +1388,13 @@ class TestOrderView(WebTestCase):
session=self.session) session=self.session)
self.assertTrue(allowed) self.assertTrue(allowed)
self.assertTrue(self.session.query(model.Setting).count() > 1) self.assertTrue(self.session.query(model.Setting).count() > 1)
discounts = view.get_dept_item_discounts()
self.assertEqual(len(discounts), 1)
self.assertEqual(discounts[0], {
'department_id': '5',
'department_name': 'Grocery',
'default_item_discount': '10',
})
class OrderItemViewTestMixin: class OrderItemViewTestMixin: