Compare commits
5 commits
76075f146c
...
3ca89a8479
Author | SHA1 | Date | |
---|---|---|---|
|
3ca89a8479 | ||
|
aa31d23fc8 | ||
|
7e1d68e2cf | ||
|
89e3445ace | ||
|
3ef84ff706 |
6
docs/api/sideshow.app.rst
Normal file
6
docs/api/sideshow.app.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``sideshow.app``
|
||||
================
|
||||
|
||||
.. automodule:: sideshow.app
|
||||
:members:
|
6
docs/api/sideshow.db.model.stores.rst
Normal file
6
docs/api/sideshow.db.model.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``sideshow.db.model.stores``
|
||||
============================
|
||||
|
||||
.. automodule:: sideshow.db.model.stores
|
||||
:members:
|
6
docs/api/sideshow.web.views.stores.rst
Normal file
6
docs/api/sideshow.web.views.stores.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``sideshow.web.views.stores``
|
||||
=============================
|
||||
|
||||
.. automodule:: sideshow.web.views.stores
|
||||
:members:
|
|
@ -30,6 +30,7 @@ For an online demo see https://demo.wuttaproject.org/
|
|||
:caption: Package API:
|
||||
|
||||
api/sideshow
|
||||
api/sideshow.app
|
||||
api/sideshow.batch
|
||||
api/sideshow.batch.neworder
|
||||
api/sideshow.cli
|
||||
|
@ -43,6 +44,7 @@ For an online demo see https://demo.wuttaproject.org/
|
|||
api/sideshow.db.model.customers
|
||||
api/sideshow.db.model.orders
|
||||
api/sideshow.db.model.products
|
||||
api/sideshow.db.model.stores
|
||||
api/sideshow.enum
|
||||
api/sideshow.orders
|
||||
api/sideshow.web
|
||||
|
@ -58,3 +60,4 @@ For an online demo see https://demo.wuttaproject.org/
|
|||
api/sideshow.web.views.customers
|
||||
api/sideshow.web.views.orders
|
||||
api/sideshow.web.views.products
|
||||
api/sideshow.web.views.stores
|
||||
|
|
|
@ -51,6 +51,9 @@ sideshow_libcache = "sideshow.web.static:libcache"
|
|||
[project.entry-points."paste.app_factory"]
|
||||
"main" = "sideshow.web.app:main"
|
||||
|
||||
[project.entry-points."wutta.app.providers"]
|
||||
sideshow = "sideshow.app:SideshowAppProvider"
|
||||
|
||||
[project.entry-points."wutta.batch.neworder"]
|
||||
"sideshow" = "sideshow.batch.neworder:NewOrderBatchHandler"
|
||||
|
||||
|
|
56
src/sideshow/app.py
Normal file
56
src/sideshow/app.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Sideshow -- Case/Special Order Tracker
|
||||
# Copyright © 2024 Lance Edgar
|
||||
#
|
||||
# This file is part of Sideshow.
|
||||
#
|
||||
# Sideshow is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Sideshow is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Sideshow app provider
|
||||
"""
|
||||
|
||||
from wuttjamaican import app as base
|
||||
|
||||
|
||||
class SideshowAppProvider(base.AppProvider):
|
||||
"""
|
||||
The :term:`app provider` for Sideshow.
|
||||
|
||||
This adds the :meth:`get_order_handler()` method to the :term:`app
|
||||
handler`.
|
||||
"""
|
||||
|
||||
def get_order_handler(self, **kwargs):
|
||||
"""
|
||||
Get the configured :term:`order handler` for the app.
|
||||
|
||||
You can specify a custom handler in your :term:`config file`
|
||||
like:
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
[sideshow]
|
||||
orders.handler_spec = poser.orders:PoserOrderHandler
|
||||
|
||||
:returns: Instance of :class:`~sideshow.orders.OrderHandler`.
|
||||
"""
|
||||
if 'order_handler' not in self.__dict__:
|
||||
spec = self.config.get('sideshow.orders.handler_spec',
|
||||
default='sideshow.orders:OrderHandler')
|
||||
self.order_handler = self.app.load_object(spec)(self.config)
|
||||
return self.order_handler
|
|
@ -26,6 +26,7 @@ New Order Batch Handler
|
|||
|
||||
import datetime
|
||||
import decimal
|
||||
from collections import OrderedDict
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
@ -50,6 +51,14 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
"""
|
||||
model_class = NewOrderBatch
|
||||
|
||||
def get_default_store_id(self):
|
||||
"""
|
||||
Returns the configured default value for
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
|
||||
or ``None``.
|
||||
"""
|
||||
return self.config.get('sideshow.orders.default_store_id')
|
||||
|
||||
def use_local_customers(self):
|
||||
"""
|
||||
Returns boolean indicating whether :term:`local customer`
|
||||
|
@ -165,6 +174,18 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
'label': customer.full_name}
|
||||
return [result(c) for c in customers]
|
||||
|
||||
def init_batch(self, batch, session=None, progress=None, **kwargs):
|
||||
"""
|
||||
Initialize a new batch.
|
||||
|
||||
This sets the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`,
|
||||
if the batch does not yet have one and a default is
|
||||
configured.
|
||||
"""
|
||||
if not batch.store_id:
|
||||
batch.store_id = self.get_default_store_id()
|
||||
|
||||
def set_customer(self, batch, customer_info, user=None):
|
||||
"""
|
||||
Set/update customer info for the batch.
|
||||
|
@ -359,6 +380,21 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
'label': product.full_description}
|
||||
return [result(c) for c in products]
|
||||
|
||||
def get_default_uom_choices(self):
|
||||
"""
|
||||
Returns a list of ordering UOM choices which should be
|
||||
presented to the user by default.
|
||||
|
||||
The built-in logic here will return everything from
|
||||
:data:`~sideshow.enum.ORDER_UOM`.
|
||||
|
||||
:returns: List of dicts, each with ``key`` and ``value``
|
||||
corresponding to the UOM code and label, respectively.
|
||||
"""
|
||||
enum = self.app.enum
|
||||
return [{'key': key, 'value': val}
|
||||
for key, val in enum.ORDER_UOM.items()]
|
||||
|
||||
def get_product_info_external(self, session, product_id, user=None):
|
||||
"""
|
||||
Returns basic info for an :term:`external product` as pertains
|
||||
|
@ -368,7 +404,8 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
choose order quantity and UOM based on case size, pricing
|
||||
etc., this method is called to retrieve the product info.
|
||||
|
||||
There is no default logic here; subclass must implement.
|
||||
There is no default logic here; subclass must implement. See
|
||||
also :meth:`get_product_info_local()`.
|
||||
|
||||
:param session: Current app :term:`db session`.
|
||||
|
||||
|
@ -424,21 +461,58 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
|
||||
def get_product_info_local(self, session, uuid, user=None):
|
||||
"""
|
||||
Returns basic info for a
|
||||
:class:`~sideshow.db.model.products.LocalProduct` as pertains
|
||||
to ordering.
|
||||
Returns basic info for a :term:`local product` as pertains to
|
||||
ordering.
|
||||
|
||||
When user has located a product via search, and must then
|
||||
choose order quantity and UOM based on case size, pricing
|
||||
etc., this method is called to retrieve the product info.
|
||||
|
||||
See :meth:`get_product_info_external()` for more explanation.
|
||||
|
||||
This method will locate the
|
||||
:class:`~sideshow.db.model.products.LocalProduct` record, then
|
||||
(if found) it calls :meth:`normalize_local_product()` and
|
||||
returns the result.
|
||||
|
||||
:param session: Current :term:`db session`.
|
||||
|
||||
:param uuid: UUID for the desired
|
||||
:class:`~sideshow.db.model.products.LocalProduct`.
|
||||
|
||||
:param user:
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||
is performing the action, if known.
|
||||
|
||||
:returns: Dict of product info.
|
||||
"""
|
||||
model = self.app.model
|
||||
product = session.get(model.LocalProduct, uuid)
|
||||
if not product:
|
||||
raise ValueError(f"Local Product not found: {uuid}")
|
||||
|
||||
return self.normalize_local_product(product)
|
||||
|
||||
def normalize_local_product(self, product):
|
||||
"""
|
||||
Returns a normalized dict of info for the given :term:`local
|
||||
product`.
|
||||
|
||||
This is called by:
|
||||
|
||||
* :meth:`get_product_info_local()`
|
||||
* :meth:`get_past_products()`
|
||||
|
||||
:param product:
|
||||
:class:`~sideshow.db.model.products.LocalProduct` instance.
|
||||
|
||||
:returns: Dict of product info.
|
||||
|
||||
The keys for this dict should essentially one-to-one for the
|
||||
product fields, with one exception:
|
||||
|
||||
* ``product_id`` will be set to the product UUID as string
|
||||
"""
|
||||
return {
|
||||
'product_id': product.uuid.hex,
|
||||
'scancode': product.scancode,
|
||||
|
@ -456,6 +530,109 @@ class NewOrderBatchHandler(BatchHandler):
|
|||
'vendor_item_code': product.vendor_item_code,
|
||||
}
|
||||
|
||||
def get_past_orders(self, batch):
|
||||
"""
|
||||
Retrieve a (possibly empty) list of past :term:`orders
|
||||
<order>` for the batch customer.
|
||||
|
||||
This is called by :meth:`get_past_products()`.
|
||||
|
||||
:param batch:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
instance.
|
||||
|
||||
:returns: List of :class:`~sideshow.db.model.orders.Order`
|
||||
records.
|
||||
"""
|
||||
model = self.app.model
|
||||
session = self.app.get_session(batch)
|
||||
orders = session.query(model.Order)
|
||||
|
||||
if batch.customer_id:
|
||||
orders = orders.filter(model.Order.customer_id == batch.customer_id)
|
||||
elif batch.local_customer:
|
||||
orders = orders.filter(model.Order.local_customer == batch.local_customer)
|
||||
else:
|
||||
raise ValueError(f"batch has no customer: {batch}")
|
||||
|
||||
orders = orders.order_by(model.Order.created.desc())
|
||||
return orders.all()
|
||||
|
||||
def get_past_products(self, batch, user=None):
|
||||
"""
|
||||
Retrieve a (possibly empty) list of products which have been
|
||||
previously ordered by the batch customer.
|
||||
|
||||
Note that this does not return :term:`order items <order
|
||||
item>`, nor does it return true product records, but rather it
|
||||
returns a list of dicts. Each will have product info but will
|
||||
*not* have order quantity etc.
|
||||
|
||||
This method calls :meth:`get_past_orders()` and then iterates
|
||||
through each order item therein. Any duplicated products
|
||||
encountered will be skipped, so the final list contains unique
|
||||
products.
|
||||
|
||||
Each dict in the result is obtained by calling one of:
|
||||
|
||||
* :meth:`normalize_local_product()`
|
||||
* :meth:`get_product_info_external()`
|
||||
|
||||
:param batch:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
instance.
|
||||
|
||||
:param user:
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||
is performing the action, if known.
|
||||
|
||||
:returns: List of product info dicts.
|
||||
"""
|
||||
model = self.app.model
|
||||
session = self.app.get_session(batch)
|
||||
use_local = self.use_local_products()
|
||||
user = user or batch.created_by
|
||||
products = OrderedDict()
|
||||
|
||||
# track down all order items for batch contact
|
||||
for order in self.get_past_orders(batch):
|
||||
for item in order.items:
|
||||
|
||||
# nb. we only need the first match for each product
|
||||
if use_local:
|
||||
product = item.local_product
|
||||
if product and product.uuid not in products:
|
||||
products[product.uuid] = self.normalize_local_product(product)
|
||||
elif item.product_id and item.product_id not in products:
|
||||
products[item.product_id] = self.get_product_info_external(
|
||||
session, item.product_id, user=user)
|
||||
|
||||
products = list(products.values())
|
||||
for product in products:
|
||||
|
||||
price = product['unit_price_reg']
|
||||
|
||||
if 'unit_price_reg_display' not in product:
|
||||
product['unit_price_reg_display'] = self.app.render_currency(price)
|
||||
|
||||
if 'unit_price_quoted' not in product:
|
||||
product['unit_price_quoted'] = price
|
||||
|
||||
if 'unit_price_quoted_display' not in product:
|
||||
product['unit_price_quoted_display'] = product['unit_price_reg_display']
|
||||
|
||||
if ('case_price_quoted' not in product
|
||||
and product.get('unit_price_quoted') is not None
|
||||
and product.get('case_size') is not None):
|
||||
product['case_price_quoted'] = product['unit_price_quoted'] * product['case_size']
|
||||
|
||||
if ('case_price_quoted_display' not in product
|
||||
and 'case_price_quoted' in product):
|
||||
product['case_price_quoted_display'] = self.app.render_currency(
|
||||
product['case_price_quoted'])
|
||||
|
||||
return products
|
||||
|
||||
def add_item(self, batch, product_info, order_qty, order_uom,
|
||||
discount_percent=None, user=None):
|
||||
"""
|
||||
|
|
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal file
39
src/sideshow/db/alembic/versions/a4273360d379_add_stores.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
"""add stores
|
||||
|
||||
Revision ID: a4273360d379
|
||||
Revises: 7a6df83afbd4
|
||||
Create Date: 2025-01-27 17:48:20.638664
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import wuttjamaican.db.util
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a4273360d379'
|
||||
down_revision: Union[str, None] = '7a6df83afbd4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
# sideshow_store
|
||||
op.create_table('sideshow_store',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('store_id', sa.String(length=10), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('archived', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_store')),
|
||||
sa.UniqueConstraint('store_id', name=op.f('uq_sideshow_store_store_id')),
|
||||
sa.UniqueConstraint('name', name=op.f('uq_sideshow_store_name'))
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
# sideshow_store
|
||||
op.drop_table('sideshow_store')
|
|
@ -30,6 +30,7 @@ This namespace exposes everything from
|
|||
|
||||
Primary :term:`data models <data model>`:
|
||||
|
||||
* :class:`~sideshow.db.model.stores.Store`
|
||||
* :class:`~sideshow.db.model.orders.Order`
|
||||
* :class:`~sideshow.db.model.orders.OrderItem`
|
||||
* :class:`~sideshow.db.model.orders.OrderItemEvent`
|
||||
|
@ -48,6 +49,7 @@ And the :term:`batch` models:
|
|||
from wuttjamaican.db.model import *
|
||||
|
||||
# sideshow models
|
||||
from .stores import Store
|
||||
from .customers import LocalCustomer, PendingCustomer
|
||||
from .products import LocalProduct, PendingProduct
|
||||
from .orders import Order, OrderItem, OrderItemEvent
|
||||
|
|
|
@ -62,6 +62,15 @@ class Order(model.Base):
|
|||
ID of the store to which the order pertains, if applicable.
|
||||
""")
|
||||
|
||||
store = orm.relationship(
|
||||
'Store',
|
||||
primaryjoin='Store.store_id == Order.store_id',
|
||||
foreign_keys='Order.store_id',
|
||||
doc="""
|
||||
Reference to the :class:`~sideshow.db.model.stores.Store`
|
||||
record, if applicable.
|
||||
""")
|
||||
|
||||
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Proper account ID for the :term:`external customer` to which the
|
||||
order pertains, if applicable.
|
||||
|
|
62
src/sideshow/db/model/stores.py
Normal file
62
src/sideshow/db/model/stores.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Sideshow -- Case/Special Order Tracker
|
||||
# Copyright © 2024-2025 Lance Edgar
|
||||
#
|
||||
# This file is part of Sideshow.
|
||||
#
|
||||
# Sideshow is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Sideshow is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Data models for Stores
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from wuttjamaican.db import model
|
||||
|
||||
|
||||
class Store(model.Base):
|
||||
"""
|
||||
Represents a physical location for the business.
|
||||
"""
|
||||
__tablename__ = 'sideshow_store'
|
||||
|
||||
uuid = model.uuid_column()
|
||||
|
||||
store_id = sa.Column(sa.String(length=10), nullable=False, unique=True, doc="""
|
||||
Unique ID for the store.
|
||||
""")
|
||||
|
||||
name = sa.Column(sa.String(length=100), nullable=False, unique=True, doc="""
|
||||
Display name for the store (must be unique!).
|
||||
""")
|
||||
|
||||
archived = sa.Column(sa.Boolean(), nullable=False, default=False, doc="""
|
||||
Indicates the store has been "retired" essentially, and mostly
|
||||
hidden from view.
|
||||
""")
|
||||
|
||||
def __str__(self):
|
||||
return self.get_display()
|
||||
|
||||
def get_display(self):
|
||||
"""
|
||||
Returns the display string for the store, e.g. "001 Acme Goods".
|
||||
"""
|
||||
return ' '.join([(self.store_id or '').strip(),
|
||||
(self.name or '').strip()])\
|
||||
.strip()
|
|
@ -37,6 +37,14 @@ class OrderHandler(GenericHandler):
|
|||
handler is responsible for creation logic.)
|
||||
"""
|
||||
|
||||
def expose_store_id(self):
|
||||
"""
|
||||
Returns boolean indicating whether the ``store_id`` field
|
||||
should be exposed at all. This is false by default.
|
||||
"""
|
||||
return self.config.get_bool('sideshow.orders.expose_store_id',
|
||||
default=False)
|
||||
|
||||
def get_order_qty_uom_text(self, order_qty, order_uom, case_size=None, html=False):
|
||||
"""
|
||||
Return the display text for a given order quantity.
|
||||
|
|
|
@ -162,4 +162,12 @@ class SideshowMenuHandler(base.MenuHandler):
|
|||
def make_admin_menu(self, request, **kwargs):
|
||||
""" """
|
||||
kwargs['include_people'] = True
|
||||
return super().make_admin_menu(request, **kwargs)
|
||||
menu = super().make_admin_menu(request, **kwargs)
|
||||
|
||||
menu['items'].insert(0, {
|
||||
'title': "Stores",
|
||||
'route': 'stores',
|
||||
'perm': 'stores.list',
|
||||
})
|
||||
|
||||
return menu
|
||||
|
|
|
@ -29,6 +29,17 @@
|
|||
<b-field horizontal label="ID">
|
||||
<span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence}</span>
|
||||
</b-field>
|
||||
% if expose_store_id:
|
||||
<b-field horizontal label="Store">
|
||||
<span>
|
||||
% if order.store:
|
||||
${h.link_to(order.store.get_display(), url('stores.view', uuid=order.store.uuid))}
|
||||
% elif order.store_id:
|
||||
${order.store_id}
|
||||
% endif
|
||||
</span>
|
||||
</b-field>
|
||||
% endif
|
||||
<b-field horizontal label="Order Qty">
|
||||
<span>${order_qty_uom_text|n}</span>
|
||||
</b-field>
|
||||
|
@ -223,7 +234,10 @@
|
|||
<b-field horizontal label="Sold by Weight">
|
||||
<span>${app.render_boolean(item.product_weighed)}</span>
|
||||
</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>
|
||||
</b-field>
|
||||
<b-field horizontal label="Special Order">
|
||||
|
|
|
@ -3,6 +3,28 @@
|
|||
|
||||
<%def name="form_content()">
|
||||
|
||||
<h3 class="block is-size-3">Stores</h3>
|
||||
<div class="block" style="padding-left: 2rem;">
|
||||
|
||||
<b-field>
|
||||
<b-checkbox name="sideshow.orders.expose_store_id"
|
||||
v-model="simpleSettings['sideshow.orders.expose_store_id']"
|
||||
native-value="true"
|
||||
@input="settingsNeedSaved = true">
|
||||
Show/choose the Store ID for each order
|
||||
</b-checkbox>
|
||||
</b-field>
|
||||
|
||||
<b-field v-show="simpleSettings['sideshow.orders.expose_store_id']"
|
||||
label="Default Store ID">
|
||||
<b-input name="sideshow.orders.default_store_id"
|
||||
v-model="simpleSettings['sideshow.orders.default_store_id']"
|
||||
@input="settingsNeedSaved = true"
|
||||
style="width: 25rem;" />
|
||||
</b-field>
|
||||
|
||||
</div>
|
||||
|
||||
<h3 class="block is-size-3">Customers</h3>
|
||||
<div class="block" style="padding-left: 2rem;">
|
||||
|
||||
|
@ -14,41 +36,12 @@
|
|||
<option value="false">External Customers (e.g. in POS)</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
|
||||
</div>
|
||||
|
||||
<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']"
|
||||
|
@ -92,6 +85,136 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="block is-size-3">Pricing</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="block"
|
||||
style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<span>Global default item discount</span>
|
||||
<b-input name="sideshow.orders.default_item_discount"
|
||||
v-model="simpleSettings['sideshow.orders.default_item_discount']"
|
||||
@input="settingsNeedSaved = true"
|
||||
style="width: 5rem;" />
|
||||
<span>%</span>
|
||||
</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>
|
||||
|
||||
<h3 class="block is-size-3">Batches</h3>
|
||||
<div class="block" style="padding-left: 2rem;">
|
||||
|
||||
|
@ -118,5 +241,62 @@
|
|||
|
||||
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>
|
||||
</%def>
|
||||
|
|
|
@ -42,7 +42,25 @@
|
|||
<script type="text/x-template" id="order-creator-template">
|
||||
<div>
|
||||
|
||||
${self.order_form_buttons()}
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<div>
|
||||
% if expose_store_id:
|
||||
<b-loading v-model="storeLoading" is-full-page />
|
||||
<b-field label="Store" horizontal
|
||||
:type="storeID ? null : 'is-danger'">
|
||||
<b-select v-model="storeID"
|
||||
@input="storeChanged">
|
||||
<option v-for="store in stores"
|
||||
:key="store.store_id"
|
||||
:value="store.store_id">
|
||||
{{ store.display }}
|
||||
</option>
|
||||
</b-select>
|
||||
</b-field>
|
||||
% endif
|
||||
</div>
|
||||
${self.order_form_buttons()}
|
||||
</div>
|
||||
|
||||
<${b}-collapse class="panel"
|
||||
:class="customerPanelType"
|
||||
|
@ -337,6 +355,135 @@
|
|||
@click="showAddItemDialog()">
|
||||
Add Item
|
||||
</b-button>
|
||||
% if allow_past_item_reorder:
|
||||
<b-button v-if="customerIsKnown && customerID"
|
||||
icon-pack="fas"
|
||||
icon-left="plus"
|
||||
@click="showAddPastItem()">
|
||||
Add Past Item
|
||||
</b-button>
|
||||
|
||||
<${b}-modal
|
||||
% if request.use_oruga:
|
||||
v-model:active="pastItemsShowDialog"
|
||||
% else:
|
||||
:active.sync="pastItemsShowDialog"
|
||||
% endif
|
||||
>
|
||||
<div class="card">
|
||||
<div class="card-content">
|
||||
|
||||
<${b}-table :data="pastItems"
|
||||
icon-pack="fas"
|
||||
:loading="pastItemsLoading"
|
||||
% if request.use_oruga:
|
||||
v-model:selected="pastItemsSelected"
|
||||
% else:
|
||||
:selected.sync="pastItemsSelected"
|
||||
% endif
|
||||
sortable
|
||||
paginated
|
||||
per-page="5"
|
||||
## :debounce-search="1000"
|
||||
>
|
||||
|
||||
<${b}-table-column label="Scancode"
|
||||
field="key"
|
||||
v-slot="props"
|
||||
sortable>
|
||||
{{ props.row.scancode }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Brand"
|
||||
field="brand_name"
|
||||
v-slot="props"
|
||||
sortable
|
||||
searchable>
|
||||
{{ props.row.brand_name }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Description"
|
||||
field="description"
|
||||
v-slot="props"
|
||||
sortable
|
||||
searchable>
|
||||
{{ props.row.description }}
|
||||
{{ props.row.size }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Unit Price"
|
||||
field="unit_price_reg_display"
|
||||
v-slot="props"
|
||||
sortable>
|
||||
{{ props.row.unit_price_reg_display }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Sale Price"
|
||||
field="sale_price"
|
||||
v-slot="props"
|
||||
sortable>
|
||||
<span class="has-background-warning">
|
||||
{{ props.row.sale_price_display }}
|
||||
</span>
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Sale Ends"
|
||||
field="sale_ends"
|
||||
v-slot="props"
|
||||
sortable>
|
||||
<span class="has-background-warning">
|
||||
{{ props.row.sale_ends_display }}
|
||||
</span>
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Department"
|
||||
field="department_name"
|
||||
v-slot="props"
|
||||
sortable
|
||||
searchable>
|
||||
{{ props.row.department_name }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Vendor"
|
||||
field="vendor_name"
|
||||
v-slot="props"
|
||||
sortable
|
||||
searchable>
|
||||
{{ props.row.vendor_name }}
|
||||
</${b}-table-column>
|
||||
|
||||
<template #empty>
|
||||
<div class="content has-text-grey has-text-centered">
|
||||
<p>
|
||||
<b-icon
|
||||
pack="fas"
|
||||
icon="sad-tear"
|
||||
size="is-large">
|
||||
</b-icon>
|
||||
</p>
|
||||
<p>Nothing here.</p>
|
||||
</div>
|
||||
</template>
|
||||
</${b}-table>
|
||||
|
||||
<div class="buttons">
|
||||
<b-button @click="pastItemsShowDialog = false">
|
||||
Cancel
|
||||
</b-button>
|
||||
<b-button type="is-primary"
|
||||
icon-pack="fas"
|
||||
icon-left="plus"
|
||||
@click="pastItemsAddSelected()"
|
||||
:disabled="!pastItemsSelected">
|
||||
Add Selected Item
|
||||
</b-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</${b}-modal>
|
||||
|
||||
% endif
|
||||
</div>
|
||||
|
||||
<${b}-modal
|
||||
|
@ -486,7 +633,16 @@
|
|||
<b-input v-model="pendingProduct.scancode" />
|
||||
</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:
|
||||
:type="pendingProduct.department_name ? null : 'is-danger'"
|
||||
% endif
|
||||
|
@ -494,8 +650,7 @@
|
|||
<b-input v-model="pendingProduct.department_name" />
|
||||
</b-field>
|
||||
|
||||
<b-field label="Special Order"
|
||||
style="width: 100%;">
|
||||
<b-field label="Special Order">
|
||||
<b-checkbox v-model="pendingProduct.special_order" />
|
||||
</b-field>
|
||||
|
||||
|
@ -732,7 +887,7 @@
|
|||
|
||||
<${b}-table-column label="Department"
|
||||
v-slot="props">
|
||||
{{ props.row.department_display }}
|
||||
{{ props.row.department_name }}
|
||||
</${b}-table-column>
|
||||
|
||||
<${b}-table-column label="Quantity"
|
||||
|
@ -837,6 +992,12 @@
|
|||
|
||||
batchTotalPriceDisplay: ${json.dumps(normalized_batch['total_price_display'])|n},
|
||||
|
||||
% if expose_store_id:
|
||||
stores: ${json.dumps(stores)|n},
|
||||
storeID: ${json.dumps(batch.store_id)|n},
|
||||
storeLoading: false,
|
||||
% endif
|
||||
|
||||
customerPanelOpen: false,
|
||||
customerLoading: false,
|
||||
customerIsKnown: ${json.dumps(customer_is_known)|n},
|
||||
|
@ -898,8 +1059,10 @@
|
|||
productCaseSize: null,
|
||||
|
||||
% 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},
|
||||
productDiscountPercent: null,
|
||||
% endif
|
||||
|
||||
pendingProduct: {},
|
||||
|
@ -908,6 +1071,13 @@
|
|||
## departmentOptions: ${json.dumps(department_options)|n},
|
||||
departmentOptions: [],
|
||||
|
||||
% if allow_past_item_reorder:
|
||||
pastItemsShowDialog: false,
|
||||
pastItemsLoading: false,
|
||||
pastItems: [],
|
||||
pastItemsSelected: null,
|
||||
% endif
|
||||
|
||||
// nb. hack to force refresh for vue3
|
||||
refreshProductDescription: 1,
|
||||
refreshTotalPrice: 1,
|
||||
|
@ -1160,8 +1330,31 @@
|
|||
})
|
||||
},
|
||||
|
||||
% if expose_store_id:
|
||||
|
||||
storeChanged(storeID) {
|
||||
this.storeLoading = true
|
||||
const params = {
|
||||
action: 'set_store',
|
||||
store_id: storeID,
|
||||
}
|
||||
this.submitBatchData(params, ({data}) => {
|
||||
this.storeLoading = false
|
||||
}, response => {
|
||||
this.$buefy.toast.open({
|
||||
message: "Update failed: " + (response.data.error || "(unknown error)"),
|
||||
type: 'is-danger',
|
||||
duration: 2000, // 2 seconds
|
||||
})
|
||||
this.storeLoading = false
|
||||
})
|
||||
},
|
||||
|
||||
% endif
|
||||
|
||||
customerChanged(customerID, callback) {
|
||||
this.customerLoading = true
|
||||
this.pastItems = []
|
||||
|
||||
const params = {}
|
||||
if (customerID) {
|
||||
|
@ -1213,6 +1406,23 @@
|
|||
})
|
||||
},
|
||||
|
||||
% if allow_item_discounts:
|
||||
|
||||
updateDiscount(deptID) {
|
||||
if (deptID) {
|
||||
// nb. our map requires ID as 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() {
|
||||
this.editNewCustomerSaving = true
|
||||
|
||||
|
@ -1351,7 +1561,7 @@
|
|||
this.productUnitChoices = this.defaultUnitChoices
|
||||
|
||||
% if allow_item_discounts:
|
||||
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
|
||||
this.productDiscountPercent = this.defaultItemDiscount
|
||||
% endif
|
||||
},
|
||||
|
||||
|
@ -1390,7 +1600,15 @@
|
|||
this.productSaleEndsDisplay = data.sale_ends_display
|
||||
|
||||
% 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
|
||||
|
||||
// this.setProductUnitChoices(data.uom_choices)
|
||||
|
@ -1468,7 +1686,7 @@
|
|||
this.productUOM = this.defaultUOM
|
||||
|
||||
% if allow_item_discounts:
|
||||
this.productDiscountPercent = ${json.dumps(default_item_discount)|n}
|
||||
this.productDiscountPercent = this.defaultItemDiscount
|
||||
% endif
|
||||
|
||||
% if request.use_oruga:
|
||||
|
@ -1522,7 +1740,7 @@
|
|||
## this.productSpecialOrder = row.special_order
|
||||
|
||||
this.productQuantity = row.order_qty
|
||||
this.productUnitChoices = row.order_uom_choices
|
||||
this.productUnitChoices = row?.order_uom_choices || this.defaultUnitChoices
|
||||
this.productUOM = row.order_uom
|
||||
|
||||
% if allow_item_discounts:
|
||||
|
@ -1564,12 +1782,77 @@
|
|||
})
|
||||
},
|
||||
|
||||
% if allow_past_item_reorder:
|
||||
|
||||
showAddPastItem() {
|
||||
this.pastItemsSelected = null
|
||||
if (!this.pastItems.length) {
|
||||
this.pastItemsLoading = true
|
||||
const params = {action: 'get_past_products'}
|
||||
this.submitBatchData(params, ({data}) => {
|
||||
this.pastItems = data
|
||||
this.pastItemsLoading = false
|
||||
})
|
||||
}
|
||||
this.pastItemsShowDialog = true
|
||||
},
|
||||
|
||||
pastItemsAddSelected() {
|
||||
this.pastItemsShowDialog = false
|
||||
const selected = this.pastItemsSelected
|
||||
|
||||
this.editItemRow = null
|
||||
this.productIsKnown = true
|
||||
this.productID = selected.product_id
|
||||
|
||||
this.selectedProduct = {
|
||||
product_id: selected.product_id,
|
||||
full_description: selected.full_description,
|
||||
// url: selected.product_url,
|
||||
}
|
||||
|
||||
this.productDisplay = selected.full_description
|
||||
this.productScancode = selected.scancode
|
||||
this.productSize = selected.size
|
||||
this.productCaseQuantity = selected.case_size
|
||||
this.productUnitPrice = selected.unit_price_quoted
|
||||
this.productUnitPriceDisplay = selected.unit_price_quoted_display
|
||||
this.productUnitRegularPriceDisplay = selected.unit_price_reg_display
|
||||
this.productCasePrice = selected.case_price_quoted
|
||||
this.productCasePriceDisplay = selected.case_price_quoted_display
|
||||
this.productSalePrice = selected.unit_price_sale
|
||||
this.productSalePriceDisplay = selected.unit_price_sale_display
|
||||
this.productSaleEndsDisplay = selected.sale_ends_display
|
||||
this.productSpecialOrder = selected.special_order
|
||||
|
||||
this.productQuantity = 1
|
||||
this.productUnitChoices = selected?.order_uom_choices || this.defaultUnitChoices
|
||||
this.productUOM = selected?.order_uom || this.defaultUOM
|
||||
|
||||
% if allow_item_discounts:
|
||||
this.updateDiscount(selected.department_id)
|
||||
% endif
|
||||
|
||||
// nb. hack to force refresh for vue3
|
||||
this.refreshProductDescription += 1
|
||||
this.refreshTotalPrice += 1
|
||||
|
||||
% if request.use_oruga:
|
||||
this.itemDialogTab = 'quantity'
|
||||
% else:
|
||||
this.itemDialogTabIndex = 1
|
||||
% endif
|
||||
this.editItemShowDialog = true
|
||||
},
|
||||
|
||||
% endif
|
||||
|
||||
itemDialogAttemptSave() {
|
||||
this.itemDialogSaving = true
|
||||
this.editItemLoading = true
|
||||
|
||||
const params = {
|
||||
order_qty: this.productQuantity,
|
||||
order_qty: parseFloat(this.productQuantity),
|
||||
order_uom: this.productUOM,
|
||||
}
|
||||
|
||||
|
@ -1580,7 +1863,9 @@
|
|||
}
|
||||
|
||||
% if allow_item_discounts:
|
||||
params.discount_percent = this.productDiscountPercent
|
||||
if (this.productDiscountPercent) {
|
||||
params.discount_percent = parseFloat(this.productDiscountPercent)
|
||||
}
|
||||
% endif
|
||||
|
||||
if (this.editItemRow) {
|
||||
|
|
|
@ -35,6 +35,7 @@ def includeme(config):
|
|||
})
|
||||
|
||||
# sideshow views
|
||||
config.include('sideshow.web.views.stores')
|
||||
config.include('sideshow.web.views.customers')
|
||||
config.include('sideshow.web.views.products')
|
||||
config.include('sideshow.web.views.orders')
|
||||
|
|
|
@ -126,6 +126,10 @@ class NewOrderBatchView(BatchMasterView):
|
|||
'status_code',
|
||||
]
|
||||
|
||||
def __init__(self, request, context=None):
|
||||
super().__init__(request, context=context)
|
||||
self.order_handler = self.app.get_order_handler()
|
||||
|
||||
def get_batch_handler(self):
|
||||
""" """
|
||||
# TODO: call self.app.get_batch_handler()
|
||||
|
@ -135,6 +139,10 @@ class NewOrderBatchView(BatchMasterView):
|
|||
""" """
|
||||
super().configure_grid(g)
|
||||
|
||||
# store_id
|
||||
if not self.order_handler.expose_store_id():
|
||||
g.remove('store_id')
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', 'currency')
|
||||
|
||||
|
@ -142,6 +150,10 @@ class NewOrderBatchView(BatchMasterView):
|
|||
""" """
|
||||
super().configure_form(f)
|
||||
|
||||
# store_id
|
||||
if not self.order_handler.expose_store_id():
|
||||
f.remove('store_id')
|
||||
|
||||
# local_customer
|
||||
f.set_node('local_customer', LocalCustomerRef(self.request))
|
||||
|
||||
|
|
|
@ -25,7 +25,9 @@ Views for Orders
|
|||
"""
|
||||
|
||||
import decimal
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
import colander
|
||||
import sqlalchemy as sa
|
||||
|
@ -35,9 +37,9 @@ from webhelpers2.html import tags, HTML
|
|||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
|
||||
from wuttaweb.util import make_json_safe
|
||||
|
||||
from sideshow.db.model import Order, OrderItem
|
||||
from sideshow.orders import OrderHandler
|
||||
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||
from sideshow.web.forms.schema import (OrderRef,
|
||||
LocalCustomerRef, LocalProductRef,
|
||||
|
@ -65,13 +67,13 @@ class OrderView(MasterView):
|
|||
.. attribute:: order_handler
|
||||
|
||||
Reference to the :term:`order handler` as returned by
|
||||
:meth:`get_order_handler()`. This gets set in the constructor.
|
||||
:meth:`~sideshow.app.SideshowAppProvider.get_order_handler()`.
|
||||
This gets set in the constructor.
|
||||
|
||||
.. attribute:: batch_handler
|
||||
|
||||
Reference to the :term:`new order batch` handler, as returned
|
||||
by :meth:`get_batch_handler()`. This gets set in the
|
||||
constructor.
|
||||
Reference to the :term:`new order batch` handler. This gets
|
||||
set in the constructor.
|
||||
"""
|
||||
model_class = Order
|
||||
editable = False
|
||||
|
@ -145,6 +147,7 @@ class OrderView(MasterView):
|
|||
'brand_name',
|
||||
'description',
|
||||
'size',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'vendor_name',
|
||||
'vendor_item_code',
|
||||
|
@ -155,41 +158,17 @@ class OrderView(MasterView):
|
|||
|
||||
def __init__(self, request, context=None):
|
||||
super().__init__(request, context=context)
|
||||
self.order_handler = self.get_order_handler()
|
||||
|
||||
def get_order_handler(self):
|
||||
"""
|
||||
Returns the configured :term:`order handler`.
|
||||
|
||||
You normally would not need to call this, and can use
|
||||
:attr:`order_handler` instead.
|
||||
|
||||
:rtype: :class:`~sideshow.orders.OrderHandler`
|
||||
"""
|
||||
if hasattr(self, 'order_handler'):
|
||||
return self.order_handler
|
||||
return OrderHandler(self.config)
|
||||
|
||||
def get_batch_handler(self):
|
||||
"""
|
||||
Returns the configured :term:`handler` for :term:`new order
|
||||
batches <new order batch>`.
|
||||
|
||||
You normally would not need to call this, and can use
|
||||
:attr:`batch_handler` instead.
|
||||
|
||||
:returns:
|
||||
:class:`~sideshow.batch.neworder.NewOrderBatchHandler`
|
||||
instance.
|
||||
"""
|
||||
if hasattr(self, 'batch_handler'):
|
||||
return self.batch_handler
|
||||
return self.app.get_batch_handler('neworder')
|
||||
self.order_handler = self.app.get_order_handler()
|
||||
self.batch_handler = self.app.get_batch_handler('neworder')
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
||||
# store_id
|
||||
if not self.order_handler.expose_store_id():
|
||||
g.remove('store_id')
|
||||
|
||||
# order_id
|
||||
g.set_link('order_id')
|
||||
|
||||
|
@ -223,6 +202,7 @@ class OrderView(MasterView):
|
|||
|
||||
* :meth:`start_over()`
|
||||
* :meth:`cancel_order()`
|
||||
* :meth:`set_store()`
|
||||
* :meth:`assign_customer()`
|
||||
* :meth:`unassign_customer()`
|
||||
* :meth:`set_pending_customer()`
|
||||
|
@ -232,10 +212,11 @@ class OrderView(MasterView):
|
|||
* :meth:`delete_item()`
|
||||
* :meth:`submit_order()`
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
self.creating = True
|
||||
self.batch_handler = self.get_batch_handler()
|
||||
session = self.Session()
|
||||
batch = self.get_current_batch()
|
||||
self.creating = True
|
||||
|
||||
context = self.get_context_customer(batch)
|
||||
|
||||
|
@ -254,6 +235,7 @@ class OrderView(MasterView):
|
|||
data = dict(self.request.json_body)
|
||||
action = data.pop('action')
|
||||
json_actions = [
|
||||
'set_store',
|
||||
'assign_customer',
|
||||
'unassign_customer',
|
||||
# 'update_phone_number',
|
||||
|
@ -262,7 +244,7 @@ class OrderView(MasterView):
|
|||
# 'get_customer_info',
|
||||
# # 'set_customer_data',
|
||||
'get_product_info',
|
||||
# 'get_past_items',
|
||||
'get_past_products',
|
||||
'add_item',
|
||||
'update_item',
|
||||
'delete_item',
|
||||
|
@ -283,20 +265,36 @@ class OrderView(MasterView):
|
|||
'normalized_batch': self.normalize_batch(batch),
|
||||
'order_items': [self.normalize_row(row)
|
||||
for row in batch.rows],
|
||||
'default_uom_choices': self.get_default_uom_choices(),
|
||||
'default_uom_choices': self.batch_handler.get_default_uom_choices(),
|
||||
'default_uom': None, # TODO?
|
||||
'expose_store_id': self.order_handler.expose_store_id(),
|
||||
'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(),
|
||||
'allow_past_item_reorder': True, # TODO: make configurable?
|
||||
})
|
||||
|
||||
if context['expose_store_id']:
|
||||
stores = session.query(model.Store)\
|
||||
.filter(model.Store.archived == False)\
|
||||
.order_by(model.Store.store_id)\
|
||||
.all()
|
||||
context['stores'] = [{'store_id': store.store_id, 'display': store.get_display()}
|
||||
for store in stores]
|
||||
|
||||
# set default so things just work
|
||||
if not batch.store_id:
|
||||
batch.store_id = self.batch_handler.get_default_store_id()
|
||||
|
||||
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())
|
||||
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)
|
||||
|
||||
|
@ -352,7 +350,7 @@ class OrderView(MasterView):
|
|||
if not term:
|
||||
return []
|
||||
|
||||
handler = self.get_batch_handler()
|
||||
handler = self.batch_handler
|
||||
if handler.use_local_customers():
|
||||
return handler.autocomplete_customers_local(session, term, user=self.request.user)
|
||||
else:
|
||||
|
@ -376,7 +374,7 @@ class OrderView(MasterView):
|
|||
if not term:
|
||||
return []
|
||||
|
||||
handler = self.get_batch_handler()
|
||||
handler = self.batch_handler
|
||||
if handler.use_local_products():
|
||||
return handler.autocomplete_products_local(session, term, user=self.request.user)
|
||||
else:
|
||||
|
@ -394,6 +392,43 @@ class OrderView(MasterView):
|
|||
required.append(field)
|
||||
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):
|
||||
"""
|
||||
This will delete the user's current batch, then redirect user
|
||||
|
@ -436,9 +471,26 @@ class OrderView(MasterView):
|
|||
url = self.get_index_url()
|
||||
return self.redirect(url)
|
||||
|
||||
def set_store(self, batch, data):
|
||||
"""
|
||||
Assign the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.store_id`
|
||||
for a batch.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
store_id = data.get('store_id')
|
||||
if not store_id:
|
||||
return {'error': "Must provide store_id"}
|
||||
|
||||
batch.store_id = store_id
|
||||
return self.get_context_customer(batch)
|
||||
|
||||
def get_context_customer(self, batch):
|
||||
""" """
|
||||
context = {
|
||||
'store_id': batch.store_id,
|
||||
'customer_is_known': True,
|
||||
'customer_id': None,
|
||||
'customer_name': batch.customer_name,
|
||||
|
@ -531,18 +583,20 @@ class OrderView(MasterView):
|
|||
|
||||
def get_product_info(self, batch, data):
|
||||
"""
|
||||
Fetch data for a specific product. (Nothing is modified.)
|
||||
Fetch data for a specific product.
|
||||
|
||||
Depending on config, this will fetch a :term:`local product`
|
||||
or :term:`external product` to get the data.
|
||||
Depending on config, this calls one of the following to get
|
||||
its primary data:
|
||||
|
||||
This should invoke a configured handler for the query
|
||||
behavior, but that is not yet implemented. For now it uses
|
||||
built-in logic only, which queries the
|
||||
:class:`~sideshow.db.model.products.LocalProduct` table.
|
||||
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_local()`
|
||||
* :meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
|
||||
|
||||
It then may supplement the data with additional fields.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
|
||||
:returns: Dict of product info.
|
||||
"""
|
||||
product_id = data.get('product_id')
|
||||
if not product_id:
|
||||
|
@ -574,9 +628,6 @@ 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',
|
||||
|
@ -593,6 +644,22 @@ class OrderView(MasterView):
|
|||
|
||||
return data
|
||||
|
||||
def get_past_products(self, batch, data):
|
||||
"""
|
||||
Fetch past products for convenient re-ordering.
|
||||
|
||||
This essentially calls
|
||||
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_past_products()`
|
||||
on the :attr:`batch_handler` and returns the result.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
|
||||
:returns: List of product info dicts.
|
||||
"""
|
||||
past_products = self.batch_handler.get_past_products(batch)
|
||||
return make_json_safe(past_products)
|
||||
|
||||
def add_item(self, batch, data):
|
||||
"""
|
||||
This adds a row to the user's current new order batch.
|
||||
|
@ -709,12 +776,6 @@ class OrderView(MasterView):
|
|||
'status_text': batch.status_text,
|
||||
}
|
||||
|
||||
def get_default_uom_choices(self):
|
||||
""" """
|
||||
enum = self.app.enum
|
||||
return [{'key': key, 'value': val}
|
||||
for key, val in enum.ORDER_UOM.items()]
|
||||
|
||||
def normalize_row(self, row):
|
||||
""" """
|
||||
data = {
|
||||
|
@ -729,12 +790,12 @@ class OrderView(MasterView):
|
|||
row.product_description,
|
||||
row.product_size),
|
||||
'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,
|
||||
'case_size': float(row.case_size) if row.case_size is not None else None,
|
||||
'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),
|
||||
|
@ -810,6 +871,10 @@ class OrderView(MasterView):
|
|||
super().configure_form(f)
|
||||
order = f.model_instance
|
||||
|
||||
# store_id
|
||||
if not self.order_handler.expose_store_id():
|
||||
f.remove('store_id')
|
||||
|
||||
# local_customer
|
||||
if order.customer_id and not order.local_customer:
|
||||
f.remove('local_customer')
|
||||
|
@ -910,8 +975,10 @@ class OrderView(MasterView):
|
|||
""" """
|
||||
settings = [
|
||||
|
||||
# batches
|
||||
{'name': 'wutta.batch.neworder.handler.spec'},
|
||||
# stores
|
||||
{'name': 'sideshow.orders.expose_store_id',
|
||||
'type': bool},
|
||||
{'name': 'sideshow.orders.default_store_id'},
|
||||
|
||||
# customers
|
||||
{'name': 'sideshow.orders.use_local_customers',
|
||||
|
@ -920,12 +987,6 @@ 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,
|
||||
|
@ -933,6 +994,17 @@ class OrderView(MasterView):
|
|||
{'name': 'sideshow.orders.allow_unknown_products',
|
||||
'type': bool,
|
||||
'default': True},
|
||||
|
||||
# pricing
|
||||
{'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},
|
||||
|
||||
# batches
|
||||
{'name': 'wutta.batch.neworder.handler.spec'},
|
||||
]
|
||||
|
||||
# required fields for new product entry
|
||||
|
@ -955,8 +1027,39 @@ class OrderView(MasterView):
|
|||
handlers = [{'spec': spec} for spec in handlers]
|
||||
context['batch_handlers'] = handlers
|
||||
|
||||
context['dept_item_discounts'] = self.get_dept_item_discounts()
|
||||
|
||||
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
|
||||
def defaults(cls, config):
|
||||
cls._order_defaults(config)
|
||||
|
@ -1037,6 +1140,7 @@ class OrderItemView(MasterView):
|
|||
|
||||
labels = {
|
||||
'order_id': "Order ID",
|
||||
'store_id': "Store ID",
|
||||
'product_id': "Product ID",
|
||||
'product_scancode': "Scancode",
|
||||
'product_brand': "Brand",
|
||||
|
@ -1050,6 +1154,7 @@ class OrderItemView(MasterView):
|
|||
|
||||
grid_columns = [
|
||||
'order_id',
|
||||
'store_id',
|
||||
'customer_name',
|
||||
# 'sequence',
|
||||
'product_scancode',
|
||||
|
@ -1099,20 +1204,7 @@ class OrderItemView(MasterView):
|
|||
|
||||
def __init__(self, request, context=None):
|
||||
super().__init__(request, context=context)
|
||||
self.order_handler = self.get_order_handler()
|
||||
|
||||
def get_order_handler(self):
|
||||
"""
|
||||
Returns the configured :term:`order handler`.
|
||||
|
||||
You normally would not need to call this, and can use
|
||||
:attr:`order_handler` instead.
|
||||
|
||||
:rtype: :class:`~sideshow.orders.OrderHandler`
|
||||
"""
|
||||
if hasattr(self, 'order_handler'):
|
||||
return self.order_handler
|
||||
return OrderHandler(self.config)
|
||||
self.order_handler = self.app.get_order_handler()
|
||||
|
||||
def get_fallback_templates(self, template):
|
||||
""" """
|
||||
|
@ -1132,11 +1224,19 @@ class OrderItemView(MasterView):
|
|||
model = self.app.model
|
||||
# enum = self.app.enum
|
||||
|
||||
# store_id
|
||||
if not self.order_handler.expose_store_id():
|
||||
g.remove('store_id')
|
||||
|
||||
# order_id
|
||||
g.set_sorter('order_id', model.Order.order_id)
|
||||
g.set_renderer('order_id', self.render_order_id)
|
||||
g.set_renderer('order_id', self.render_order_attr)
|
||||
g.set_link('order_id')
|
||||
|
||||
# store_id
|
||||
g.set_sorter('store_id', model.Order.store_id)
|
||||
g.set_renderer('store_id', self.render_order_attr)
|
||||
|
||||
# customer_name
|
||||
g.set_label('customer_name', "Customer", column_only=True)
|
||||
|
||||
|
@ -1165,9 +1265,10 @@ class OrderItemView(MasterView):
|
|||
# status_code
|
||||
g.set_renderer('status_code', self.render_status_code)
|
||||
|
||||
def render_order_id(self, item, key, value):
|
||||
def render_order_attr(self, item, key, value):
|
||||
""" """
|
||||
return item.order.order_id
|
||||
order = item.order
|
||||
return getattr(order, key)
|
||||
|
||||
def render_status_code(self, item, key, value):
|
||||
""" """
|
||||
|
@ -1237,6 +1338,8 @@ class OrderItemView(MasterView):
|
|||
item = context['instance']
|
||||
form = context['form']
|
||||
|
||||
context['expose_store_id'] = self.order_handler.expose_store_id()
|
||||
|
||||
context['item'] = item
|
||||
context['order'] = item.order
|
||||
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
|
||||
|
|
|
@ -235,6 +235,7 @@ class PendingProductView(MasterView):
|
|||
url_prefix = '/pending/products'
|
||||
|
||||
labels = {
|
||||
'department_id': "Department ID",
|
||||
'product_id': "Product ID",
|
||||
}
|
||||
|
||||
|
|
120
src/sideshow/web/views/stores.py
Normal file
120
src/sideshow/web/views/stores.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
################################################################################
|
||||
#
|
||||
# Sideshow -- Case/Special Order Tracker
|
||||
# Copyright © 2024-2025 Lance Edgar
|
||||
#
|
||||
# This file is part of Sideshow.
|
||||
#
|
||||
# Sideshow is free software: you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Sideshow is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Sideshow. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Views for Stores
|
||||
"""
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
|
||||
from sideshow.db.model import Store
|
||||
|
||||
|
||||
class StoreView(MasterView):
|
||||
"""
|
||||
Master view for
|
||||
:class:`~sideshow.db.model.stores.Store`; route prefix
|
||||
is ``stores``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/stores/``
|
||||
* ``/stores/new``
|
||||
* ``/stores/XXX``
|
||||
* ``/stores/XXX/edit``
|
||||
* ``/stores/XXX/delete``
|
||||
"""
|
||||
model_class = Store
|
||||
|
||||
labels = {
|
||||
'store_id': "Store ID",
|
||||
}
|
||||
|
||||
filter_defaults = {
|
||||
'archived': {'active': True, 'verb': 'is_false'},
|
||||
}
|
||||
|
||||
sort_defaults = 'store_id'
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
||||
# links
|
||||
g.set_link('store_id')
|
||||
g.set_link('name')
|
||||
|
||||
def grid_row_class(self, store, data, i):
|
||||
""" """
|
||||
if store.archived:
|
||||
return 'has-background-warning'
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
|
||||
# store_id
|
||||
f.set_validator('store_id', self.unique_store_id)
|
||||
|
||||
# name
|
||||
f.set_validator('name', self.unique_name)
|
||||
|
||||
def unique_store_id(self, node, value):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
query = session.query(model.Store)\
|
||||
.filter(model.Store.store_id == value)
|
||||
|
||||
if self.editing:
|
||||
uuid = self.request.matchdict['uuid']
|
||||
query = query.filter(model.Store.uuid != uuid)
|
||||
|
||||
if query.count():
|
||||
node.raise_invalid("Store ID must be unique")
|
||||
|
||||
def unique_name(self, node, value):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
query = session.query(model.Store)\
|
||||
.filter(model.Store.name == value)
|
||||
|
||||
if self.editing:
|
||||
uuid = self.request.matchdict['uuid']
|
||||
query = query.filter(model.Store.uuid != uuid)
|
||||
|
||||
if query.count():
|
||||
node.raise_invalid("Name must be unique")
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
StoreView = kwargs.get('StoreView', base['StoreView'])
|
||||
StoreView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
|
@ -4,6 +4,8 @@ import datetime
|
|||
import decimal
|
||||
from unittest.mock import patch
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from wuttjamaican.testing import DataTestCase
|
||||
|
||||
from sideshow.batch import neworder as mod
|
||||
|
@ -20,6 +22,16 @@ class TestNewOrderBatchHandler(DataTestCase):
|
|||
def make_handler(self):
|
||||
return mod.NewOrderBatchHandler(self.config)
|
||||
|
||||
def test_get_default_store_id(self):
|
||||
handler = self.make_handler()
|
||||
|
||||
# null by default
|
||||
self.assertIsNone(handler.get_default_store_id())
|
||||
|
||||
# whatever is configured
|
||||
self.config.setdefault('sideshow.orders.default_store_id', '042')
|
||||
self.assertEqual(handler.get_default_store_id(), '042')
|
||||
|
||||
def test_use_local_customers(self):
|
||||
handler = self.make_handler()
|
||||
|
||||
|
@ -108,6 +120,23 @@ class TestNewOrderBatchHandler(DataTestCase):
|
|||
# search for sally finds nothing
|
||||
self.assertEqual(handler.autocomplete_customers_local(self.session, 'sally'), [])
|
||||
|
||||
def test_init_batch(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
||||
# store_id is null by default
|
||||
batch = handler.model_class()
|
||||
self.assertIsNone(batch.store_id)
|
||||
handler.init_batch(batch)
|
||||
self.assertIsNone(batch.store_id)
|
||||
|
||||
# but default can be configured
|
||||
self.config.setdefault('sideshow.orders.default_store_id', '042')
|
||||
batch = handler.model_class()
|
||||
self.assertIsNone(batch.store_id)
|
||||
handler.init_batch(batch)
|
||||
self.assertEqual(batch.store_id, '042')
|
||||
|
||||
def test_set_customer(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
@ -240,6 +269,14 @@ class TestNewOrderBatchHandler(DataTestCase):
|
|||
# search for juice finds nothing
|
||||
self.assertEqual(handler.autocomplete_products_local(self.session, 'juice'), [])
|
||||
|
||||
def test_get_default_uom_choices(self):
|
||||
enum = self.app.enum
|
||||
handler = self.make_handler()
|
||||
|
||||
uoms = handler.get_default_uom_choices()
|
||||
self.assertEqual(uoms, [{'key': key, 'value': val}
|
||||
for key, val in enum.ORDER_UOM.items()])
|
||||
|
||||
def test_get_product_info_external(self):
|
||||
handler = self.make_handler()
|
||||
self.assertRaises(NotImplementedError, handler.get_product_info_external,
|
||||
|
@ -281,6 +318,174 @@ class TestNewOrderBatchHandler(DataTestCase):
|
|||
mock_uuid = self.app.make_true_uuid()
|
||||
self.assertRaises(ValueError, handler.get_product_info_local, self.session, mock_uuid.hex)
|
||||
|
||||
def test_normalize_local_product(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
||||
product = model.LocalProduct(scancode='07430500132',
|
||||
brand_name="Bragg's",
|
||||
description="Apple Cider Vinegar",
|
||||
size="32oz",
|
||||
department_name="Grocery",
|
||||
case_size=12,
|
||||
unit_price_reg=5.99,
|
||||
vendor_name="UNFI",
|
||||
vendor_item_code='1234')
|
||||
self.session.add(product)
|
||||
self.session.flush()
|
||||
|
||||
info = handler.normalize_local_product(product)
|
||||
self.assertIsInstance(info, dict)
|
||||
self.assertEqual(info['product_id'], product.uuid.hex)
|
||||
for prop in sa.inspect(model.LocalProduct).column_attrs:
|
||||
if prop.key == 'uuid':
|
||||
continue
|
||||
if prop.key not in info:
|
||||
continue
|
||||
self.assertEqual(info[prop.key], getattr(product, prop.key))
|
||||
|
||||
def test_get_past_orders(self):
|
||||
model = self.app.model
|
||||
handler = self.make_handler()
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
# ..will test local customers first
|
||||
|
||||
# error if no customer
|
||||
self.assertRaises(ValueError, handler.get_past_orders, batch)
|
||||
|
||||
# empty history for customer
|
||||
customer = model.LocalCustomer(full_name='Fred Flintstone')
|
||||
batch.local_customer = customer
|
||||
self.session.flush()
|
||||
orders = handler.get_past_orders(batch)
|
||||
self.assertEqual(len(orders), 0)
|
||||
|
||||
# mock historical order
|
||||
order = model.Order(order_id=42, local_customer=customer, created_by=user)
|
||||
self.session.add(order)
|
||||
self.session.flush()
|
||||
|
||||
# that should now be returned
|
||||
orders = handler.get_past_orders(batch)
|
||||
self.assertEqual(len(orders), 1)
|
||||
self.assertIs(orders[0], order)
|
||||
|
||||
# ..now we test external customers, w/ new batch
|
||||
with patch.object(handler, 'use_local_customers', return_value=False):
|
||||
batch2 = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch2)
|
||||
self.session.flush()
|
||||
|
||||
# error if no customer
|
||||
self.assertRaises(ValueError, handler.get_past_orders, batch2)
|
||||
|
||||
# empty history for customer
|
||||
batch2.customer_id = '123'
|
||||
self.session.flush()
|
||||
orders = handler.get_past_orders(batch2)
|
||||
self.assertEqual(len(orders), 0)
|
||||
|
||||
# mock historical order
|
||||
order2 = model.Order(order_id=42, customer_id='123', created_by=user)
|
||||
self.session.add(order2)
|
||||
self.session.flush()
|
||||
|
||||
# that should now be returned
|
||||
orders = handler.get_past_orders(batch2)
|
||||
self.assertEqual(len(orders), 1)
|
||||
self.assertIs(orders[0], order2)
|
||||
|
||||
def test_get_past_products(self):
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
handler = self.make_handler()
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
# (nb. this all assumes local customers)
|
||||
|
||||
# ..will test local products first
|
||||
|
||||
# error if no customer
|
||||
self.assertRaises(ValueError, handler.get_past_products, batch)
|
||||
|
||||
# empty history for customer
|
||||
customer = model.LocalCustomer(full_name='Fred Flintstone')
|
||||
batch.local_customer = customer
|
||||
self.session.flush()
|
||||
products = handler.get_past_products(batch)
|
||||
self.assertEqual(len(products), 0)
|
||||
|
||||
# mock historical order
|
||||
order = model.Order(order_id=42, local_customer=customer, created_by=user)
|
||||
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
|
||||
unit_price_reg=5.99, case_size=12)
|
||||
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
|
||||
status_code=enum.ORDER_ITEM_STATUS_READY)
|
||||
order.items.append(item)
|
||||
self.session.add(order)
|
||||
self.session.flush()
|
||||
self.session.refresh(product)
|
||||
|
||||
# that should now be returned
|
||||
products = handler.get_past_products(batch)
|
||||
self.assertEqual(len(products), 1)
|
||||
self.assertEqual(products[0]['product_id'], product.uuid.hex)
|
||||
self.assertEqual(products[0]['scancode'], '07430500132')
|
||||
self.assertEqual(products[0]['description'], 'Vinegar')
|
||||
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('71.88'))
|
||||
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
|
||||
|
||||
# ..now we test external products, w/ new batch
|
||||
with patch.object(handler, 'use_local_products', return_value=False):
|
||||
batch2 = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch2)
|
||||
self.session.flush()
|
||||
|
||||
# error if no customer
|
||||
self.assertRaises(ValueError, handler.get_past_products, batch2)
|
||||
|
||||
# empty history for customer
|
||||
batch2.local_customer = customer
|
||||
self.session.flush()
|
||||
products = handler.get_past_products(batch2)
|
||||
self.assertEqual(len(products), 0)
|
||||
|
||||
# mock historical order
|
||||
order2 = model.Order(order_id=44, local_customer=customer, created_by=user)
|
||||
self.session.add(order2)
|
||||
item2 = model.OrderItem(product_id='07430500116',
|
||||
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
|
||||
status_code=enum.ORDER_ITEM_STATUS_READY)
|
||||
order2.items.append(item2)
|
||||
self.session.flush()
|
||||
|
||||
# its product should now be returned
|
||||
with patch.object(handler, 'get_product_info_external', return_value={
|
||||
'product_id': '07430500116',
|
||||
'scancode': '07430500116',
|
||||
'description': 'VINEGAR',
|
||||
'unit_price_reg': decimal.Decimal('3.99'),
|
||||
'case_size': 12,
|
||||
}):
|
||||
products = handler.get_past_products(batch2)
|
||||
self.assertEqual(len(products), 1)
|
||||
self.assertEqual(products[0]['product_id'], '07430500116')
|
||||
self.assertEqual(products[0]['scancode'], '07430500116')
|
||||
self.assertEqual(products[0]['description'], 'VINEGAR')
|
||||
self.assertEqual(products[0]['case_price_quoted'], decimal.Decimal('47.88'))
|
||||
self.assertEqual(products[0]['case_price_quoted_display'], '$47.88')
|
||||
|
||||
def test_add_item(self):
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
|
|
28
tests/db/model/test_stores.py
Normal file
28
tests/db/model/test_stores.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from wuttjamaican.testing import DataTestCase
|
||||
|
||||
from sideshow.db.model import stores as mod
|
||||
|
||||
|
||||
class TestPendingCustomer(DataTestCase):
|
||||
|
||||
def test_str(self):
|
||||
store = mod.Store()
|
||||
self.assertEqual(str(store), "")
|
||||
|
||||
store.name = "Acme Goods"
|
||||
self.assertEqual(str(store), "Acme Goods")
|
||||
|
||||
store.store_id = "001"
|
||||
self.assertEqual(str(store), "001 Acme Goods")
|
||||
|
||||
def test_get_display(self):
|
||||
store = mod.Store()
|
||||
self.assertEqual(store.get_display(), "")
|
||||
|
||||
store.name = "Acme Goods"
|
||||
self.assertEqual(store.get_display(), "Acme Goods")
|
||||
|
||||
store.store_id = "001"
|
||||
self.assertEqual(store.get_display(), "001 Acme Goods")
|
17
tests/test_app.py
Normal file
17
tests/test_app.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from wuttjamaican.testing import ConfigTestCase
|
||||
|
||||
from sideshow import app as mod
|
||||
from sideshow.orders import OrderHandler
|
||||
|
||||
|
||||
class TestSideshowAppProvider(ConfigTestCase):
|
||||
|
||||
def make_provider(self):
|
||||
return mod.SideshowAppProvider(self.config)
|
||||
|
||||
def test_get_order_handler(self):
|
||||
provider = self.make_provider()
|
||||
handler = provider.get_order_handler()
|
||||
self.assertIsInstance(handler, OrderHandler)
|
|
@ -16,6 +16,16 @@ class TestOrderHandler(DataTestCase):
|
|||
def make_handler(self):
|
||||
return mod.OrderHandler(self.config)
|
||||
|
||||
def test_expose_store_id(self):
|
||||
handler = self.make_handler()
|
||||
|
||||
# false by default
|
||||
self.assertFalse(handler.expose_store_id())
|
||||
|
||||
# config can enable
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
self.assertTrue(handler.expose_store_id())
|
||||
|
||||
def test_get_order_qty_uom_text(self):
|
||||
enum = self.app.enum
|
||||
handler = self.make_handler()
|
||||
|
|
|
@ -30,10 +30,19 @@ class TestNewOrderBatchView(WebTestCase):
|
|||
def test_configure_grid(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
# store_id not exposed by default
|
||||
grid = view.make_grid(model_class=model.NewOrderBatch)
|
||||
self.assertNotIn('total_price', grid.renderers)
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('total_price', grid.renderers)
|
||||
self.assertNotIn('store_id', grid.columns)
|
||||
|
||||
# store_id is exposed if configured
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
grid = view.make_grid(model_class=model.NewOrderBatch)
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('store_id', grid.columns)
|
||||
|
||||
def test_configure_form(self):
|
||||
model = self.app.model
|
||||
|
@ -58,6 +67,19 @@ class TestNewOrderBatchView(WebTestCase):
|
|||
self.assertIsInstance(schema['pending_customer'].typ, PendingCustomerRef)
|
||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||
|
||||
# store_id not exposed by default
|
||||
form = view.make_form(model_instance=batch)
|
||||
self.assertIn('store_id', form)
|
||||
view.configure_form(form)
|
||||
self.assertNotIn('store_id', form)
|
||||
|
||||
# store_id is exposed if configured
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
form = view.make_form(model_instance=batch)
|
||||
self.assertIn('store_id', form)
|
||||
view.configure_form(form)
|
||||
self.assertIn('store_id', form)
|
||||
|
||||
def test_configure_row_grid(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import datetime
|
||||
import decimal
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import orm
|
||||
|
@ -15,6 +16,7 @@ from sideshow.orders import OrderHandler
|
|||
from sideshow.testing import WebTestCase
|
||||
from sideshow.web.views import orders as mod
|
||||
from sideshow.web.forms.schema import OrderRef, PendingProductRef
|
||||
from sideshow.config import SideshowConfig
|
||||
|
||||
|
||||
class TestIncludeme(WebTestCase):
|
||||
|
@ -25,33 +27,39 @@ class TestIncludeme(WebTestCase):
|
|||
|
||||
class TestOrderView(WebTestCase):
|
||||
|
||||
def make_config(self, **kw):
|
||||
config = super().make_config(**kw)
|
||||
SideshowConfig().configure(config)
|
||||
return config
|
||||
|
||||
def make_view(self):
|
||||
return mod.OrderView(self.request)
|
||||
|
||||
def make_handler(self):
|
||||
return NewOrderBatchHandler(self.config)
|
||||
|
||||
def test_order_handler(self):
|
||||
view = self.make_view()
|
||||
handler = view.order_handler
|
||||
self.assertIsInstance(handler, OrderHandler)
|
||||
handler2 = view.get_order_handler()
|
||||
self.assertIs(handler2, handler)
|
||||
|
||||
def test_configure_grid(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
grid = view.make_grid(model_class=model.PendingProduct)
|
||||
self.assertNotIn('order_id', grid.linked_columns)
|
||||
self.assertNotIn('total_price', grid.renderers)
|
||||
|
||||
# store_id hidden by default
|
||||
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('order_id', grid.linked_columns)
|
||||
self.assertIn('total_price', grid.renderers)
|
||||
self.assertNotIn('store_id', grid.columns)
|
||||
|
||||
# store_id is shown if configured
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
grid = view.make_grid(model_class=model.Order, columns=['store_id', 'order_id'])
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('store_id', grid.columns)
|
||||
|
||||
def test_create(self):
|
||||
self.pyramid_config.include('sideshow.web.views')
|
||||
self.config.setdefault('wutta.batch.neworder.handler.spec',
|
||||
'sideshow.batch.neworder:NewOrderBatchHandler')
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
self.config.setdefault('sideshow.orders.allow_item_discounts', 'true')
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
|
@ -59,6 +67,10 @@ class TestOrderView(WebTestCase):
|
|||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
store = model.Store(store_id='001', name='Acme Goods')
|
||||
self.session.add(store)
|
||||
store = model.Store(store_id='002', name='Acme Services')
|
||||
self.session.add(store)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
|
@ -101,6 +113,7 @@ class TestOrderView(WebTestCase):
|
|||
self.assertIsInstance(response, Response)
|
||||
self.assertEqual(response.content_type, 'application/json')
|
||||
self.assertEqual(response.json_body, {
|
||||
'store_id': None,
|
||||
'customer_is_known': False,
|
||||
'customer_id': None,
|
||||
'customer_name': 'Fred Flintstone',
|
||||
|
@ -285,6 +298,50 @@ class TestOrderView(WebTestCase):
|
|||
fields = view.get_pending_product_required_fields()
|
||||
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):
|
||||
self.pyramid_config.add_route('orders', '/orders/')
|
||||
model = self.app.model
|
||||
|
@ -304,6 +361,7 @@ class TestOrderView(WebTestCase):
|
|||
self.session.flush()
|
||||
context = view.get_context_customer(batch)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': True,
|
||||
'customer_id': 42,
|
||||
'customer_name': 'Fred Flintstone',
|
||||
|
@ -321,6 +379,7 @@ class TestOrderView(WebTestCase):
|
|||
self.session.flush()
|
||||
context = view.get_context_customer(batch)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': True,
|
||||
'customer_id': local.uuid.hex,
|
||||
'customer_name': 'Betty Boop',
|
||||
|
@ -339,6 +398,7 @@ class TestOrderView(WebTestCase):
|
|||
self.session.flush()
|
||||
context = view.get_context_customer(batch)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': False,
|
||||
'customer_id': None,
|
||||
'customer_name': 'Fred Flintstone',
|
||||
|
@ -357,6 +417,7 @@ class TestOrderView(WebTestCase):
|
|||
self.session.flush()
|
||||
context = view.get_context_customer(batch)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': True, # nb. this is for UI default
|
||||
'customer_id': None,
|
||||
'customer_name': None,
|
||||
|
@ -408,6 +469,34 @@ class TestOrderView(WebTestCase):
|
|||
self.session.flush()
|
||||
self.assertEqual(self.session.query(model.NewOrderBatch).count(), 0)
|
||||
|
||||
def test_set_store(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
handler = NewOrderBatchHandler(self.config)
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
self.session.flush()
|
||||
|
||||
with patch.object(view, 'batch_handler', create=True, new=handler):
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
with patch.object(self.request, 'user', new=user):
|
||||
|
||||
batch = view.get_current_batch()
|
||||
self.assertIsNone(batch.store_id)
|
||||
|
||||
# store_id is required
|
||||
result = view.set_store(batch, {})
|
||||
self.assertEqual(result, {'error': "Must provide store_id"})
|
||||
result = view.set_store(batch, {'store_id': ''})
|
||||
self.assertEqual(result, {'error': "Must provide store_id"})
|
||||
|
||||
# store_id is set on batch
|
||||
result = view.set_store(batch, {'store_id': '042'})
|
||||
self.assertEqual(batch.store_id, '042')
|
||||
self.assertIn('store_id', result)
|
||||
self.assertEqual(result['store_id'], '042')
|
||||
|
||||
def test_assign_customer(self):
|
||||
self.pyramid_config.add_route('orders.create', '/orders/new')
|
||||
model = self.app.model
|
||||
|
@ -432,6 +521,7 @@ class TestOrderView(WebTestCase):
|
|||
self.assertIsNone(batch.pending_customer)
|
||||
self.assertIs(batch.local_customer, weirdal)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': True,
|
||||
'customer_id': weirdal.uuid.hex,
|
||||
'customer_name': 'Weird Al',
|
||||
|
@ -470,6 +560,7 @@ class TestOrderView(WebTestCase):
|
|||
self.assertIsNone(batch.customer_name)
|
||||
self.assertIsNone(batch.local_customer)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': True,
|
||||
'customer_id': None,
|
||||
'customer_name': None,
|
||||
|
@ -510,6 +601,7 @@ class TestOrderView(WebTestCase):
|
|||
context = view.set_pending_customer(batch, data)
|
||||
self.assertIsInstance(batch.pending_customer, model.PendingCustomer)
|
||||
self.assertEqual(context, {
|
||||
'store_id': None,
|
||||
'customer_is_known': False,
|
||||
'customer_id': None,
|
||||
'customer_name': 'Fred Flintstone',
|
||||
|
@ -575,6 +667,51 @@ class TestOrderView(WebTestCase):
|
|||
context = view.get_product_info(batch, {'product_id': '42'})
|
||||
self.assertEqual(context, {'error': "something smells fishy"})
|
||||
|
||||
def test_get_past_products(self):
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
view = self.make_view()
|
||||
handler = view.batch_handler
|
||||
|
||||
user = model.User(username='barney')
|
||||
self.session.add(user)
|
||||
batch = handler.make_batch(self.session, created_by=user)
|
||||
self.session.add(batch)
|
||||
self.session.flush()
|
||||
|
||||
# (nb. this all assumes local customers and products)
|
||||
|
||||
# error if no customer
|
||||
self.assertRaises(ValueError, view.get_past_products, batch, {})
|
||||
|
||||
# empty history for customer
|
||||
customer = model.LocalCustomer(full_name='Fred Flintstone')
|
||||
batch.local_customer = customer
|
||||
self.session.flush()
|
||||
products = view.get_past_products(batch, {})
|
||||
self.assertEqual(len(products), 0)
|
||||
|
||||
# mock historical order
|
||||
order = model.Order(order_id=42, local_customer=customer, created_by=user)
|
||||
product = model.LocalProduct(scancode='07430500132', description='Vinegar',
|
||||
unit_price_reg=5.99, case_size=12)
|
||||
item = model.OrderItem(local_product=product, order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
|
||||
status_code=enum.ORDER_ITEM_STATUS_READY)
|
||||
order.items.append(item)
|
||||
self.session.add(order)
|
||||
self.session.flush()
|
||||
self.session.refresh(product)
|
||||
|
||||
# that should now be returned
|
||||
products = view.get_past_products(batch, {})
|
||||
self.assertEqual(len(products), 1)
|
||||
self.assertEqual(products[0]['product_id'], product.uuid.hex)
|
||||
self.assertEqual(products[0]['scancode'], '07430500132')
|
||||
self.assertEqual(products[0]['description'], 'Vinegar')
|
||||
# nb. this is a float, since result is JSON-safe
|
||||
self.assertEqual(products[0]['case_price_quoted'], 71.88)
|
||||
self.assertEqual(products[0]['case_price_quoted_display'], '$71.88')
|
||||
|
||||
def test_add_item(self):
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
|
@ -825,14 +962,6 @@ class TestOrderView(WebTestCase):
|
|||
'error': f"ValueError: batch has already been executed: {batch}",
|
||||
})
|
||||
|
||||
def test_get_default_uom_choices(self):
|
||||
enum = self.app.enum
|
||||
view = self.make_view()
|
||||
|
||||
uoms = view.get_default_uom_choices()
|
||||
self.assertEqual(uoms, [{'key': key, 'value': val}
|
||||
for key, val in enum.ORDER_UOM.items()])
|
||||
|
||||
def test_normalize_batch(self):
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
|
@ -1078,7 +1207,11 @@ class TestOrderView(WebTestCase):
|
|||
form = view.make_form(model_instance=order)
|
||||
# nb. this is to avoid include/exclude ambiguity
|
||||
form.remove('items')
|
||||
# nb. store_id gets hidden by default
|
||||
form.append('store_id')
|
||||
self.assertIn('store_id', form)
|
||||
view.configure_form(form)
|
||||
self.assertNotIn('store_id', form)
|
||||
schema = form.get_schema()
|
||||
self.assertIn('pending_customer', form)
|
||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||
|
@ -1089,13 +1222,20 @@ class TestOrderView(WebTestCase):
|
|||
self.session.add(local)
|
||||
self.session.flush()
|
||||
|
||||
# nb. from now on we include store_id
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
|
||||
# viewing (local customer)
|
||||
with patch.object(view, 'viewing', new=True):
|
||||
with patch.object(order, 'local_customer', new=local):
|
||||
form = view.make_form(model_instance=order)
|
||||
# nb. this is to avoid include/exclude ambiguity
|
||||
form.remove('items')
|
||||
# nb. store_id will now remain
|
||||
form.append('store_id')
|
||||
self.assertIn('store_id', form)
|
||||
view.configure_form(form)
|
||||
self.assertIn('store_id', form)
|
||||
self.assertNotIn('pending_customer', form)
|
||||
schema = form.get_schema()
|
||||
self.assertIsInstance(schema['total_price'].typ, WuttaMoney)
|
||||
|
@ -1236,6 +1376,12 @@ class TestOrderView(WebTestCase):
|
|||
model = self.app.model
|
||||
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.multiple(self.config, usedb=True, preferdb=True):
|
||||
|
||||
|
@ -1243,7 +1389,19 @@ class TestOrderView(WebTestCase):
|
|||
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
|
||||
session=self.session)
|
||||
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
|
||||
response = view.configure()
|
||||
|
@ -1253,13 +1411,18 @@ class TestOrderView(WebTestCase):
|
|||
allowed = self.config.get_bool('sideshow.orders.allow_unknown_products',
|
||||
session=self.session)
|
||||
self.assertIsNone(allowed)
|
||||
self.assertEqual(self.session.query(model.Setting).count(), 0)
|
||||
self.assertEqual(self.session.query(model.Setting).count(), 4)
|
||||
|
||||
# post new settings
|
||||
with patch.multiple(self.request, create=True,
|
||||
method='POST',
|
||||
POST={
|
||||
'sideshow.orders.allow_unknown_products': 'true',
|
||||
'dept_item_discounts': json.dumps([{
|
||||
'department_id': '5',
|
||||
'department_name': 'Grocery',
|
||||
'default_item_discount': 10,
|
||||
}])
|
||||
}):
|
||||
response = view.configure()
|
||||
self.assertIsInstance(response, HTTPFound)
|
||||
|
@ -1268,17 +1431,17 @@ class TestOrderView(WebTestCase):
|
|||
session=self.session)
|
||||
self.assertTrue(allowed)
|
||||
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:
|
||||
|
||||
def test_common_order_handler(self):
|
||||
view = self.make_view()
|
||||
handler = view.order_handler
|
||||
self.assertIsInstance(handler, OrderHandler)
|
||||
handler2 = view.get_order_handler()
|
||||
self.assertIs(handler2, handler)
|
||||
|
||||
def test_common_get_fallback_templates(self):
|
||||
view = self.make_view()
|
||||
|
||||
|
@ -1294,18 +1457,29 @@ class OrderItemViewTestMixin:
|
|||
def test_common_configure_grid(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
grid = view.make_grid(model_class=model.OrderItem)
|
||||
self.assertNotIn('order_id', grid.linked_columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('order_id', grid.linked_columns)
|
||||
|
||||
def test_common_render_order_id(self):
|
||||
# store_id is removed by default
|
||||
grid = view.make_grid(model_class=model.OrderItem)
|
||||
grid.append('store_id')
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertNotIn('store_id', grid.columns)
|
||||
|
||||
# store_id is shown if configured
|
||||
self.config.setdefault('sideshow.orders.expose_store_id', 'true')
|
||||
grid = view.make_grid(model_class=model.OrderItem)
|
||||
grid.append('store_id')
|
||||
self.assertIn('store_id', grid.columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('store_id', grid.columns)
|
||||
|
||||
def test_common_render_order_attr(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
order = model.Order(order_id=42)
|
||||
item = model.OrderItem()
|
||||
order.items.append(item)
|
||||
self.assertEqual(view.render_order_id(item, None, None), 42)
|
||||
self.assertEqual(view.render_order_attr(item, 'order_id', None), 42)
|
||||
|
||||
def test_common_render_status_code(self):
|
||||
enum = self.app.enum
|
||||
|
|
94
tests/web/views/test_stores.py
Normal file
94
tests/web/views/test_stores.py
Normal file
|
@ -0,0 +1,94 @@
|
|||
# -*- coding: utf-8; -*-
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import colander
|
||||
|
||||
from sideshow.testing import WebTestCase
|
||||
from sideshow.web.views import stores as mod
|
||||
|
||||
|
||||
class TestIncludeme(WebTestCase):
|
||||
|
||||
def test_coverage(self):
|
||||
mod.includeme(self.pyramid_config)
|
||||
|
||||
|
||||
class TestStoreView(WebTestCase):
|
||||
|
||||
def make_view(self):
|
||||
return mod.StoreView(self.request)
|
||||
|
||||
def test_configure_grid(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
grid = view.make_grid(model_class=model.Store)
|
||||
self.assertNotIn('store_id', grid.linked_columns)
|
||||
self.assertNotIn('name', grid.linked_columns)
|
||||
view.configure_grid(grid)
|
||||
self.assertIn('store_id', grid.linked_columns)
|
||||
self.assertIn('name', grid.linked_columns)
|
||||
|
||||
def test_grid_row_class(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
store = model.Store()
|
||||
self.assertFalse(store.archived)
|
||||
self.assertIsNone(view.grid_row_class(store, {}, 0))
|
||||
|
||||
store = model.Store(archived=True)
|
||||
self.assertTrue(store.archived)
|
||||
self.assertEqual(view.grid_row_class(store, {}, 0), 'has-background-warning')
|
||||
|
||||
def test_configure_form(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
# unique validators are set
|
||||
form = view.make_form(model_class=model.Store)
|
||||
self.assertNotIn('store_id', form.validators)
|
||||
self.assertNotIn('name', form.validators)
|
||||
view.configure_form(form)
|
||||
self.assertIn('store_id', form.validators)
|
||||
self.assertIn('name', form.validators)
|
||||
|
||||
def test_unique_store_id(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
store = model.Store(store_id='001', name='whatever')
|
||||
self.session.add(store)
|
||||
self.session.commit()
|
||||
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
|
||||
# invalid if same store_id in data
|
||||
node = colander.SchemaNode(colander.String(), name='store_id')
|
||||
self.assertRaises(colander.Invalid, view.unique_store_id, node, '001')
|
||||
|
||||
# but not if store_id belongs to current store
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
|
||||
with patch.object(view, 'editing', new=True):
|
||||
node = colander.SchemaNode(colander.String(), name='store_id')
|
||||
self.assertIsNone(view.unique_store_id(node, '001'))
|
||||
|
||||
def test_unique_name(self):
|
||||
model = self.app.model
|
||||
view = self.make_view()
|
||||
|
||||
store = model.Store(store_id='001', name='Acme Goods')
|
||||
self.session.add(store)
|
||||
self.session.commit()
|
||||
|
||||
with patch.object(view, 'Session', return_value=self.session):
|
||||
|
||||
# invalid if same name in data
|
||||
node = colander.SchemaNode(colander.String(), name='name')
|
||||
self.assertRaises(colander.Invalid, view.unique_name, node, 'Acme Goods')
|
||||
|
||||
# but not if name belongs to current store
|
||||
with patch.object(self.request, 'matchdict', new={'uuid': store.uuid}):
|
||||
with patch.object(view, 'editing', new=True):
|
||||
node = colander.SchemaNode(colander.String(), name='name')
|
||||
self.assertIsNone(view.unique_name(node, 'Acme Goods'))
|
Loading…
Reference in a new issue