diff --git a/docs/api/sideshow.app.rst b/docs/api/sideshow.app.rst new file mode 100644 index 0000000..7c738b1 --- /dev/null +++ b/docs/api/sideshow.app.rst @@ -0,0 +1,6 @@ + +``sideshow.app`` +================ + +.. automodule:: sideshow.app + :members: diff --git a/docs/api/sideshow.db.model.stores.rst b/docs/api/sideshow.db.model.stores.rst new file mode 100644 index 0000000..b114a9b --- /dev/null +++ b/docs/api/sideshow.db.model.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.db.model.stores`` +============================ + +.. automodule:: sideshow.db.model.stores + :members: diff --git a/docs/api/sideshow.web.views.stores.rst b/docs/api/sideshow.web.views.stores.rst new file mode 100644 index 0000000..896a0d7 --- /dev/null +++ b/docs/api/sideshow.web.views.stores.rst @@ -0,0 +1,6 @@ + +``sideshow.web.views.stores`` +============================= + +.. automodule:: sideshow.web.views.stores + :members: diff --git a/docs/index.rst b/docs/index.rst index 29882dd..d91ae5e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e1d42a8..ff4a4bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/sideshow/app.py b/src/sideshow/app.py new file mode 100644 index 0000000..0fbcf2e --- /dev/null +++ b/src/sideshow/app.py @@ -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 . +# +################################################################################ +""" +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 diff --git a/src/sideshow/batch/neworder.py b/src/sideshow/batch/neworder.py index bfa04ea..28e2627 100644 --- a/src/sideshow/batch/neworder.py +++ b/src/sideshow/batch/neworder.py @@ -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 + ` 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 `, 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): """ diff --git a/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py new file mode 100644 index 0000000..79e6242 --- /dev/null +++ b/src/sideshow/db/alembic/versions/a4273360d379_add_stores.py @@ -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') diff --git a/src/sideshow/db/model/__init__.py b/src/sideshow/db/model/__init__.py index f53dd27..056ccfc 100644 --- a/src/sideshow/db/model/__init__.py +++ b/src/sideshow/db/model/__init__.py @@ -30,6 +30,7 @@ This namespace exposes everything from Primary :term:`data models `: +* :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 diff --git a/src/sideshow/db/model/orders.py b/src/sideshow/db/model/orders.py index 2cadeaa..5455d01 100644 --- a/src/sideshow/db/model/orders.py +++ b/src/sideshow/db/model/orders.py @@ -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. diff --git a/src/sideshow/db/model/stores.py b/src/sideshow/db/model/stores.py new file mode 100644 index 0000000..4b01d02 --- /dev/null +++ b/src/sideshow/db/model/stores.py @@ -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 . +# +################################################################################ +""" +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() diff --git a/src/sideshow/orders.py b/src/sideshow/orders.py index 9f99e53..868cada 100644 --- a/src/sideshow/orders.py +++ b/src/sideshow/orders.py @@ -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. diff --git a/src/sideshow/web/menus.py b/src/sideshow/web/menus.py index 1641c72..9da61c0 100644 --- a/src/sideshow/web/menus.py +++ b/src/sideshow/web/menus.py @@ -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 diff --git a/src/sideshow/web/templates/order-items/view.mako b/src/sideshow/web/templates/order-items/view.mako index 3cd210a..cb86977 100644 --- a/src/sideshow/web/templates/order-items/view.mako +++ b/src/sideshow/web/templates/order-items/view.mako @@ -29,6 +29,17 @@ ${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} — Item #${item.sequence} + % if expose_store_id: + + + % 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 + + + % endif ${order_qty_uom_text|n} @@ -223,7 +234,10 @@ ${app.render_boolean(item.product_weighed)} - + + ${item.department_id} + + ${item.department_name} diff --git a/src/sideshow/web/templates/orders/configure.mako b/src/sideshow/web/templates/orders/configure.mako index e247b2f..144e5f0 100644 --- a/src/sideshow/web/templates/orders/configure.mako +++ b/src/sideshow/web/templates/orders/configure.mako @@ -3,6 +3,28 @@ <%def name="form_content()"> +

Stores

+
+ + + + Show/choose the Store ID for each order + + + + + + + +
+

Customers

@@ -14,41 +36,12 @@ +

Products

- - - Allow per-item discounts - - - - - - Allow discount even if item is on sale - - - -
-
Default item discount
-
- -
-
%
-
-
+

Pricing

+
+ + + + Allow per-item discounts + + + + + + Allow discount even if item is on sale + + + +
+ Global default item discount + + % +
+ +
+
+

Per-Department default item discounts

+
+ + Add + + + <${b}-modal has-modal-card + % if request.use_oruga: + v-model:active="deptItemDiscountShowDialog" + % else: + :active.sync="deptItemDiscountShowDialog" + % endif + > + + +
+
+ <${b}-table :data="deptItemDiscounts"> + <${b}-table-column field="department_id" + label="Dept. ID" + v-slot="props"> + {{ props.row.department_id }} + + <${b}-table-column field="department_name" + label="Department Name" + v-slot="props"> + {{ props.row.department_name }} + + <${b}-table-column field="default_item_discount" + label="Discount" + v-slot="props"> + {{ props.row.default_item_discount }} % + + <${b}-table-column label="Actions" + v-slot="props"> + + + Edit + + + + Delete + + + +
+
+

Batches

@@ -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 + } + diff --git a/src/sideshow/web/templates/orders/create.mako b/src/sideshow/web/templates/orders/create.mako index 2ac6f0a..5dd39b7 100644 --- a/src/sideshow/web/templates/orders/create.mako +++ b/src/sideshow/web/templates/orders/create.mako @@ -42,7 +42,25 @@