feat: add initial support for order item events

so far just attaching events on creation, but then can view them
This commit is contained in:
Lance Edgar 2025-01-15 19:25:45 -06:00
parent c79b0262f3
commit b4deea76e0
10 changed files with 456 additions and 13 deletions

View file

@ -994,9 +994,35 @@ class NewOrderBatchHandler(BatchHandler):
order.items.append(item) order.items.append(item)
# set item status # set item status
item.status_code = enum.ORDER_ITEM_STATUS_INITIATED self.set_initial_item_status(item, user)
self.app.progress_loop(convert, rows, progress, self.app.progress_loop(convert, rows, progress,
message="Converting batch rows to order items") message="Converting batch rows to order items")
session.flush() session.flush()
return order return order
def set_initial_item_status(self, item, user, **kwargs):
"""
Set the initial status and attach event(s) for the given item.
This is called from :meth:`make_new_order()` for each item
after it is added to the order.
Default logic will set status to
:data:`~sideshow.enum.ORDER_ITEM_STATUS_READY` and attach 2
events:
* :data:`~sideshow.enum.ORDER_ITEM_EVENT_INITIATED`
* :data:`~sideshow.enum.ORDER_ITEM_EVENT_READY`
:param item: :class:`~sideshow.db.model.orders.OrderItem`
being added to the new order.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action.
"""
enum = self.app.enum
item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
item.status_code = enum.ORDER_ITEM_STATUS_READY

View file

@ -156,6 +156,19 @@ def upgrade() -> None:
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item')) sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item'))
) )
# sideshow_order_item_event
op.create_table('sideshow_order_item_event',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('item_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('type_code', sa.Integer(), nullable=False),
sa.Column('occurred', sa.DateTime(timezone=True), nullable=False),
sa.Column('actor_uuid', wuttjamaican.db.util.UUID(), nullable=False),
sa.Column('note', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['actor_uuid'], ['user.uuid'], name=op.f('fk_sideshow_order_item_event_actor_uuid_user')),
sa.ForeignKeyConstraint(['item_uuid'], ['sideshow_order_item.uuid'], name=op.f('fk_sideshow_order_item_event_item_uuid_sideshow_order_item')),
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_order_item_event'))
)
# sideshow_batch_neworder # sideshow_batch_neworder
op.create_table('sideshow_batch_neworder', op.create_table('sideshow_batch_neworder',
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False), sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
@ -227,6 +240,9 @@ def downgrade() -> None:
op.drop_table('sideshow_batch_neworder_row') op.drop_table('sideshow_batch_neworder_row')
op.drop_table('sideshow_batch_neworder') op.drop_table('sideshow_batch_neworder')
# sideshow_order_item_event
op.drop_table('sideshow_order_item_event')
# sideshow_order_item # sideshow_order_item
op.drop_table('sideshow_order_item') op.drop_table('sideshow_order_item')

View file

@ -32,6 +32,7 @@ Primary :term:`data models <data model>`:
* :class:`~sideshow.db.model.orders.Order` * :class:`~sideshow.db.model.orders.Order`
* :class:`~sideshow.db.model.orders.OrderItem` * :class:`~sideshow.db.model.orders.OrderItem`
* :class:`~sideshow.db.model.orders.OrderItemEvent`
* :class:`~sideshow.db.model.customers.LocalCustomer` * :class:`~sideshow.db.model.customers.LocalCustomer`
* :class:`~sideshow.db.model.products.LocalProduct` * :class:`~sideshow.db.model.products.LocalProduct`
* :class:`~sideshow.db.model.customers.PendingCustomer` * :class:`~sideshow.db.model.customers.PendingCustomer`
@ -49,7 +50,7 @@ from wuttjamaican.db.model import *
# sideshow models # sideshow models
from .customers import LocalCustomer, PendingCustomer from .customers import LocalCustomer, PendingCustomer
from .products import LocalProduct, PendingProduct from .products import LocalProduct, PendingProduct
from .orders import Order, OrderItem from .orders import Order, OrderItem, OrderItemEvent
# batch models # batch models
from .batch.neworder import NewOrderBatch, NewOrderBatchRow from .batch.neworder import NewOrderBatch, NewOrderBatchRow

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Sideshow -- Case/Special Order Tracker # Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar # Copyright © 2024-2025 Lance Edgar
# #
# This file is part of Sideshow. # This file is part of Sideshow.
# #
@ -332,6 +332,16 @@ class OrderItem(model.Base):
applicable/known. applicable/known.
""") """)
events = orm.relationship(
'OrderItemEvent',
order_by='OrderItemEvent.occurred, OrderItemEvent.uuid',
cascade='all, delete-orphan',
cascade_backrefs=False,
back_populates='item',
doc="""
List of :class:`OrderItemEvent` records for the item.
""")
@property @property
def full_description(self): def full_description(self):
""" """ """ """
@ -344,3 +354,52 @@ class OrderItem(model.Base):
def __str__(self): def __str__(self):
return self.full_description return self.full_description
def add_event(self, type_code, user, **kwargs):
"""
Convenience method to add a new :class:`OrderItemEvent` for
the item.
"""
kwargs['type_code'] = type_code
kwargs['actor'] = user
self.events.append(OrderItemEvent(**kwargs))
class OrderItemEvent(model.Base):
"""
An event in the life of an :term:`order item`.
"""
__tablename__ = 'sideshow_order_item_event'
uuid = model.uuid_column()
item_uuid = model.uuid_fk_column('sideshow_order_item.uuid', nullable=False)
item = orm.relationship(
OrderItem,
cascade_backrefs=False,
back_populates='events',
doc="""
Reference to the :class:`OrderItem` to which the event
pertains.
""")
type_code = sa.Column(sa.Integer, nullable=False, doc="""
Code indicating the type of event; values must be defined in
:data:`~sideshow.enum.ORDER_ITEM_EVENT`.
""")
occurred = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
Date and time when the event occurred.
""")
actor_uuid = model.uuid_fk_column('user.uuid', nullable=False)
actor = orm.relationship(
model.User,
doc="""
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
performed the action.
""")
note = sa.Column(sa.Text(), nullable=True, doc="""
Optional note recorded for the event.
""")

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Sideshow -- Case/Special Order Tracker # Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar # Copyright © 2024-2025 Lance Edgar
# #
# This file is part of Sideshow. # This file is part of Sideshow.
# #
@ -108,23 +108,101 @@ class PendingProductStatus(Enum):
######################################## ########################################
ORDER_ITEM_STATUS_UNINITIATED = 1 ORDER_ITEM_STATUS_UNINITIATED = 1
"""
Indicates the item is "not yet initiated" - this probably is not
useful but exists as a possibility just in case.
"""
ORDER_ITEM_STATUS_INITIATED = 10 ORDER_ITEM_STATUS_INITIATED = 10
"""
Indicates the item is "initiated" (aka. created) but not yet "ready"
for buyer/PO. This may imply the price needs confirmation etc.
"""
ORDER_ITEM_STATUS_PAID_BEFORE = 50 ORDER_ITEM_STATUS_PAID_BEFORE = 50
"""
Indicates the customer has fully paid for the item, up-front before
the buyer places PO etc. It implies the item is not yet "ready" for
some reason.
"""
# TODO: deprecate / remove this one # TODO: deprecate / remove this one
ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE
ORDER_ITEM_STATUS_READY = 100 ORDER_ITEM_STATUS_READY = 100
"""
Indicates the item is "ready" for buyer to include it on a vendor
purchase order.
"""
ORDER_ITEM_STATUS_PLACED = 200 ORDER_ITEM_STATUS_PLACED = 200
"""
Indicates the buyer has placed a vendor purchase order which includes
this item. The item is thereby "on order" until the truck arrives.
"""
ORDER_ITEM_STATUS_RECEIVED = 300 ORDER_ITEM_STATUS_RECEIVED = 300
"""
Indicates the item has been received as part of a vendor delivery.
The item is thereby "on hand" until customer comes in for pickup.
"""
ORDER_ITEM_STATUS_CONTACTED = 350 ORDER_ITEM_STATUS_CONTACTED = 350
"""
Indicates the customer has been notified that the item is "on hand"
and awaiting their pickup.
"""
ORDER_ITEM_STATUS_CONTACT_FAILED = 375 ORDER_ITEM_STATUS_CONTACT_FAILED = 375
"""
Indicates the attempt(s) to notify customer have failed. The item is
on hand but the customer does not know to pickup.
"""
ORDER_ITEM_STATUS_DELIVERED = 500 ORDER_ITEM_STATUS_DELIVERED = 500
"""
Indicates the customer has picked up the item.
"""
ORDER_ITEM_STATUS_PAID_AFTER = 550 ORDER_ITEM_STATUS_PAID_AFTER = 550
"""
Indicates the customer has fully paid for the item, as part of their
pickup. This completes the cycle for orders which require payment on
the tail end.
"""
ORDER_ITEM_STATUS_CANCELED = 900 ORDER_ITEM_STATUS_CANCELED = 900
"""
Indicates the order item has been canceled.
"""
ORDER_ITEM_STATUS_REFUND_PENDING = 910 ORDER_ITEM_STATUS_REFUND_PENDING = 910
"""
Indicates the order item has been canceled, and the customer is due a
(pending) refund.
"""
ORDER_ITEM_STATUS_REFUNDED = 920 ORDER_ITEM_STATUS_REFUNDED = 920
"""
Indicates the order item has been canceled, and the customer has been
given a refund.
"""
ORDER_ITEM_STATUS_RESTOCKED = 930 ORDER_ITEM_STATUS_RESTOCKED = 930
"""
Indicates the product has been restocked, e.g. after the order item
was canceled.
"""
ORDER_ITEM_STATUS_EXPIRED = 940 ORDER_ITEM_STATUS_EXPIRED = 940
"""
Indicates the order item and/or product has expired.
"""
ORDER_ITEM_STATUS_INACTIVE = 950 ORDER_ITEM_STATUS_INACTIVE = 950
"""
Indicates the order item has become inactive.
"""
ORDER_ITEM_STATUS = OrderedDict([ ORDER_ITEM_STATUS = OrderedDict([
(ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"), (ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"),
@ -144,3 +222,169 @@ ORDER_ITEM_STATUS = OrderedDict([
(ORDER_ITEM_STATUS_EXPIRED, "expired"), (ORDER_ITEM_STATUS_EXPIRED, "expired"),
(ORDER_ITEM_STATUS_INACTIVE, "inactive"), (ORDER_ITEM_STATUS_INACTIVE, "inactive"),
]) ])
"""
Dict of possible code -> label options for :term:`order item` status.
These codes are referenced by:
* :attr:`sideshow.db.model.orders.OrderItem.status_code`
"""
########################################
# Order Item Event Type
########################################
ORDER_ITEM_EVENT_INITIATED = 10
"""
Indicates the item was "initiated" - this occurs when the
:term:`order` is first created.
"""
ORDER_ITEM_EVENT_PRICE_CONFIRMED = 20
"""
Indicates the item's price was confirmed by a user who is authorized
to do that.
"""
ORDER_ITEM_EVENT_PAYMENT_RECEIVED = 50
"""
Indicates payment was received for the item. This may occur toward
the beginning, or toward the end, of the item's life cycle depending
on app configuration etc.
"""
# TODO: deprecate / remove this
ORDER_ITEM_EVENT_PAID = ORDER_ITEM_EVENT_PAYMENT_RECEIVED
ORDER_ITEM_EVENT_READY = 100
"""
Indicates the item has become "ready" for buyer placement on a new
vendor purchase order. Often this will occur when the :term:`order`
is first created, if the data is suitable. However this may be
delayed if e.g. the price needs confirmation.
"""
ORDER_ITEM_EVENT_CUSTOMER_RESOLVED = 120
"""
Indicates the customer for the :term:`order` has been assigned to a
"proper" (existing) account. This may happen (after the fact) if the
order was first created with a new/unknown customer.
"""
ORDER_ITEM_EVENT_PRODUCT_RESOLVED = 140
"""
Indicates the product for the :term:`order item` has been assigned to
a "proper" (existing) product record. This may happen (after the
fact) if the order was first created with a new/unknown product.
"""
ORDER_ITEM_EVENT_PLACED = 200
"""
Indicates the buyer has placed a vendor purchase order which includes
this item. So the item is "on order" until the truck arrives.
"""
ORDER_ITEM_EVENT_RECEIVED = 300
"""
Indicates the receiver has found the item while receiving a vendor
delivery. The item is set aside and is "on hand" until customer comes
in to pick it up.
"""
ORDER_ITEM_EVENT_CONTACTED = 350
"""
Indicates the customer has been contacted, to notify them of the item
being on hand and ready for pickup.
"""
ORDER_ITEM_EVENT_CONTACT_FAILED = 375
"""
Indicates an attempt was made to contact the customer, to notify them
of item being on hand, but the attempt failed, e.g. due to bad phone
or email on file.
"""
ORDER_ITEM_EVENT_DELIVERED = 500
"""
Indicates the customer has picked up the item.
"""
ORDER_ITEM_EVENT_STATUS_CHANGE = 700
"""
Indicates a manual status change was made. Such an event should
ideally contain a note with further explanation.
"""
ORDER_ITEM_EVENT_NOTE_ADDED = 750
"""
Indicates an arbitrary note was added.
"""
ORDER_ITEM_EVENT_CANCELED = 900
"""
Indicates the :term:`order item` was canceled.
"""
ORDER_ITEM_EVENT_REFUND_PENDING = 910
"""
Indicates the customer is due a (pending) refund for the item.
"""
ORDER_ITEM_EVENT_REFUNDED = 920
"""
Indicates the customer has been refunded for the item.
"""
ORDER_ITEM_EVENT_RESTOCKED = 930
"""
Indicates the product has been restocked, e.g. due to the order item
being canceled.
"""
ORDER_ITEM_EVENT_EXPIRED = 940
"""
Indicates the order item (or its product) has expired.
"""
ORDER_ITEM_EVENT_INACTIVE = 950
"""
Indicates the order item has become inactive.
"""
ORDER_ITEM_EVENT_OTHER = 999
"""
Arbitrary event type which does not signify anything in particular.
If used, the event should be given an explanatory note.
"""
ORDER_ITEM_EVENT = OrderedDict([
(ORDER_ITEM_EVENT_INITIATED, "initiated"),
(ORDER_ITEM_EVENT_PRICE_CONFIRMED, "price confirmed"),
(ORDER_ITEM_EVENT_PAYMENT_RECEIVED, "payment received"),
(ORDER_ITEM_EVENT_READY, "ready to proceed"),
(ORDER_ITEM_EVENT_CUSTOMER_RESOLVED, "customer resolved"),
(ORDER_ITEM_EVENT_PRODUCT_RESOLVED, "product resolved"),
(ORDER_ITEM_EVENT_PLACED, "placed with vendor"),
(ORDER_ITEM_EVENT_RECEIVED, "received from vendor"),
(ORDER_ITEM_EVENT_CONTACTED, "customer contacted"),
(ORDER_ITEM_EVENT_CONTACT_FAILED, "contact failed"),
(ORDER_ITEM_EVENT_DELIVERED, "delivered"),
(ORDER_ITEM_EVENT_STATUS_CHANGE, "status change"),
(ORDER_ITEM_EVENT_NOTE_ADDED, "note added"),
(ORDER_ITEM_EVENT_CANCELED, "canceled"),
(ORDER_ITEM_EVENT_REFUND_PENDING, "refund pending"),
(ORDER_ITEM_EVENT_REFUNDED, "refunded"),
(ORDER_ITEM_EVENT_RESTOCKED, "restocked"),
(ORDER_ITEM_EVENT_EXPIRED, "expired"),
(ORDER_ITEM_EVENT_INACTIVE, "inactive"),
(ORDER_ITEM_EVENT_OTHER, "other"),
])
"""
Dict of possible code -> label options for :term:`order item` event
types.
These codes are referenced by:
* :attr:`sideshow.db.model.orders.OrderItemEvent.type_code`
"""

View file

@ -22,7 +22,7 @@
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
<b-field horizontal label="ID"> <b-field horizontal label="ID">
<span>Order ID ${order.order_id} &mdash; Item #${item.sequence}</span> <span>${h.link_to(f"Order ID {order.order_id}", url('orders.view', uuid=order.uuid))} &mdash; Item #${item.sequence}</span>
</b-field> </b-field>
<b-field horizontal label="Order Qty"> <b-field horizontal label="Order Qty">
<span>${order_qty_uom_text|n}</span> <span>${order_qty_uom_text|n}</span>
@ -151,4 +151,40 @@
</div> </div>
</div> </div>
<div style="padding: 0 2rem;">
<nav class="panel" style="width: 100%;">
<p class="panel-heading">Events</p>
<div class="panel-block">
<div style="width: 100%;">
${events_grid.render_table_element()}
</div>
</div>
</nav>
</div>
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${events_grid.render_vue_template()}
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
## TODO: ugh the hackiness
ThisPageData.gridContext = {
% for key, data in form.grid_vue_context.items():
'${key}': ${json.dumps(data)|n},
% endfor
}
</script>
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
${events_grid.render_vue_finalize()}
</%def> </%def>

View file

@ -30,6 +30,8 @@ import logging
import colander import colander
from sqlalchemy import orm from sqlalchemy import orm
from webhelpers2.html import tags
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum, WuttaDictEnum
@ -1173,12 +1175,38 @@ class OrderItemView(MasterView):
def get_template_context(self, context): def get_template_context(self, context):
""" """ """ """
if self.viewing: if self.viewing:
model = self.app.model
enum = self.app.enum
route_prefix = self.get_route_prefix()
item = context['instance'] item = context['instance']
form = context['form']
context['item'] = item context['item'] = item
context['order'] = item.order context['order'] = item.order
context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text( context['order_qty_uom_text'] = self.order_handler.get_order_qty_uom_text(
item.order_qty, item.order_uom, case_size=item.case_size, html=True) item.order_qty, item.order_uom, case_size=item.case_size, html=True)
grid = self.make_grid(key=f'{route_prefix}.view.events',
model_class=model.OrderItemEvent,
data=item.events,
columns=[
'occurred',
'actor',
'type_code',
'note',
],
labels={
'occurred': "Date/Time",
'actor': "User",
'type_code': "Event Type",
})
grid.set_renderer('type_code', lambda e, k, v: enum.ORDER_ITEM_EVENT[v])
if self.request.has_perm('users.view'):
grid.set_renderer('actor', lambda e, k, v: tags.link_to(
e.actor, self.request.route_url('users.view', uuid=e.actor.uuid)))
form.add_grid_vue_context(grid)
context['events_grid'] = grid
return context return context
def get_xref_buttons(self, item): def get_xref_buttons(self, item):

View file

@ -1109,6 +1109,20 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(item.unit_cost, decimal.Decimal('3.99')) self.assertEqual(item.unit_cost, decimal.Decimal('3.99'))
self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99')) self.assertEqual(item.unit_price_reg, decimal.Decimal('5.99'))
def test_set_initial_item_status(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
user = model.User(username='barney')
item = model.OrderItem()
self.assertIsNone(item.status_code)
self.assertEqual(len(item.events), 0)
handler.set_initial_item_status(item, user)
self.assertEqual(item.status_code, enum.ORDER_ITEM_STATUS_READY)
self.assertEqual(len(item.events), 2)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED)
self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY)
def test_execute(self): def test_execute(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum

View file

@ -19,6 +19,11 @@ class TestOrder(DataTestCase):
class TestOrderItem(DataTestCase): class TestOrderItem(DataTestCase):
def make_config(self, **kw):
config = super().make_config(**kw)
config.setdefault('wutta.enum_spec', 'sideshow.enum')
return config
def test_full_description(self): def test_full_description(self):
item = mod.OrderItem() item = mod.OrderItem()
@ -44,3 +49,15 @@ class TestOrderItem(DataTestCase):
product_description='Vinegar', product_description='Vinegar',
product_size='32oz') product_size='32oz')
self.assertEqual(str(item), "Bragg Vinegar 32oz") self.assertEqual(str(item), "Bragg Vinegar 32oz")
def test_add_event(self):
model = self.app.model
enum = self.app.enum
user = model.User(username='barney')
item = mod.OrderItem()
self.assertEqual(item.events, [])
item.add_event(enum.ORDER_ITEM_EVENT_INITIATED, user)
item.add_event(enum.ORDER_ITEM_EVENT_READY, user)
self.assertEqual(len(item.events), 2)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_INITIATED)
self.assertEqual(item.events[1].type_code, enum.ORDER_ITEM_EVENT_READY)

View file

@ -1338,14 +1338,16 @@ class TestOrderItemView(WebTestCase):
item = model.OrderItem(order_qty=2, order_uom=enum.ORDER_UOM_CASE, case_size=8) item = model.OrderItem(order_qty=2, order_uom=enum.ORDER_UOM_CASE, case_size=8)
order.items.append(item) order.items.append(item)
with patch.object(view, 'viewing', new=True): with patch.object(self.request, 'is_root', new=True):
context = view.get_template_context({'instance': item}) with patch.object(view, 'viewing', new=True):
self.assertIn('item', context) form = view.make_model_form(model_instance=item)
self.assertIs(context['item'], item) context = view.get_template_context({'instance': item, 'form': form})
self.assertIn('order', context) self.assertIn('item', context)
self.assertIs(context['order'], order) self.assertIs(context['item'], item)
self.assertIn('order_qty_uom_text', context) self.assertIn('order', context)
self.assertEqual(context['order_qty_uom_text'], "2 Cases (&times; 8 = 16 Units)") self.assertIs(context['order'], order)
self.assertIn('order_qty_uom_text', context)
self.assertEqual(context['order_qty_uom_text'], "2 Cases (&times; 8 = 16 Units)")
def test_get_xref_buttons(self): def test_get_xref_buttons(self):
self.pyramid_config.add_route('orders.view', '/orders/{uuid}') self.pyramid_config.add_route('orders.view', '/orders/{uuid}')