feat: add basic "create order" feature, docs, tests
just the package API docs so far, narrative will come later
This commit is contained in:
parent
89265f0240
commit
ef07d30a85
86 changed files with 7749 additions and 35 deletions
25
src/sideshow/__init__.py
Normal file
25
src/sideshow/__init__.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
# -*- 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 - Case/Special Order Tracker
|
||||
"""
|
0
src/sideshow/batch/__init__.py
Normal file
0
src/sideshow/batch/__init__.py
Normal file
471
src/sideshow/batch/neworder.py
Normal file
471
src/sideshow/batch/neworder.py
Normal file
|
@ -0,0 +1,471 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
New Order Batch Handler
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import decimal
|
||||
|
||||
from wuttjamaican.batch import BatchHandler
|
||||
|
||||
from sideshow.db.model import NewOrderBatch
|
||||
|
||||
|
||||
class NewOrderBatchHandler(BatchHandler):
|
||||
"""
|
||||
The :term:`batch handler` for New Order Batches.
|
||||
|
||||
This is responsible for business logic around the creation of new
|
||||
:term:`orders <order>`. A
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` tracks
|
||||
all user input until they "submit" (execute) at which point an
|
||||
:class:`~sideshow.db.model.orders.Order` is created.
|
||||
"""
|
||||
model_class = NewOrderBatch
|
||||
|
||||
def set_pending_customer(self, batch, data):
|
||||
"""
|
||||
Set (add or update) pending customer info for the batch.
|
||||
|
||||
This will clear the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.customer_id`
|
||||
and set the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`,
|
||||
creating a new record if needed. It then updates the pending
|
||||
customer record per the given ``data``.
|
||||
|
||||
:param batch:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
to be updated.
|
||||
|
||||
:param data: Dict of field data for the
|
||||
:class:`~sideshow.db.model.customers.PendingCustomer`
|
||||
record.
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
|
||||
# remove customer account if set
|
||||
batch.customer_id = None
|
||||
|
||||
# create pending customer if needed
|
||||
pending = batch.pending_customer
|
||||
if not pending:
|
||||
kw = dict(data)
|
||||
kw.setdefault('status', enum.PendingCustomerStatus.PENDING)
|
||||
pending = model.PendingCustomer(**kw)
|
||||
batch.pending_customer = pending
|
||||
|
||||
# update pending customer
|
||||
if 'first_name' in data:
|
||||
pending.first_name = data['first_name']
|
||||
if 'last_name' in data:
|
||||
pending.last_name = data['last_name']
|
||||
if 'full_name' in data:
|
||||
pending.full_name = data['full_name']
|
||||
elif 'first_name' in data or 'last_name' in data:
|
||||
pending.full_name = self.app.make_full_name(data.get('first_name'),
|
||||
data.get('last_name'))
|
||||
if 'phone_number' in data:
|
||||
pending.phone_number = data['phone_number']
|
||||
if 'email_address' in data:
|
||||
pending.email_address = data['email_address']
|
||||
|
||||
# update batch per pending customer
|
||||
batch.customer_name = pending.full_name
|
||||
batch.phone_number = pending.phone_number
|
||||
batch.email_address = pending.email_address
|
||||
|
||||
def add_pending_product(self, batch, pending_info,
|
||||
order_qty, order_uom):
|
||||
"""
|
||||
Add a new row to the batch, for the given "pending" product
|
||||
and order quantity.
|
||||
|
||||
See also :meth:`set_pending_product()` to update an existing row.
|
||||
|
||||
:param batch:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` to
|
||||
which the row should be added.
|
||||
|
||||
:param pending_info: Dict of kwargs to use when constructing a
|
||||
new :class:`~sideshow.db.model.products.PendingProduct`.
|
||||
|
||||
:param order_qty: Quantity of the product to be added to the
|
||||
order.
|
||||
|
||||
:param order_uom: UOM for the order quantity; must be a code
|
||||
from :data:`~sideshow.enum.ORDER_UOM`.
|
||||
|
||||
:returns:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||
which was added to the batch.
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
session = self.app.get_session(batch)
|
||||
|
||||
# make new pending product
|
||||
kw = dict(pending_info)
|
||||
kw.setdefault('status', enum.PendingProductStatus.PENDING)
|
||||
product = model.PendingProduct(**kw)
|
||||
session.add(product)
|
||||
session.flush()
|
||||
# nb. this may convert float to decimal etc.
|
||||
session.refresh(product)
|
||||
|
||||
# make/add new row, w/ pending product
|
||||
row = self.make_row(pending_product=product,
|
||||
order_qty=order_qty, order_uom=order_uom)
|
||||
self.add_row(batch, row)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
def set_pending_product(self, row, data):
|
||||
"""
|
||||
Set (add or update) pending product info for the given batch row.
|
||||
|
||||
This will clear the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`
|
||||
and set the
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`,
|
||||
creating a new record if needed. It then updates the pending
|
||||
product record per the given ``data``, and finally calls
|
||||
:meth:`refresh_row()`.
|
||||
|
||||
Note that this does not update order quantity for the item.
|
||||
|
||||
See also :meth:`add_pending_product()` to add a new row
|
||||
instead of updating.
|
||||
|
||||
:param row:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||
to be updated.
|
||||
|
||||
:param data: Dict of field data for the
|
||||
:class:`~sideshow.db.model.products.PendingProduct` record.
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
session = self.app.get_session(row)
|
||||
|
||||
# values for these fields can be used as-is
|
||||
simple_fields = [
|
||||
'scancode',
|
||||
'brand_name',
|
||||
'description',
|
||||
'size',
|
||||
'weighed',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'special_order',
|
||||
'vendor_name',
|
||||
'vendor_item_code',
|
||||
'notes',
|
||||
'unit_cost',
|
||||
'case_size',
|
||||
'case_cost',
|
||||
'unit_price_reg',
|
||||
]
|
||||
|
||||
# clear true product id
|
||||
row.product_id = None
|
||||
|
||||
# make pending product if needed
|
||||
product = row.pending_product
|
||||
if not product:
|
||||
kw = dict(data)
|
||||
kw.setdefault('status', enum.PendingProductStatus.PENDING)
|
||||
product = model.PendingProduct(**kw)
|
||||
session.add(product)
|
||||
row.pending_product = product
|
||||
session.flush()
|
||||
|
||||
# update pending product
|
||||
for field in simple_fields:
|
||||
if field in data:
|
||||
setattr(product, field, data[field])
|
||||
|
||||
# nb. this may convert float to decimal etc.
|
||||
session.flush()
|
||||
session.refresh(product)
|
||||
|
||||
# refresh per new info
|
||||
self.refresh_row(row)
|
||||
|
||||
def refresh_row(self, row, now=None):
|
||||
"""
|
||||
Refresh all data for the row. This is called when adding a
|
||||
new row to the batch, or anytime the row is updated (e.g. when
|
||||
changing order quantity).
|
||||
|
||||
This calls one of the following to update product-related
|
||||
attributes for the row:
|
||||
|
||||
* :meth:`refresh_row_from_pending_product()`
|
||||
* :meth:`refresh_row_from_true_product()`
|
||||
|
||||
It then re-calculates the row's
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.total_price`
|
||||
and updates the batch accordingly.
|
||||
|
||||
It also sets the row
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.status_code`.
|
||||
"""
|
||||
enum = self.app.enum
|
||||
row.status_code = None
|
||||
row.status_text = None
|
||||
|
||||
# ensure product
|
||||
if not row.product_id and not row.pending_product:
|
||||
row.status_code = row.STATUS_MISSING_PRODUCT
|
||||
return
|
||||
|
||||
# ensure order qty/uom
|
||||
if not row.order_qty or not row.order_uom:
|
||||
row.status_code = row.STATUS_MISSING_ORDER_QTY
|
||||
return
|
||||
|
||||
# update product attrs on row
|
||||
if row.product_id:
|
||||
self.refresh_row_from_true_product(row)
|
||||
else:
|
||||
self.refresh_row_from_pending_product(row)
|
||||
|
||||
# we need to know if total price changes
|
||||
old_total = row.total_price
|
||||
|
||||
# update quoted price
|
||||
row.unit_price_quoted = None
|
||||
row.case_price_quoted = None
|
||||
if row.unit_price_sale is not None and (
|
||||
not row.sale_ends
|
||||
or row.sale_ends > (now or datetime.datetime.now())):
|
||||
row.unit_price_quoted = row.unit_price_sale
|
||||
else:
|
||||
row.unit_price_quoted = row.unit_price_reg
|
||||
if row.unit_price_quoted is not None and row.case_size:
|
||||
row.case_price_quoted = row.unit_price_quoted * row.case_size
|
||||
|
||||
# update row total price
|
||||
row.total_price = None
|
||||
if row.order_uom == enum.ORDER_UOM_CASE:
|
||||
if row.unit_price_quoted is not None and row.case_size is not None:
|
||||
row.total_price = row.unit_price_quoted * row.case_size * row.order_qty
|
||||
else: # ORDER_UOM_UNIT (or similar)
|
||||
if row.unit_price_quoted is not None:
|
||||
row.total_price = row.unit_price_quoted * row.order_qty
|
||||
if row.total_price is not None:
|
||||
row.total_price = decimal.Decimal(f'{row.total_price:0.2f}')
|
||||
|
||||
# update batch if total price changed
|
||||
if row.total_price != old_total:
|
||||
batch = row.batch
|
||||
batch.total_price = ((batch.total_price or 0)
|
||||
+ (row.total_price or 0)
|
||||
- (old_total or 0))
|
||||
|
||||
# all ok
|
||||
row.status_code = row.STATUS_OK
|
||||
|
||||
def refresh_row_from_pending_product(self, row):
|
||||
"""
|
||||
Update product-related attributes on the row, from its
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`
|
||||
record.
|
||||
|
||||
This is called automatically from :meth:`refresh_row()`.
|
||||
"""
|
||||
product = row.pending_product
|
||||
|
||||
row.product_scancode = product.scancode
|
||||
row.product_brand = product.brand_name
|
||||
row.product_description = product.description
|
||||
row.product_size = product.size
|
||||
row.product_weighed = product.weighed
|
||||
row.department_id = product.department_id
|
||||
row.department_name = product.department_name
|
||||
row.special_order = product.special_order
|
||||
row.case_size = product.case_size
|
||||
row.unit_cost = product.unit_cost
|
||||
row.unit_price_reg = product.unit_price_reg
|
||||
|
||||
def refresh_row_from_true_product(self, row):
|
||||
"""
|
||||
Update product-related attributes on the row, from its "true"
|
||||
product record indicated by
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.product_id`.
|
||||
|
||||
This is called automatically from :meth:`refresh_row()`.
|
||||
|
||||
There is no default logic here; subclass must implement as
|
||||
needed.
|
||||
"""
|
||||
|
||||
def remove_row(self, row):
|
||||
"""
|
||||
Remove a row from its batch.
|
||||
|
||||
This also will update the batch
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.total_price`
|
||||
accordingly.
|
||||
"""
|
||||
if row.total_price:
|
||||
batch = row.batch
|
||||
batch.total_price = (batch.total_price or 0) - row.total_price
|
||||
|
||||
super().remove_row(row)
|
||||
|
||||
def do_delete(self, batch, user, **kwargs):
|
||||
"""
|
||||
Delete the given batch entirely.
|
||||
|
||||
If the batch has a
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatch.pending_customer`
|
||||
record, that is deleted also.
|
||||
"""
|
||||
# maybe delete pending customer record, if it only exists for
|
||||
# sake of this batch
|
||||
if batch.pending_customer:
|
||||
if len(batch.pending_customer.new_order_batches) == 1:
|
||||
# TODO: check for past orders too
|
||||
session = self.app.get_session(batch)
|
||||
session.delete(batch.pending_customer)
|
||||
|
||||
# continue with normal deletion
|
||||
super().do_delete(batch, user, **kwargs)
|
||||
|
||||
def why_not_execute(self, batch, **kwargs):
|
||||
"""
|
||||
By default this checks to ensure the batch has a customer and
|
||||
at least one item.
|
||||
"""
|
||||
if not batch.customer_id and not batch.pending_customer:
|
||||
return "Must assign the customer"
|
||||
|
||||
rows = self.get_effective_rows(batch)
|
||||
if not rows:
|
||||
return "Must add at least one valid item"
|
||||
|
||||
def get_effective_rows(self, batch):
|
||||
"""
|
||||
Only rows with
|
||||
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.STATUS_OK`
|
||||
are "effective" - i.e. rows with other status codes will not
|
||||
be created as proper order items.
|
||||
"""
|
||||
return [row for row in batch.rows
|
||||
if row.status_code == row.STATUS_OK]
|
||||
|
||||
def execute(self, batch, user=None, progress=None, **kwargs):
|
||||
"""
|
||||
By default, this will call :meth:`make_new_order()` and return
|
||||
the new :class:`~sideshow.db.model.orders.Order` instance.
|
||||
|
||||
Note that callers should use
|
||||
:meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
|
||||
instead, which calls this method automatically.
|
||||
"""
|
||||
rows = self.get_effective_rows(batch)
|
||||
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
|
||||
return order
|
||||
|
||||
def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):
|
||||
"""
|
||||
Create a new :term:`order` from the batch data.
|
||||
|
||||
This is called automatically from :meth:`execute()`.
|
||||
|
||||
:param batch:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
instance.
|
||||
|
||||
:param rows: List of effective rows for the batch, i.e. which
|
||||
rows should be converted to :term:`order items <order
|
||||
item>`.
|
||||
|
||||
:returns: :class:`~sideshow.db.model.orders.Order` instance.
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
session = self.app.get_session(batch)
|
||||
|
||||
batch_fields = [
|
||||
'store_id',
|
||||
'customer_id',
|
||||
'pending_customer',
|
||||
'customer_name',
|
||||
'phone_number',
|
||||
'email_address',
|
||||
'total_price',
|
||||
]
|
||||
|
||||
row_fields = [
|
||||
'pending_product_uuid',
|
||||
'product_scancode',
|
||||
'product_brand',
|
||||
'product_description',
|
||||
'product_size',
|
||||
'product_weighed',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'case_size',
|
||||
'order_qty',
|
||||
'order_uom',
|
||||
'unit_cost',
|
||||
'unit_price_quoted',
|
||||
'case_price_quoted',
|
||||
'unit_price_reg',
|
||||
'unit_price_sale',
|
||||
'sale_ends',
|
||||
# 'discount_percent',
|
||||
'total_price',
|
||||
'special_order',
|
||||
]
|
||||
|
||||
# make order
|
||||
kw = dict([(field, getattr(batch, field))
|
||||
for field in batch_fields])
|
||||
kw['order_id'] = batch.id
|
||||
kw['created_by'] = user
|
||||
order = model.Order(**kw)
|
||||
session.add(order)
|
||||
session.flush()
|
||||
|
||||
def convert(row, i):
|
||||
|
||||
# make order item
|
||||
kw = dict([(field, getattr(row, field))
|
||||
for field in row_fields])
|
||||
item = model.OrderItem(**kw)
|
||||
order.items.append(item)
|
||||
|
||||
# set item status
|
||||
item.status_code = enum.ORDER_ITEM_STATUS_INITIATED
|
||||
|
||||
self.app.progress_loop(convert, rows, progress,
|
||||
message="Converting batch rows to order items")
|
||||
session.flush()
|
||||
return order
|
|
@ -21,17 +21,16 @@
|
|||
#
|
||||
################################################################################
|
||||
"""
|
||||
Pyramid event subscribers
|
||||
Sideshow - command line interface
|
||||
|
||||
See also :doc:`/narr/cli/index`.
|
||||
|
||||
This (``sideshow.cli``) namespace exposes the following:
|
||||
|
||||
* :data:`~sideshow.cli.base.sideshow_typer` (top-level command)
|
||||
"""
|
||||
|
||||
import sideshow
|
||||
from .base import sideshow_typer
|
||||
|
||||
|
||||
def add_sideshow_to_context(event):
|
||||
renderer_globals = event
|
||||
renderer_globals['sideshow'] = sideshow
|
||||
|
||||
|
||||
def includeme(config):
|
||||
config.include('wuttaweb.subscribers')
|
||||
config.add_subscriber(add_sideshow_to_context, 'pyramid.events.BeforeRender')
|
||||
# nb. must bring in all modules for discovery to work
|
||||
from . import install
|
40
src/sideshow/cli/base.py
Normal file
40
src/sideshow/cli/base.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
# -*- 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 - core command logic
|
||||
|
||||
See also :doc:`/narr/cli/index`.
|
||||
|
||||
.. data:: sideshow_typer
|
||||
|
||||
This is the top-level ``sideshow`` :term:`command`, using the Typer
|
||||
framework.
|
||||
"""
|
||||
|
||||
from wuttjamaican.cli import make_typer
|
||||
|
||||
|
||||
sideshow_typer = make_typer(
|
||||
name='sideshow',
|
||||
help="Sideshow -- Case/Special Order Tracker"
|
||||
)
|
|
@ -21,18 +21,12 @@
|
|||
#
|
||||
################################################################################
|
||||
"""
|
||||
Sideshow CLI
|
||||
See also: :ref:`sideshow-install`
|
||||
"""
|
||||
|
||||
import typer
|
||||
|
||||
from wuttjamaican.cli import make_typer
|
||||
|
||||
|
||||
sideshow_typer = make_typer(
|
||||
name='sideshow',
|
||||
help="Sideshow -- Case/Special Order Tracker"
|
||||
)
|
||||
from .base import sideshow_typer
|
||||
|
||||
|
||||
@sideshow_typer.command()
|
|
@ -21,7 +21,7 @@
|
|||
#
|
||||
################################################################################
|
||||
"""
|
||||
Sideshow config extensions
|
||||
Sideshow config extension
|
||||
"""
|
||||
|
||||
from wuttjamaican.conf import WuttaConfigExtension
|
||||
|
@ -29,18 +29,22 @@ from wuttjamaican.conf import WuttaConfigExtension
|
|||
|
||||
class SideshowConfig(WuttaConfigExtension):
|
||||
"""
|
||||
Config extension for Sideshow
|
||||
Config extension for Sideshow.
|
||||
|
||||
This establishes some config defaults specific to Sideshow.
|
||||
"""
|
||||
key = 'sideshow'
|
||||
|
||||
def configure(self, config):
|
||||
""" """
|
||||
|
||||
# app info
|
||||
config.setdefault(f'{config.appname}.app_title', "Sideshow")
|
||||
config.setdefault(f'{config.appname}.app_dist', "Sideshow")
|
||||
|
||||
# app model
|
||||
# app model, enum
|
||||
config.setdefault(f'{config.appname}.model_spec', 'sideshow.db.model')
|
||||
config.setdefault(f'{config.appname}.enum_spec', 'sideshow.enum')
|
||||
|
||||
# web app menu
|
||||
config.setdefault(f'{config.appname}.web.menus.handler_spec',
|
||||
|
|
|
@ -0,0 +1,203 @@
|
|||
"""initial order tables
|
||||
|
||||
Revision ID: 7a6df83afbd4
|
||||
Revises:
|
||||
Create Date: 2024-12-30 18:53:51.358163
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
import wuttjamaican.db.util
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7a6df83afbd4'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = ('sideshow',)
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
# enums
|
||||
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').create(op.get_bind())
|
||||
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').create(op.get_bind())
|
||||
|
||||
# sideshow_pending_customer
|
||||
op.create_table('sideshow_pending_customer',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('full_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('first_name', sa.String(length=50), nullable=True),
|
||||
sa.Column('last_name', sa.String(length=50), nullable=True),
|
||||
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus', create_type=False), nullable=False),
|
||||
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_customer_created_by_uuid_user')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_customer'))
|
||||
)
|
||||
|
||||
# sideshow_pending_product
|
||||
op.create_table('sideshow_pending_product',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('scancode', sa.String(length=14), nullable=True),
|
||||
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||
sa.Column('brand_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('size', sa.String(length=30), nullable=True),
|
||||
sa.Column('weighed', sa.Boolean(), nullable=True),
|
||||
sa.Column('vendor_name', sa.String(length=50), nullable=True),
|
||||
sa.Column('vendor_item_code', sa.String(length=20), nullable=True),
|
||||
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||
sa.Column('case_size', sa.Numeric(precision=9, scale=4), nullable=True),
|
||||
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('status', postgresql.ENUM('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus', create_type=False), nullable=False),
|
||||
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_pending_product_created_by_uuid_user')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_pending_product'))
|
||||
)
|
||||
|
||||
# sideshow_order
|
||||
op.create_table('sideshow_order',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('order_id', sa.Integer(), nullable=False),
|
||||
sa.Column('store_id', sa.String(length=10), nullable=True),
|
||||
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||
sa.Column('customer_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
|
||||
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_order_pending_customer_uuid_pending_customer')),
|
||||
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_order_created_by_uuid_user')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order'))
|
||||
)
|
||||
|
||||
# sideshow_order_item
|
||||
op.create_table('sideshow_order_item',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('order_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('sequence', sa.Integer(), nullable=False),
|
||||
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||
sa.Column('product_scancode', sa.String(length=14), nullable=True),
|
||||
sa.Column('product_brand', sa.String(length=100), nullable=True),
|
||||
sa.Column('product_description', sa.String(length=255), nullable=True),
|
||||
sa.Column('product_size', sa.String(length=30), nullable=True),
|
||||
sa.Column('product_weighed', sa.Boolean(), nullable=True),
|
||||
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
|
||||
sa.Column('order_uom', sa.String(length=10), nullable=False),
|
||||
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
|
||||
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('status_code', sa.Integer(), nullable=False),
|
||||
sa.Column('paid_amount', sa.Numeric(precision=8, scale=3), nullable=False),
|
||||
sa.Column('payment_transaction_number', sa.String(length=20), nullable=True),
|
||||
sa.ForeignKeyConstraint(['order_uuid'], ['sideshow_order.uuid'], name=op.f('fk_sideshow_order_item_order_uuid_order')),
|
||||
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_order_item_pending_product_uuid_pending_product')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_order_item'))
|
||||
)
|
||||
|
||||
# sideshow_batch_neworder
|
||||
op.create_table('sideshow_batch_neworder',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('row_count', sa.Integer(), nullable=True),
|
||||
sa.Column('status_code', sa.Integer(), nullable=True),
|
||||
sa.Column('status_text', sa.String(length=255), nullable=True),
|
||||
sa.Column('created', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('created_by_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('executed', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('executed_by_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||
sa.Column('store_id', sa.String(length=10), nullable=True),
|
||||
sa.Column('customer_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('pending_customer_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||
sa.Column('customer_name', sa.String(length=100), nullable=True),
|
||||
sa.Column('phone_number', sa.String(length=20), nullable=True),
|
||||
sa.Column('email_address', sa.String(length=255), nullable=True),
|
||||
sa.Column('total_price', sa.Numeric(precision=10, scale=3), nullable=True),
|
||||
sa.ForeignKeyConstraint(['created_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_created_by_uuid_user')),
|
||||
sa.ForeignKeyConstraint(['executed_by_uuid'], ['user.uuid'], name=op.f('fk_sideshow_batch_neworder_executed_by_uuid_user')),
|
||||
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid'], name=op.f('fk_sideshow_batch_neworder_pending_customer_uuid_pending_customer')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder'))
|
||||
)
|
||||
|
||||
# sideshow_batch_neworder_row
|
||||
op.create_table('sideshow_batch_neworder_row',
|
||||
sa.Column('uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('batch_uuid', wuttjamaican.db.util.UUID(), nullable=False),
|
||||
sa.Column('sequence', sa.Integer(), nullable=False),
|
||||
sa.Column('status_text', sa.String(length=255), nullable=True),
|
||||
sa.Column('modified', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('pending_product_uuid', wuttjamaican.db.util.UUID(), nullable=True),
|
||||
sa.Column('product_id', sa.String(length=20), nullable=True),
|
||||
sa.Column('product_scancode', sa.String(length=14), nullable=True),
|
||||
sa.Column('product_brand', sa.String(length=100), nullable=True),
|
||||
sa.Column('product_description', sa.String(length=255), nullable=True),
|
||||
sa.Column('product_size', sa.String(length=30), nullable=True),
|
||||
sa.Column('product_weighed', sa.Boolean(), nullable=True),
|
||||
sa.Column('department_id', sa.String(length=10), nullable=True),
|
||||
sa.Column('department_name', sa.String(length=30), nullable=True),
|
||||
sa.Column('special_order', sa.Boolean(), nullable=True),
|
||||
sa.Column('case_size', sa.Numeric(precision=10, scale=4), nullable=True),
|
||||
sa.Column('order_qty', sa.Numeric(precision=10, scale=4), nullable=False),
|
||||
sa.Column('order_uom', sa.String(length=10), nullable=False),
|
||||
sa.Column('unit_cost', sa.Numeric(precision=9, scale=5), nullable=True),
|
||||
sa.Column('unit_price_reg', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('unit_price_sale', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('sale_ends', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column('unit_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('case_price_quoted', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('discount_percent', sa.Numeric(precision=5, scale=3), nullable=True),
|
||||
sa.Column('total_price', sa.Numeric(precision=8, scale=3), nullable=True),
|
||||
sa.Column('status_code', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['batch_uuid'], ['sideshow_batch_neworder.uuid'], name=op.f('fk_sideshow_batch_neworder_row_batch_uuid_batch_neworder')),
|
||||
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid'], name=op.f('fk_sideshow_batch_neworder_row_pending_product_uuid_pending_product')),
|
||||
sa.PrimaryKeyConstraint('uuid', name=op.f('pk_sideshow_batch_neworder_row'))
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
# sideshow_batch_neworder*
|
||||
op.drop_table('sideshow_batch_neworder_row')
|
||||
op.drop_table('sideshow_batch_neworder')
|
||||
|
||||
# sideshow_order_item
|
||||
op.drop_table('sideshow_order_item')
|
||||
|
||||
# sideshow_order
|
||||
op.drop_table('sideshow_order')
|
||||
|
||||
# sideshow_pending_product
|
||||
op.drop_table('sideshow_pending_product')
|
||||
|
||||
# sideshow_pending_customer
|
||||
op.drop_table('sideshow_pending_customer')
|
||||
|
||||
# enums
|
||||
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingproductstatus').drop(op.get_bind())
|
||||
sa.Enum('PENDING', 'READY', 'RESOLVED', name='pendingcustomerstatus').drop(op.get_bind())
|
|
@ -22,9 +22,32 @@
|
|||
################################################################################
|
||||
"""
|
||||
Sideshow data models
|
||||
|
||||
This is the default :term:`app model` module for Sideshow.
|
||||
|
||||
This namespace exposes everything from
|
||||
:mod:`wuttjamaican:wuttjamaican.db.model`, plus the following.
|
||||
|
||||
Primary :term:`data models <data model>`:
|
||||
|
||||
* :class:`~sideshow.db.model.orders.Order`
|
||||
* :class:`~sideshow.db.model.orders.OrderItem`
|
||||
* :class:`~sideshow.db.model.customers.PendingCustomer`
|
||||
* :class:`~sideshow.db.model.products.PendingProduct`
|
||||
|
||||
And the :term:`batch` models:
|
||||
|
||||
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
* :class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||
"""
|
||||
|
||||
# bring in all of wutta
|
||||
from wuttjamaican.db.model import *
|
||||
|
||||
# TODO: import other/custom models here...
|
||||
# sideshow models
|
||||
from .customers import PendingCustomer
|
||||
from .products import PendingProduct
|
||||
from .orders import Order, OrderItem
|
||||
|
||||
# batch models
|
||||
from .batch.neworder import NewOrderBatch, NewOrderBatchRow
|
||||
|
|
0
src/sideshow/db/model/batch/__init__.py
Normal file
0
src/sideshow/db/model/batch/__init__.py
Normal file
310
src/sideshow/db/model/batch/neworder.py
Normal file
310
src/sideshow/db/model/batch/neworder.py
Normal file
|
@ -0,0 +1,310 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Data models for New Order Batch
|
||||
|
||||
* :class:`NewOrderBatch`
|
||||
* :class:`NewOrderBatchRow`
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.ext.declarative import declared_attr
|
||||
|
||||
from wuttjamaican.db import model
|
||||
|
||||
|
||||
class NewOrderBatch(model.BatchMixin, model.Base):
|
||||
"""
|
||||
:term:`Batch <batch>` used for entering new :term:`orders <order>`
|
||||
into the system. Each batch ultimately becomes an
|
||||
:class:`~sideshow.db.model.orders.Order`.
|
||||
|
||||
See also :class:`~sideshow.batch.neworder.NewOrderBatchHandler`
|
||||
which is the default :term:`batch handler` for this :term:`batch
|
||||
type`.
|
||||
|
||||
Generic batch attributes (undocumented below) are inherited from
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin`.
|
||||
"""
|
||||
__tablename__ = 'sideshow_batch_neworder'
|
||||
__batchrow_class__ = 'NewOrderBatchRow'
|
||||
|
||||
batch_type = 'neworder'
|
||||
"""
|
||||
Official :term:`batch type` key.
|
||||
"""
|
||||
|
||||
@declared_attr
|
||||
def __table_args__(cls):
|
||||
return cls.__default_table_args__() + (
|
||||
sa.ForeignKeyConstraint(['pending_customer_uuid'], ['sideshow_pending_customer.uuid']),
|
||||
)
|
||||
|
||||
STATUS_OK = 1
|
||||
|
||||
STATUS = {
|
||||
STATUS_OK : "ok",
|
||||
}
|
||||
|
||||
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||
ID of the store to which the order pertains, if applicable.
|
||||
""")
|
||||
|
||||
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the proper customer account to which the order pertains, if
|
||||
applicable.
|
||||
|
||||
This will be set only when an "existing" customer account can be
|
||||
selected for the order. See also :attr:`pending_customer`.
|
||||
""")
|
||||
|
||||
pending_customer_uuid = sa.Column(model.UUID(), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def pending_customer(cls):
|
||||
return orm.relationship(
|
||||
'PendingCustomer',
|
||||
back_populates='new_order_batches',
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~sideshow.db.model.customers.PendingCustomer`
|
||||
record for the order, if applicable.
|
||||
|
||||
This is set only when making an order for a "new /
|
||||
unknown" customer. See also :attr:`customer_id`.
|
||||
""")
|
||||
|
||||
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Name for the customer account.
|
||||
""")
|
||||
|
||||
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Phone number for the customer.
|
||||
""")
|
||||
|
||||
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Email address for the customer.
|
||||
""")
|
||||
|
||||
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
|
||||
Full price (not including tax etc.) for all items on the order.
|
||||
""")
|
||||
|
||||
|
||||
class NewOrderBatchRow(model.BatchRowMixin, model.Base):
|
||||
"""
|
||||
Row of data within a :class:`NewOrderBatch`. Each row ultimately
|
||||
becomes an :class:`~sideshow.db.model.orders.OrderItem`.
|
||||
|
||||
Generic row attributes (undocumented below) are inherited from
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.batch.BatchRowMixin`.
|
||||
"""
|
||||
__tablename__ = 'sideshow_batch_neworder_row'
|
||||
__batch_class__ = NewOrderBatch
|
||||
|
||||
@declared_attr
|
||||
def __table_args__(cls):
|
||||
return cls.__default_table_args__() + (
|
||||
sa.ForeignKeyConstraint(['pending_product_uuid'], ['sideshow_pending_product.uuid']),
|
||||
)
|
||||
|
||||
STATUS_OK = 1
|
||||
"""
|
||||
This is the default value for :attr:`status_code`. All rows are
|
||||
considered "OK" if they have either a :attr:`product_id` or
|
||||
:attr:`pending_product`.
|
||||
"""
|
||||
|
||||
STATUS_MISSING_PRODUCT = 2
|
||||
"""
|
||||
Status code indicating the row has no :attr:`product_id` or
|
||||
:attr:`pending_product` set.
|
||||
"""
|
||||
|
||||
STATUS_MISSING_ORDER_QTY = 3
|
||||
"""
|
||||
Status code indicating the row has no :attr:`order_qty` and/or
|
||||
:attr:`order_uom` set.
|
||||
"""
|
||||
|
||||
STATUS = {
|
||||
STATUS_OK : "ok",
|
||||
STATUS_MISSING_PRODUCT : "missing product",
|
||||
STATUS_MISSING_ORDER_QTY : "missing order qty/uom",
|
||||
}
|
||||
"""
|
||||
Dict of possible status code -> label options.
|
||||
"""
|
||||
|
||||
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the true product which the order item represents, if
|
||||
applicable.
|
||||
|
||||
This will be set only when an "existing" product can be selected
|
||||
for the order. See also :attr:`pending_product`.
|
||||
""")
|
||||
|
||||
pending_product_uuid = sa.Column(model.UUID(), nullable=True)
|
||||
|
||||
@declared_attr
|
||||
def pending_product(cls):
|
||||
return orm.relationship(
|
||||
'PendingProduct',
|
||||
back_populates='new_order_batch_rows',
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~sideshow.db.model.products.PendingProduct` record
|
||||
for the order item, if applicable.
|
||||
|
||||
This is set only when making an order for a "new /
|
||||
unknown" product. See also :attr:`product_id`.
|
||||
""")
|
||||
|
||||
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||
Scancode for the product, as string.
|
||||
|
||||
.. note::
|
||||
|
||||
This column allows 14 chars, so can store a full GPC with check
|
||||
digit. However as of writing the actual format used here does
|
||||
not matter to Sideshow logic; "anything" should work.
|
||||
|
||||
That may change eventually, depending on POS integration
|
||||
scenarios that come up. Maybe a config option to declare
|
||||
whether check digit should be included or not, etc.
|
||||
""")
|
||||
|
||||
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Brand name for the product - up to 100 chars.
|
||||
""")
|
||||
|
||||
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Description for the product - up to 255 chars.
|
||||
""")
|
||||
|
||||
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Size of the product, as string - up to 30 chars.
|
||||
""")
|
||||
|
||||
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the product is sold by weight; default is null.
|
||||
""")
|
||||
|
||||
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||
ID of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Name of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the item is a "special order" - e.g. something not
|
||||
normally carried by the store. Default is null.
|
||||
""")
|
||||
|
||||
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Case pack count for the product, if known.
|
||||
|
||||
If this is not set, then customer cannot order a "case" of the item.
|
||||
""")
|
||||
|
||||
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
|
||||
Quantity (as decimal) of product being ordered.
|
||||
|
||||
This must be interpreted along with :attr:`order_uom` to determine
|
||||
the *complete* order quantity, e.g. "2 cases".
|
||||
""")
|
||||
|
||||
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
|
||||
Code indicating the unit of measure for product being ordered.
|
||||
|
||||
This should be one of the codes from
|
||||
:data:`~sideshow.enum.ORDER_UOM`.
|
||||
|
||||
Sideshow will treat :data:`~sideshow.enum.ORDER_UOM_CASE`
|
||||
differently but :data:`~sideshow.enum.ORDER_UOM_UNIT` and others
|
||||
are all treated the same (i.e. "unit" is assumed).
|
||||
""")
|
||||
|
||||
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||
Cost of goods amount for one "unit" (not "case") of the product,
|
||||
as decimal to 4 places.
|
||||
""")
|
||||
|
||||
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Regular price for the item unit. Unless a sale is in effect,
|
||||
:attr:`unit_price_quoted` will typically match this value.
|
||||
""")
|
||||
|
||||
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Sale price for the item unit, if applicable. If set, then
|
||||
:attr:`unit_price_quoted` will typically match this value. See
|
||||
also :attr:`sale_ends`.
|
||||
""")
|
||||
|
||||
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
|
||||
End date/time for the sale in effect, if any.
|
||||
|
||||
This is only relevant if :attr:`unit_price_sale` is set.
|
||||
""")
|
||||
|
||||
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Quoted price for the item unit. This is the "effective" unit
|
||||
price, which is used to calculate :attr:`total_price`.
|
||||
|
||||
This price does *not* reflect the :attr:`discount_percent`. It
|
||||
normally should match either :attr:`unit_price_reg` or
|
||||
:attr:`unit_price_sale`.
|
||||
|
||||
See also :attr:`case_price_quoted`, if applicable.
|
||||
""")
|
||||
|
||||
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Quoted price for a "case" of the item, if applicable.
|
||||
|
||||
This is mostly for display purposes; :attr:`unit_price_quoted` is
|
||||
used for calculations.
|
||||
""")
|
||||
|
||||
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
|
||||
Discount percent to apply when calculating :attr:`total_price`, if
|
||||
applicable.
|
||||
""")
|
||||
|
||||
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Full price (not including tax etc.) which the customer is quoted
|
||||
for the order item.
|
||||
|
||||
This is calculated using values from:
|
||||
|
||||
* :attr:`unit_price_quoted`
|
||||
* :attr:`order_qty`
|
||||
* :attr:`order_uom`
|
||||
* :attr:`case_size`
|
||||
* :attr:`discount_percent`
|
||||
""")
|
||||
|
||||
def __str__(self):
|
||||
return str(self.pending_product or self.product_description or "")
|
112
src/sideshow/db/model/customers.py
Normal file
112
src/sideshow/db/model/customers.py
Normal file
|
@ -0,0 +1,112 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Data models for Customers
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from wuttjamaican.db import model
|
||||
|
||||
from sideshow.enum import PendingCustomerStatus
|
||||
|
||||
|
||||
class PendingCustomer(model.Base):
|
||||
"""
|
||||
A "pending" customer record, used when entering an :term:`order`
|
||||
for new/unknown customer.
|
||||
"""
|
||||
__tablename__ = 'sideshow_pending_customer'
|
||||
|
||||
uuid = model.uuid_column()
|
||||
|
||||
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the proper customer account associated with this record, if
|
||||
applicable.
|
||||
""")
|
||||
|
||||
full_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Full display name for the customer account.
|
||||
""")
|
||||
|
||||
first_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||
First name of the customer.
|
||||
""")
|
||||
|
||||
last_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||
Last name of the customer.
|
||||
""")
|
||||
|
||||
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Phone number for the customer.
|
||||
""")
|
||||
|
||||
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Email address for the customer.
|
||||
""")
|
||||
|
||||
status = sa.Column(sa.Enum(PendingCustomerStatus), nullable=False, doc="""
|
||||
Status code for the customer record.
|
||||
""")
|
||||
|
||||
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
|
||||
default=datetime.datetime.now, doc="""
|
||||
Timestamp when the customer record was created.
|
||||
""")
|
||||
|
||||
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||
created_by = orm.relationship(
|
||||
model.User,
|
||||
cascade_backrefs=False,
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||
created the customer record.
|
||||
""")
|
||||
|
||||
orders = orm.relationship(
|
||||
'Order',
|
||||
order_by='Order.order_id.desc()',
|
||||
cascade_backrefs=False,
|
||||
back_populates='pending_customer',
|
||||
doc="""
|
||||
List of :class:`~sideshow.db.model.orders.Order` records
|
||||
associated with this customer.
|
||||
""")
|
||||
|
||||
new_order_batches = orm.relationship(
|
||||
'NewOrderBatch',
|
||||
order_by='NewOrderBatch.id.desc()',
|
||||
cascade_backrefs=False,
|
||||
back_populates='pending_customer',
|
||||
doc="""
|
||||
List of
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
records associated with this customer.
|
||||
""")
|
||||
|
||||
def __str__(self):
|
||||
return self.full_name or ""
|
314
src/sideshow/db/model/orders.py
Normal file
314
src/sideshow/db/model/orders.py
Normal file
|
@ -0,0 +1,314 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Data models for Orders
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy.ext.orderinglist import ordering_list
|
||||
|
||||
from wuttjamaican.db import model
|
||||
|
||||
|
||||
class Order(model.Base):
|
||||
"""
|
||||
Represents an :term:`order` for a customer. Each order has one or
|
||||
more :attr:`items`.
|
||||
|
||||
Usually, orders are created by way of a
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
|
||||
"""
|
||||
__tablename__ = 'sideshow_order'
|
||||
|
||||
# TODO: this feels a bit hacky yet but it does avoid problems
|
||||
# showing the Orders grid for a PendingCustomer
|
||||
__colanderalchemy_config__ = {
|
||||
'excludes': ['items'],
|
||||
}
|
||||
|
||||
uuid = model.uuid_column()
|
||||
|
||||
order_id = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||
Unique ID for the order.
|
||||
|
||||
When the order is created from New Order Batch, this order ID will
|
||||
match the batch ID.
|
||||
""")
|
||||
|
||||
store_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||
ID of the store to which the order pertains, if applicable.
|
||||
""")
|
||||
|
||||
customer_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the proper customer account to which the order pertains, if
|
||||
applicable.
|
||||
|
||||
This will be set only when an "existing" customer account can be
|
||||
assigned for the order. See also :attr:`pending_customer`.
|
||||
""")
|
||||
|
||||
pending_customer_uuid = model.uuid_fk_column('sideshow_pending_customer.uuid', nullable=True)
|
||||
pending_customer = orm.relationship(
|
||||
'PendingCustomer',
|
||||
cascade_backrefs=False,
|
||||
back_populates='orders',
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~sideshow.db.model.customers.PendingCustomer` record
|
||||
for the order, if applicable.
|
||||
|
||||
This is set only when the order is for a "new / unknown"
|
||||
customer. See also :attr:`customer_id`.
|
||||
""")
|
||||
|
||||
customer_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Name for the customer account.
|
||||
""")
|
||||
|
||||
phone_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Phone number for the customer.
|
||||
""")
|
||||
|
||||
email_address = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Email address for the customer.
|
||||
""")
|
||||
|
||||
total_price = sa.Column(sa.Numeric(precision=10, scale=3), nullable=True, doc="""
|
||||
Full price (not including tax etc.) for all items on the order.
|
||||
""")
|
||||
|
||||
created = sa.Column(sa.DateTime(timezone=True), nullable=False, default=datetime.datetime.now, doc="""
|
||||
Timestamp when the order was created.
|
||||
|
||||
If the order is created via New Order Batch, this will match the
|
||||
batch execution timestamp.
|
||||
""")
|
||||
|
||||
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||
created_by = orm.relationship(
|
||||
model.User,
|
||||
cascade_backrefs=False,
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||
created the order.
|
||||
""")
|
||||
|
||||
items = orm.relationship(
|
||||
'OrderItem',
|
||||
collection_class=ordering_list('sequence', count_from=1),
|
||||
cascade='all, delete-orphan',
|
||||
cascade_backrefs=False,
|
||||
back_populates='order',
|
||||
doc="""
|
||||
List of :class:`OrderItem` records belonging to the order.
|
||||
""")
|
||||
|
||||
def __str__(self):
|
||||
return str(self.order_id)
|
||||
|
||||
|
||||
class OrderItem(model.Base):
|
||||
"""
|
||||
Represents an :term:`order item` within an :class:`Order`.
|
||||
|
||||
Usually these are created from
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||
records.
|
||||
"""
|
||||
__tablename__ = 'sideshow_order_item'
|
||||
|
||||
uuid = model.uuid_column()
|
||||
|
||||
order_uuid = model.uuid_fk_column('sideshow_order.uuid', nullable=False)
|
||||
order = orm.relationship(
|
||||
Order,
|
||||
cascade_backrefs=False,
|
||||
back_populates='items',
|
||||
doc="""
|
||||
Reference to the :class:`Order` to which the item belongs.
|
||||
""")
|
||||
|
||||
sequence = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||
1-based numeric sequence for the item, i.e. its line number within
|
||||
the order.
|
||||
""")
|
||||
|
||||
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the true product which the order item represents, if
|
||||
applicable.
|
||||
|
||||
This will be set only when an "existing" product can be selected
|
||||
for the order. See also :attr:`pending_product`.
|
||||
""")
|
||||
|
||||
pending_product_uuid = model.uuid_fk_column('sideshow_pending_product.uuid', nullable=True)
|
||||
pending_product = orm.relationship(
|
||||
'PendingProduct',
|
||||
cascade_backrefs=False,
|
||||
back_populates='order_items',
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~sideshow.db.model.products.PendingProduct` record for
|
||||
the order item, if applicable.
|
||||
|
||||
This is set only when the order item is for a "new / unknown"
|
||||
product. See also :attr:`product_id`.
|
||||
""")
|
||||
|
||||
product_scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||
Scancode for the product, as string.
|
||||
|
||||
.. note::
|
||||
|
||||
This column allows 14 chars, so can store a full GPC with check
|
||||
digit. However as of writing the actual format used here does
|
||||
not matter to Sideshow logic; "anything" should work.
|
||||
|
||||
That may change eventually, depending on POS integration
|
||||
scenarios that come up. Maybe a config option to declare
|
||||
whether check digit should be included or not, etc.
|
||||
""")
|
||||
|
||||
product_brand = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Brand name for the product - up to 100 chars.
|
||||
""")
|
||||
|
||||
product_description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Description for the product - up to 255 chars.
|
||||
""")
|
||||
|
||||
product_size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Size of the product, as string - up to 30 chars.
|
||||
""")
|
||||
|
||||
product_weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the product is sold by weight; default is null.
|
||||
""")
|
||||
|
||||
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||
ID of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Name of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the item is a "special order" - e.g. something not
|
||||
normally carried by the store. Default is null.
|
||||
""")
|
||||
|
||||
case_size = sa.Column(sa.Numeric(precision=10, scale=4), nullable=True, doc="""
|
||||
Case pack count for the product, if known.
|
||||
""")
|
||||
|
||||
order_qty = sa.Column(sa.Numeric(precision=10, scale=4), nullable=False, doc="""
|
||||
Quantity (as decimal) of product being ordered.
|
||||
|
||||
This must be interpreted along with :attr:`order_uom` to determine
|
||||
the *complete* order quantity, e.g. "2 cases".
|
||||
""")
|
||||
|
||||
order_uom = sa.Column(sa.String(length=10), nullable=False, doc="""
|
||||
Code indicating the unit of measure for product being ordered.
|
||||
|
||||
This should be one of the codes from
|
||||
:data:`~sideshow.enum.ORDER_UOM`.
|
||||
""")
|
||||
|
||||
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||
Cost of goods amount for one "unit" (not "case") of the product,
|
||||
as decimal to 4 places.
|
||||
""")
|
||||
|
||||
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Regular price for the item unit. Unless a sale is in effect,
|
||||
:attr:`unit_price_quoted` will typically match this value.
|
||||
""")
|
||||
|
||||
unit_price_sale = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Sale price for the item unit, if applicable. If set, then
|
||||
:attr:`unit_price_quoted` will typically match this value. See
|
||||
also :attr:`sale_ends`.
|
||||
""")
|
||||
|
||||
sale_ends = sa.Column(sa.DateTime(timezone=True), nullable=True, doc="""
|
||||
End date/time for the sale in effect, if any.
|
||||
|
||||
This is only relevant if :attr:`unit_price_sale` is set.
|
||||
""")
|
||||
|
||||
unit_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Quoted price for the item unit. This is the "effective" unit
|
||||
price, which is used to calculate :attr:`total_price`.
|
||||
|
||||
This price does *not* reflect the :attr:`discount_percent`. It
|
||||
normally should match either :attr:`unit_price_reg` or
|
||||
:attr:`unit_price_sale`.
|
||||
""")
|
||||
|
||||
case_price_quoted = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Quoted price for a "case" of the item, if applicable.
|
||||
|
||||
This is mostly for display purposes; :attr:`unit_price_quoted` is
|
||||
used for calculations.
|
||||
""")
|
||||
|
||||
discount_percent = sa.Column(sa.Numeric(precision=5, scale=3), nullable=True, doc="""
|
||||
Discount percent to apply when calculating :attr:`total_price`, if
|
||||
applicable.
|
||||
""")
|
||||
|
||||
total_price = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Full price (not including tax etc.) which the customer is quoted
|
||||
for the order item.
|
||||
|
||||
This is calculated using values from:
|
||||
|
||||
* :attr:`unit_price_quoted`
|
||||
* :attr:`order_qty`
|
||||
* :attr:`order_uom`
|
||||
* :attr:`case_size`
|
||||
* :attr:`discount_percent`
|
||||
""")
|
||||
|
||||
status_code = sa.Column(sa.Integer(), nullable=False, doc="""
|
||||
Code indicating current status for the order item.
|
||||
""")
|
||||
|
||||
paid_amount = sa.Column(sa.Numeric(precision=8, scale=3), nullable=False, default=0, doc="""
|
||||
Amount which the customer has paid toward the :attr:`total_price`
|
||||
of the item.
|
||||
""")
|
||||
|
||||
payment_transaction_number = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Transaction number in which payment for the order was taken, if
|
||||
applicable/known.
|
||||
""")
|
||||
|
||||
def __str__(self):
|
||||
return str(self.pending_product or self.product_description or "")
|
173
src/sideshow/db/model/products.py
Normal file
173
src/sideshow/db/model/products.py
Normal file
|
@ -0,0 +1,173 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Data models for Products
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import orm
|
||||
|
||||
from wuttjamaican.db import model
|
||||
|
||||
from sideshow.enum import PendingProductStatus
|
||||
|
||||
|
||||
class PendingProduct(model.Base):
|
||||
"""
|
||||
A "pending" product record, used when entering an :term:`order
|
||||
item` for new/unknown product.
|
||||
"""
|
||||
__tablename__ = 'sideshow_pending_product'
|
||||
|
||||
uuid = model.uuid_column()
|
||||
|
||||
product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
ID of the true product associated with this record, if applicable.
|
||||
""")
|
||||
|
||||
scancode = sa.Column(sa.String(length=14), nullable=True, doc="""
|
||||
Scancode for the product, as string.
|
||||
|
||||
.. note::
|
||||
|
||||
This column allows 14 chars, so can store a full GPC with check
|
||||
digit. However as of writing the actual format used here does
|
||||
not matter to Sideshow logic; "anything" should work.
|
||||
|
||||
That may change eventually, depending on POS integration
|
||||
scenarios that come up. Maybe a config option to declare
|
||||
whether check digit should be included or not, etc.
|
||||
""")
|
||||
|
||||
brand_name = sa.Column(sa.String(length=100), nullable=True, doc="""
|
||||
Brand name for the product - up to 100 chars.
|
||||
""")
|
||||
|
||||
description = sa.Column(sa.String(length=255), nullable=True, doc="""
|
||||
Description for the product - up to 255 chars.
|
||||
""")
|
||||
|
||||
size = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Size of the product, as string - up to 30 chars.
|
||||
""")
|
||||
|
||||
weighed = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the product is sold by weight; default is null.
|
||||
""")
|
||||
|
||||
department_id = sa.Column(sa.String(length=10), nullable=True, doc="""
|
||||
ID of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
department_name = sa.Column(sa.String(length=30), nullable=True, doc="""
|
||||
Name of the department to which the product belongs, if known.
|
||||
""")
|
||||
|
||||
special_order = sa.Column(sa.Boolean(), nullable=True, doc="""
|
||||
Flag indicating the item is a "special order" - e.g. something not
|
||||
normally carried by the store. Default is null.
|
||||
""")
|
||||
|
||||
vendor_name = sa.Column(sa.String(length=50), nullable=True, doc="""
|
||||
Name of vendor from which product may be purchased, if known. See
|
||||
also :attr:`vendor_item_code`.
|
||||
""")
|
||||
|
||||
vendor_item_code = sa.Column(sa.String(length=20), nullable=True, doc="""
|
||||
Item code (SKU) to use when ordering this product from the vendor
|
||||
identified by :attr:`vendor_name`, if known.
|
||||
""")
|
||||
|
||||
case_size = sa.Column(sa.Numeric(precision=9, scale=4), nullable=True, doc="""
|
||||
Case pack count for the product, if known.
|
||||
""")
|
||||
|
||||
unit_cost = sa.Column(sa.Numeric(precision=9, scale=5), nullable=True, doc="""
|
||||
Cost of goods amount for one "unit" (not "case") of the product,
|
||||
as decimal to 4 places.
|
||||
""")
|
||||
|
||||
unit_price_reg = sa.Column(sa.Numeric(precision=8, scale=3), nullable=True, doc="""
|
||||
Regular price for a "unit" of the product.
|
||||
""")
|
||||
|
||||
notes = sa.Column(sa.Text(), nullable=True, doc="""
|
||||
Arbitrary notes regarding the product, if applicable.
|
||||
""")
|
||||
|
||||
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""
|
||||
Status code for the product record.
|
||||
""")
|
||||
|
||||
created = sa.Column(sa.DateTime(timezone=True), nullable=False,
|
||||
default=datetime.datetime.now, doc="""
|
||||
Timestamp when the product record was created.
|
||||
""")
|
||||
|
||||
created_by_uuid = model.uuid_fk_column('user.uuid', nullable=False)
|
||||
created_by = orm.relationship(
|
||||
model.User,
|
||||
cascade_backrefs=False,
|
||||
doc="""
|
||||
Reference to the
|
||||
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
|
||||
created the product record.
|
||||
""")
|
||||
|
||||
order_items = orm.relationship(
|
||||
'OrderItem',
|
||||
# TODO
|
||||
# order_by='NewOrderBatchRow.id.desc()',
|
||||
cascade_backrefs=False,
|
||||
back_populates='pending_product',
|
||||
doc="""
|
||||
List of :class:`~sideshow.db.model.orders.OrderItem` records
|
||||
associated with this product.
|
||||
""")
|
||||
|
||||
new_order_batch_rows = orm.relationship(
|
||||
'NewOrderBatchRow',
|
||||
# TODO
|
||||
# order_by='NewOrderBatchRow.id.desc()',
|
||||
cascade_backrefs=False,
|
||||
back_populates='pending_product',
|
||||
doc="""
|
||||
List of
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatchRow`
|
||||
records associated with this product.
|
||||
""")
|
||||
|
||||
@property
|
||||
def full_description(self):
|
||||
""" """
|
||||
fields = [
|
||||
self.brand_name or '',
|
||||
self.description or '',
|
||||
self.size or '']
|
||||
fields = [f.strip() for f in fields if f.strip()]
|
||||
return ' '.join(fields)
|
||||
|
||||
def __str__(self):
|
||||
return self.full_description
|
146
src/sideshow/enum.py
Normal file
146
src/sideshow/enum.py
Normal file
|
@ -0,0 +1,146 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Enum Values
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from collections import OrderedDict
|
||||
|
||||
from wuttjamaican.enum import *
|
||||
|
||||
|
||||
ORDER_UOM_CASE = 'CS'
|
||||
"""
|
||||
UOM code for ordering a "case" of product.
|
||||
|
||||
Sideshow will treat "case" orders somewhat differently as compared to
|
||||
"unit" orders.
|
||||
"""
|
||||
|
||||
ORDER_UOM_UNIT = 'EA'
|
||||
"""
|
||||
UOM code for ordering a "unit" of product.
|
||||
|
||||
This is the default "unit" UOM but in practice all others are treated
|
||||
the same by Sideshow, whereas "case" orders are treated somewhat
|
||||
differently.
|
||||
"""
|
||||
|
||||
ORDER_UOM_KILOGRAM = 'KG'
|
||||
"""
|
||||
UOM code for ordering a "kilogram" of product.
|
||||
|
||||
This is treated same as "unit" by Sideshow. However it should
|
||||
(probably?) only be used for items where
|
||||
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
|
||||
true.
|
||||
"""
|
||||
|
||||
ORDER_UOM_POUND = 'LB'
|
||||
"""
|
||||
UOM code for ordering a "pound" of product.
|
||||
|
||||
This is treated same as "unit" by Sideshow. However it should
|
||||
(probably?) only be used for items where
|
||||
e.g. :attr:`~sideshow.db.model.orders.OrderItem.product_weighed` is
|
||||
true.
|
||||
"""
|
||||
|
||||
ORDER_UOM = OrderedDict([
|
||||
(ORDER_UOM_CASE, "Cases"),
|
||||
(ORDER_UOM_UNIT, "Units"),
|
||||
(ORDER_UOM_KILOGRAM, "Kilograms"),
|
||||
(ORDER_UOM_POUND, "Pounds"),
|
||||
])
|
||||
"""
|
||||
Dict of possible code -> label options for ordering unit of measure.
|
||||
|
||||
These codes are referenced by:
|
||||
|
||||
* :attr:`sideshow.db.model.batch.neworder.NewOrderBatchRow.order_uom`
|
||||
* :attr:`sideshow.db.model.orders.OrderItem.order_uom`
|
||||
"""
|
||||
|
||||
|
||||
class PendingCustomerStatus(Enum):
|
||||
"""
|
||||
Enum values for
|
||||
:attr:`sideshow.db.model.customers.PendingCustomer.status`.
|
||||
"""
|
||||
PENDING = 'pending'
|
||||
READY = 'ready'
|
||||
RESOLVED = 'resolved'
|
||||
|
||||
|
||||
class PendingProductStatus(Enum):
|
||||
"""
|
||||
Enum values for
|
||||
:attr:`sideshow.db.model.products.PendingProduct.status`.
|
||||
"""
|
||||
PENDING = 'pending'
|
||||
READY = 'ready'
|
||||
RESOLVED = 'resolved'
|
||||
|
||||
|
||||
########################################
|
||||
# Order Item Status
|
||||
########################################
|
||||
|
||||
ORDER_ITEM_STATUS_UNINITIATED = 1
|
||||
ORDER_ITEM_STATUS_INITIATED = 10
|
||||
ORDER_ITEM_STATUS_PAID_BEFORE = 50
|
||||
# TODO: deprecate / remove this one
|
||||
ORDER_ITEM_STATUS_PAID = ORDER_ITEM_STATUS_PAID_BEFORE
|
||||
ORDER_ITEM_STATUS_READY = 100
|
||||
ORDER_ITEM_STATUS_PLACED = 200
|
||||
ORDER_ITEM_STATUS_RECEIVED = 300
|
||||
ORDER_ITEM_STATUS_CONTACTED = 350
|
||||
ORDER_ITEM_STATUS_CONTACT_FAILED = 375
|
||||
ORDER_ITEM_STATUS_DELIVERED = 500
|
||||
ORDER_ITEM_STATUS_PAID_AFTER = 550
|
||||
ORDER_ITEM_STATUS_CANCELED = 900
|
||||
ORDER_ITEM_STATUS_REFUND_PENDING = 910
|
||||
ORDER_ITEM_STATUS_REFUNDED = 920
|
||||
ORDER_ITEM_STATUS_RESTOCKED = 930
|
||||
ORDER_ITEM_STATUS_EXPIRED = 940
|
||||
ORDER_ITEM_STATUS_INACTIVE = 950
|
||||
|
||||
ORDER_ITEM_STATUS = OrderedDict([
|
||||
(ORDER_ITEM_STATUS_UNINITIATED, "uninitiated"),
|
||||
(ORDER_ITEM_STATUS_INITIATED, "initiated"),
|
||||
(ORDER_ITEM_STATUS_PAID_BEFORE, "paid"),
|
||||
(ORDER_ITEM_STATUS_READY, "ready"),
|
||||
(ORDER_ITEM_STATUS_PLACED, "placed"),
|
||||
(ORDER_ITEM_STATUS_RECEIVED, "received"),
|
||||
(ORDER_ITEM_STATUS_CONTACTED, "contacted"),
|
||||
(ORDER_ITEM_STATUS_CONTACT_FAILED, "contact failed"),
|
||||
(ORDER_ITEM_STATUS_DELIVERED, "delivered"),
|
||||
(ORDER_ITEM_STATUS_PAID_AFTER, "paid"),
|
||||
(ORDER_ITEM_STATUS_CANCELED, "canceled"),
|
||||
(ORDER_ITEM_STATUS_REFUND_PENDING, "refund pending"),
|
||||
(ORDER_ITEM_STATUS_REFUNDED, "refunded"),
|
||||
(ORDER_ITEM_STATUS_RESTOCKED, "restocked"),
|
||||
(ORDER_ITEM_STATUS_EXPIRED, "expired"),
|
||||
(ORDER_ITEM_STATUS_INACTIVE, "inactive"),
|
||||
])
|
36
src/sideshow/testing.py
Normal file
36
src/sideshow/testing.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
# -*- 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 - test utilities
|
||||
"""
|
||||
|
||||
from wuttaweb import testing as base
|
||||
|
||||
|
||||
class WebTestCase(base.WebTestCase):
|
||||
|
||||
def make_config(self, **kwargs):
|
||||
config = super().make_config(**kwargs)
|
||||
config.setdefault('wutta.model_spec', 'sideshow.db.model')
|
||||
config.setdefault('wutta.enum_spec', 'sideshow.enum')
|
||||
return config
|
|
@ -43,7 +43,7 @@ def main(global_config, **settings):
|
|||
|
||||
# bring in the rest of Sideshow
|
||||
pyramid_config.include('sideshow.web.static')
|
||||
pyramid_config.include('sideshow.web.subscribers')
|
||||
pyramid_config.include('wuttaweb.subscribers')
|
||||
pyramid_config.include('sideshow.web.views')
|
||||
|
||||
return pyramid_config.make_wsgi_app()
|
||||
|
|
0
src/sideshow/web/forms/__init__.py
Normal file
0
src/sideshow/web/forms/__init__.py
Normal file
101
src/sideshow/web/forms/schema.py
Normal file
101
src/sideshow/web/forms/schema.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Form schema types
|
||||
"""
|
||||
|
||||
from wuttaweb.forms.schema import ObjectRef
|
||||
|
||||
|
||||
class OrderRef(ObjectRef):
|
||||
"""
|
||||
Custom schema type for an :class:`~sideshow.db.model.orders.Order`
|
||||
reference field.
|
||||
|
||||
This is a subclass of
|
||||
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model_class(self):
|
||||
""" """
|
||||
model = self.app.model
|
||||
return model.Order
|
||||
|
||||
def sort_query(self, query):
|
||||
""" """
|
||||
return query.order_by(self.model_class.order_id)
|
||||
|
||||
def get_object_url(self, order):
|
||||
""" """
|
||||
return self.request.route_url('orders.view', uuid=order.uuid)
|
||||
|
||||
|
||||
class PendingCustomerRef(ObjectRef):
|
||||
"""
|
||||
Custom schema type for a
|
||||
:class:`~sideshow.db.model.customers.PendingCustomer` reference
|
||||
field.
|
||||
|
||||
This is a subclass of
|
||||
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model_class(self):
|
||||
""" """
|
||||
model = self.app.model
|
||||
return model.PendingCustomer
|
||||
|
||||
def sort_query(self, query):
|
||||
""" """
|
||||
return query.order_by(self.model_class.full_name)
|
||||
|
||||
def get_object_url(self, customer):
|
||||
""" """
|
||||
return self.request.route_url('pending_customers.view', uuid=customer.uuid)
|
||||
|
||||
|
||||
class PendingProductRef(ObjectRef):
|
||||
"""
|
||||
Custom schema type for a
|
||||
:class:`~sideshow.db.model.products.PendingProduct` reference
|
||||
field.
|
||||
|
||||
This is a subclass of
|
||||
:class:`~wuttaweb:wuttaweb.forms.schema.ObjectRef`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def model_class(self):
|
||||
""" """
|
||||
model = self.app.model
|
||||
return model.PendingProduct
|
||||
|
||||
def sort_query(self, query):
|
||||
""" """
|
||||
return query.order_by(self.model_class.scancode)
|
||||
|
||||
def get_object_url(self, product):
|
||||
""" """
|
||||
return self.request.route_url('pending_products.view', uuid=product.uuid)
|
|
@ -33,15 +33,79 @@ class SideshowMenuHandler(base.MenuHandler):
|
|||
"""
|
||||
|
||||
def make_menus(self, request, **kwargs):
|
||||
""" """
|
||||
return [
|
||||
self.make_orders_menu(request),
|
||||
self.make_pending_menu(request),
|
||||
self.make_batch_menu(request),
|
||||
self.make_admin_menu(request),
|
||||
]
|
||||
|
||||
# TODO: override this if you need custom menus...
|
||||
def make_orders_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Orders menu.
|
||||
"""
|
||||
return {
|
||||
'title': "Orders",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Create New Order",
|
||||
'route': 'orders.create',
|
||||
'perm': 'orders.create',
|
||||
},
|
||||
{'type': 'sep'},
|
||||
{
|
||||
'title': "All Orders",
|
||||
'route': 'orders',
|
||||
'perm': 'orders.list',
|
||||
},
|
||||
{
|
||||
'title': "All Order Items",
|
||||
'route': 'order_items',
|
||||
'perm': 'order_items.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# menus = [
|
||||
# self.make_products_menu(request),
|
||||
# self.make_admin_menu(request),
|
||||
# ]
|
||||
def make_pending_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Pending menu.
|
||||
"""
|
||||
return {
|
||||
'title': "Pending",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "Customers",
|
||||
'route': 'pending_customers',
|
||||
'perm': 'pending_customers.list',
|
||||
},
|
||||
{
|
||||
'title': "Products",
|
||||
'route': 'pending_products',
|
||||
'perm': 'pending_products.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
# ...but for now this uses default menus
|
||||
menus = super().make_menus(request, **kwargs)
|
||||
def make_batch_menu(self, request, **kwargs):
|
||||
"""
|
||||
Generate a typical Batch menu.
|
||||
"""
|
||||
return {
|
||||
'title': "Batches",
|
||||
'type': 'menu',
|
||||
'items': [
|
||||
{
|
||||
'title': "New Orders",
|
||||
'route': 'neworder_batches',
|
||||
'perm': 'neworder_batches.list',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return menus
|
||||
def make_admin_menu(self, request, **kwargs):
|
||||
""" """
|
||||
kwargs['include_people'] = True
|
||||
return super().make_admin_menu(request, **kwargs)
|
||||
|
|
1522
src/sideshow/web/templates/orders/create.mako
Normal file
1522
src/sideshow/web/templates/orders/create.mako
Normal file
File diff suppressed because it is too large
Load diff
|
@ -30,5 +30,10 @@ def includeme(config):
|
|||
# core views for wuttaweb
|
||||
config.include('wuttaweb.views.essential')
|
||||
|
||||
# TODO: include your own views here
|
||||
#config.include('sideshow.web.views.widgets')
|
||||
# sideshow views
|
||||
config.include('sideshow.web.views.customers')
|
||||
config.include('sideshow.web.views.products')
|
||||
config.include('sideshow.web.views.orders')
|
||||
|
||||
# batch views
|
||||
config.include('sideshow.web.views.batch.neworder')
|
||||
|
|
0
src/sideshow/web/views/batch/__init__.py
Normal file
0
src/sideshow/web/views/batch/__init__.py
Normal file
189
src/sideshow/web/views/batch/neworder.py
Normal file
189
src/sideshow/web/views/batch/neworder.py
Normal file
|
@ -0,0 +1,189 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Views for New Order Batch
|
||||
"""
|
||||
|
||||
from wuttaweb.views.batch import BatchMasterView
|
||||
from wuttaweb.forms.schema import WuttaMoney
|
||||
|
||||
from sideshow.db.model import NewOrderBatch
|
||||
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||
from sideshow.web.forms.schema import PendingCustomerRef
|
||||
|
||||
|
||||
class NewOrderBatchView(BatchMasterView):
|
||||
"""
|
||||
Master view for :class:`~sideshow.db.model.batch.neworder.NewOrderBatch`.
|
||||
|
||||
Route prefix is ``neworder_batches``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/batch/neworder/``
|
||||
* ``/batch/neworder/XXX``
|
||||
* ``/batch/neworder/XXX/delete``
|
||||
|
||||
The purpose of this class is to expose "raw" batch data, e.g. for
|
||||
troubleshooting purposes by the admin. Ideally it is not very
|
||||
useful.
|
||||
|
||||
Note that the "create" and "edit" views are not exposed here,
|
||||
since those should be handled by
|
||||
:class:`~sideshow.web.views.orders.OrderView` instead.
|
||||
"""
|
||||
model_class = NewOrderBatch
|
||||
model_title = "New Order Batch"
|
||||
model_title_plural = "New Order Batches"
|
||||
route_prefix = 'neworder_batches'
|
||||
url_prefix = '/batch/neworder'
|
||||
creatable = False
|
||||
editable = False
|
||||
|
||||
labels = {
|
||||
'store_id': "Store ID",
|
||||
'customer_id': "Customer ID",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'id',
|
||||
'store_id',
|
||||
'customer_id',
|
||||
'customer_name',
|
||||
'phone_number',
|
||||
'email_address',
|
||||
'total_price',
|
||||
'row_count',
|
||||
'created',
|
||||
'created_by',
|
||||
'executed',
|
||||
]
|
||||
|
||||
filter_defaults = {
|
||||
'executed': {'active': True, 'verb': 'is_null'},
|
||||
}
|
||||
|
||||
form_fields = [
|
||||
'id',
|
||||
'store_id',
|
||||
'customer_id',
|
||||
'pending_customer',
|
||||
'customer_name',
|
||||
'phone_number',
|
||||
'email_address',
|
||||
'total_price',
|
||||
'row_count',
|
||||
'status_code',
|
||||
'created',
|
||||
'created_by',
|
||||
'executed',
|
||||
'executed_by',
|
||||
]
|
||||
|
||||
row_labels = {
|
||||
'product_scancode': "Scancode",
|
||||
'product_brand': "Brand",
|
||||
'product_description': "Description",
|
||||
'product_size': "Size",
|
||||
'order_uom': "Order UOM",
|
||||
}
|
||||
|
||||
row_grid_columns = [
|
||||
'sequence',
|
||||
'product_scancode',
|
||||
'product_brand',
|
||||
'product_description',
|
||||
'product_size',
|
||||
'special_order',
|
||||
'order_qty',
|
||||
'order_uom',
|
||||
'case_size',
|
||||
'total_price',
|
||||
'status_code',
|
||||
]
|
||||
|
||||
def get_batch_handler(self):
|
||||
""" """
|
||||
# TODO: call self.app.get_batch_handler()
|
||||
return NewOrderBatchHandler(self.config)
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', 'currency')
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
|
||||
# pending_customer
|
||||
f.set_node('pending_customer', PendingCustomerRef(self.request))
|
||||
|
||||
# total_price
|
||||
f.set_node('total_price', WuttaMoney(self.request))
|
||||
|
||||
def configure_row_grid(self, g):
|
||||
""" """
|
||||
super().configure_row_grid(g)
|
||||
enum = self.app.enum
|
||||
|
||||
# TODO
|
||||
# order_uom
|
||||
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.ORDER_UOM)
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', 'currency')
|
||||
|
||||
def get_xref_buttons(self, batch):
|
||||
"""
|
||||
Adds "View this Order" button, if batch has been executed and
|
||||
a corresponding :class:`~sideshow.db.model.orders.Order` can
|
||||
be located.
|
||||
"""
|
||||
buttons = super().get_xref_buttons(batch)
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
if batch.executed and self.request.has_perm('orders.view'):
|
||||
order = session.query(model.Order)\
|
||||
.filter(model.Order.order_id == batch.id)\
|
||||
.first()
|
||||
if order:
|
||||
url = self.request.route_url('orders.view', uuid=order.uuid)
|
||||
buttons.append(
|
||||
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
NewOrderBatchView = kwargs.get('NewOrderBatchView', base['NewOrderBatchView'])
|
||||
NewOrderBatchView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
246
src/sideshow/web/views/customers.py
Normal file
246
src/sideshow/web/views/customers.py
Normal file
|
@ -0,0 +1,246 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Views for Customers
|
||||
"""
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef, WuttaEnum
|
||||
|
||||
from sideshow.db.model import PendingCustomer
|
||||
|
||||
|
||||
class PendingCustomerView(MasterView):
|
||||
"""
|
||||
Master view for
|
||||
:class:`~sideshow.db.model.customers.PendingCustomer`; route
|
||||
prefix is ``pending_customers``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/pending/customers/``
|
||||
* ``/pending/customers/new``
|
||||
* ``/pending/customers/XXX``
|
||||
* ``/pending/customers/XXX/edit``
|
||||
* ``/pending/customers/XXX/delete``
|
||||
"""
|
||||
model_class = PendingCustomer
|
||||
model_title = "Pending Customer"
|
||||
route_prefix = 'pending_customers'
|
||||
url_prefix = '/pending/customers'
|
||||
|
||||
labels = {
|
||||
'customer_id': "Customer ID",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'full_name',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'phone_number',
|
||||
'email_address',
|
||||
'customer_id',
|
||||
'status',
|
||||
'created',
|
||||
'created_by',
|
||||
]
|
||||
|
||||
sort_defaults = 'full_name'
|
||||
|
||||
form_fields = [
|
||||
'customer_id',
|
||||
'full_name',
|
||||
'first_name',
|
||||
'middle_name',
|
||||
'last_name',
|
||||
'phone_number',
|
||||
'phone_type',
|
||||
'email_address',
|
||||
'email_type',
|
||||
'status',
|
||||
'created',
|
||||
'created_by',
|
||||
'orders',
|
||||
'new_order_batches',
|
||||
]
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
enum = self.app.enum
|
||||
|
||||
# status
|
||||
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingCustomerStatus)
|
||||
|
||||
# links
|
||||
g.set_link('full_name')
|
||||
g.set_link('first_name')
|
||||
g.set_link('last_name')
|
||||
g.set_link('phone_number')
|
||||
g.set_link('email_address')
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
enum = self.app.enum
|
||||
customer = f.model_instance
|
||||
|
||||
# customer_id
|
||||
if self.creating:
|
||||
f.remove('customer_id')
|
||||
else:
|
||||
f.set_readonly('customer_id')
|
||||
|
||||
# status
|
||||
if self.creating:
|
||||
f.remove('status')
|
||||
else:
|
||||
f.set_node('status', WuttaEnum(self.request, enum.PendingCustomerStatus))
|
||||
f.set_readonly('status')
|
||||
|
||||
# created
|
||||
if self.creating:
|
||||
f.remove('created')
|
||||
else:
|
||||
f.set_readonly('created')
|
||||
|
||||
# created_by
|
||||
if self.creating:
|
||||
f.remove('created_by')
|
||||
else:
|
||||
f.set_node('created_by', UserRef(self.request))
|
||||
f.set_readonly('created_by')
|
||||
|
||||
# orders
|
||||
if self.creating or self.editing:
|
||||
f.remove('orders')
|
||||
else:
|
||||
f.set_grid('orders', self.make_orders_grid(customer))
|
||||
|
||||
# new_order_batches
|
||||
if self.creating or self.editing:
|
||||
f.remove('new_order_batches')
|
||||
else:
|
||||
f.set_grid('new_order_batches', self.make_new_order_batches_grid(customer))
|
||||
|
||||
def make_orders_grid(self, customer):
|
||||
"""
|
||||
Make and return the grid for the Orders field.
|
||||
"""
|
||||
model = self.app.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
grid = self.make_grid(key=f'{route_prefix}.view.orders',
|
||||
model_class=model.Order,
|
||||
data=customer.orders,
|
||||
columns=[
|
||||
'order_id',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
],
|
||||
labels={
|
||||
'order_id': "Order ID",
|
||||
})
|
||||
grid.set_renderer('total_price', grid.render_currency)
|
||||
|
||||
if self.request.has_perm('orders.view'):
|
||||
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
|
||||
grid.add_action('view', icon='eye', url=url)
|
||||
grid.set_link('order_id')
|
||||
|
||||
return grid
|
||||
|
||||
def make_new_order_batches_grid(self, customer):
|
||||
"""
|
||||
Make and return the grid for the New Order Batches field.
|
||||
"""
|
||||
model = self.app.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
|
||||
model_class=model.NewOrderBatch,
|
||||
data=customer.new_order_batches,
|
||||
columns=[
|
||||
'id',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
'executed',
|
||||
],
|
||||
labels={
|
||||
'id': "Batch ID",
|
||||
},
|
||||
renderers={
|
||||
'id': 'batch_id',
|
||||
'total_price': 'currency',
|
||||
})
|
||||
|
||||
if self.request.has_perm('neworder_batches.view'):
|
||||
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
|
||||
grid.add_action('view', icon='eye', url=url)
|
||||
grid.set_link('id')
|
||||
|
||||
return grid
|
||||
|
||||
def objectify(self, form):
|
||||
""" """
|
||||
enum = self.app.enum
|
||||
customer = super().objectify(form)
|
||||
|
||||
if self.creating:
|
||||
customer.status = enum.PendingCustomerStatus.PENDING
|
||||
customer.created_by = self.request.user
|
||||
|
||||
return customer
|
||||
|
||||
def delete_instance(self, customer):
|
||||
""" """
|
||||
model_title = self.get_model_title()
|
||||
|
||||
# avoid deleting if still referenced by order(s)
|
||||
for order in customer.orders:
|
||||
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||
"to Order(s)", 'warning')
|
||||
raise self.redirect(self.get_action_url('view', customer))
|
||||
|
||||
# avoid deleting if still referenced by new order batch(es)
|
||||
for batch in customer.new_order_batches:
|
||||
if not batch.executed:
|
||||
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||
"to New Order Batch(es)", 'warning')
|
||||
raise self.redirect(self.get_action_url('view', customer))
|
||||
|
||||
# go ahead and delete per usual
|
||||
super().delete_instance(customer)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
PendingCustomerView = kwargs.get('PendingCustomerView', base['PendingCustomerView'])
|
||||
PendingCustomerView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
866
src/sideshow/web/views/orders.py
Normal file
866
src/sideshow/web/views/orders.py
Normal file
|
@ -0,0 +1,866 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Views for Orders
|
||||
"""
|
||||
|
||||
import decimal
|
||||
import logging
|
||||
|
||||
import colander
|
||||
from sqlalchemy import orm
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef, WuttaMoney, WuttaQuantity, WuttaEnum
|
||||
|
||||
from sideshow.db.model import Order, OrderItem
|
||||
from sideshow.batch.neworder import NewOrderBatchHandler
|
||||
from sideshow.web.forms.schema import OrderRef, PendingCustomerRef, PendingProductRef
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OrderView(MasterView):
|
||||
"""
|
||||
Master view for :class:`~sideshow.db.model.orders.Order`; route
|
||||
prefix is ``orders``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/orders/``
|
||||
* ``/orders/new``
|
||||
* ``/orders/XXX``
|
||||
* ``/orders/XXX/delete``
|
||||
|
||||
Note that the "edit" view is not exposed here; user must perform
|
||||
various other workflow actions to modify the order.
|
||||
"""
|
||||
model_class = Order
|
||||
editable = False
|
||||
|
||||
labels = {
|
||||
'order_id': "Order ID",
|
||||
'store_id': "Store ID",
|
||||
'customer_id': "Customer ID",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'order_id',
|
||||
'store_id',
|
||||
'customer_id',
|
||||
'customer_name',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
]
|
||||
|
||||
sort_defaults = ('order_id', 'desc')
|
||||
|
||||
form_fields = [
|
||||
'order_id',
|
||||
'store_id',
|
||||
'customer_id',
|
||||
'pending_customer',
|
||||
'customer_name',
|
||||
'phone_number',
|
||||
'email_address',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
]
|
||||
|
||||
has_rows = True
|
||||
row_model_class = OrderItem
|
||||
rows_title = "Order Items"
|
||||
rows_sort_defaults = 'sequence'
|
||||
rows_viewable = True
|
||||
|
||||
row_labels = {
|
||||
'product_scancode': "Scancode",
|
||||
'product_brand': "Brand",
|
||||
'product_description': "Description",
|
||||
'product_size': "Size",
|
||||
'department_name': "Department",
|
||||
'order_uom': "Order UOM",
|
||||
'status_code': "Status",
|
||||
}
|
||||
|
||||
row_grid_columns = [
|
||||
'sequence',
|
||||
'product_scancode',
|
||||
'product_brand',
|
||||
'product_description',
|
||||
'product_size',
|
||||
'department_name',
|
||||
'special_order',
|
||||
'order_qty',
|
||||
'order_uom',
|
||||
'total_price',
|
||||
'status_code',
|
||||
]
|
||||
|
||||
PENDING_PRODUCT_ENTRY_FIELDS = [
|
||||
'scancode',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'brand_name',
|
||||
'description',
|
||||
'size',
|
||||
'vendor_name',
|
||||
'vendor_item_code',
|
||||
'unit_cost',
|
||||
'case_size',
|
||||
'unit_price_reg',
|
||||
]
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
|
||||
# order_id
|
||||
g.set_link('order_id')
|
||||
|
||||
# customer_id
|
||||
g.set_link('customer_id')
|
||||
|
||||
# customer_name
|
||||
g.set_link('customer_name')
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', g.render_currency)
|
||||
|
||||
def create(self):
|
||||
"""
|
||||
Instead of the typical "create" view, this displays a "wizard"
|
||||
of sorts.
|
||||
|
||||
Under the hood a
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch` is
|
||||
automatically created for the user when they first visit this
|
||||
page. They can select a customer, add items etc.
|
||||
|
||||
When user is finished assembling the order (i.e. populating
|
||||
the batch), they submit it. This of course executes the
|
||||
batch, which in turn creates a true
|
||||
:class:`~sideshow.db.model.orders.Order`, and user is
|
||||
redirected to the "view order" page.
|
||||
"""
|
||||
enum = self.app.enum
|
||||
self.creating = True
|
||||
self.batch_handler = NewOrderBatchHandler(self.config)
|
||||
batch = self.get_current_batch()
|
||||
|
||||
context = self.get_context_customer(batch)
|
||||
|
||||
if self.request.method == 'POST':
|
||||
|
||||
# first we check for traditional form post
|
||||
action = self.request.POST.get('action')
|
||||
post_actions = [
|
||||
'start_over',
|
||||
'cancel_order',
|
||||
]
|
||||
if action in post_actions:
|
||||
return getattr(self, action)(batch)
|
||||
|
||||
# okay then, we'll assume newer JSON-style post params
|
||||
data = dict(self.request.json_body)
|
||||
action = data.pop('action')
|
||||
json_actions = [
|
||||
# 'assign_contact',
|
||||
# 'unassign_contact',
|
||||
# 'update_phone_number',
|
||||
# 'update_email_address',
|
||||
'set_pending_customer',
|
||||
# 'get_customer_info',
|
||||
# # 'set_customer_data',
|
||||
# 'get_product_info',
|
||||
# 'get_past_items',
|
||||
'add_item',
|
||||
'update_item',
|
||||
'delete_item',
|
||||
'submit_new_order',
|
||||
]
|
||||
if action in json_actions:
|
||||
result = getattr(self, action)(batch, data)
|
||||
return self.json_response(result)
|
||||
|
||||
return self.json_response({'error': "unknown form action"})
|
||||
|
||||
context.update({
|
||||
'batch': batch,
|
||||
'normalized_batch': self.normalize_batch(batch),
|
||||
'order_items': [self.normalize_row(row)
|
||||
for row in batch.rows],
|
||||
|
||||
'allow_unknown_product': True, # TODO
|
||||
'default_uom_choices': self.get_default_uom_choices(),
|
||||
'default_uom': None, # TODO?
|
||||
'pending_product_required_fields': self.get_pending_product_required_fields(),
|
||||
})
|
||||
return self.render_to_response('create', context)
|
||||
|
||||
def get_current_batch(self):
|
||||
"""
|
||||
Returns the current batch for the current user.
|
||||
|
||||
This looks for a new order batch which was created by the
|
||||
user, but not yet executed. If none is found, a new batch is
|
||||
created.
|
||||
|
||||
:returns:
|
||||
:class:`~sideshow.db.model.batch.neworder.NewOrderBatch`
|
||||
instance
|
||||
"""
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
user = self.request.user
|
||||
if not user:
|
||||
raise self.forbidden()
|
||||
|
||||
try:
|
||||
# there should be at most *one* new batch per user
|
||||
batch = session.query(model.NewOrderBatch)\
|
||||
.filter(model.NewOrderBatch.created_by == user)\
|
||||
.filter(model.NewOrderBatch.executed == None)\
|
||||
.one()
|
||||
|
||||
except orm.exc.NoResultFound:
|
||||
# no batch yet for this user, so make one
|
||||
batch = self.batch_handler.make_batch(session, created_by=user)
|
||||
session.add(batch)
|
||||
session.flush()
|
||||
|
||||
return batch
|
||||
|
||||
def get_pending_product_required_fields(self):
|
||||
""" """
|
||||
required = []
|
||||
for field in self.PENDING_PRODUCT_ENTRY_FIELDS:
|
||||
require = self.config.get_bool(
|
||||
f'sideshow.orders.unknown_product.fields.{field}.required')
|
||||
if require is None and field == 'description':
|
||||
require = True
|
||||
if require:
|
||||
required.append(field)
|
||||
return required
|
||||
|
||||
def start_over(self, batch):
|
||||
"""
|
||||
This will delete the user's current batch, then redirect user
|
||||
back to "Create Order" page, which in turn will auto-create a
|
||||
new batch for them.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
# drop current batch
|
||||
self.batch_handler.do_delete(batch, self.request.user)
|
||||
self.Session.flush()
|
||||
|
||||
# send back to "create order" which makes new batch
|
||||
route_prefix = self.get_route_prefix()
|
||||
url = self.request.route_url(f'{route_prefix}.create')
|
||||
return self.redirect(url)
|
||||
|
||||
def cancel_order(self, batch):
|
||||
"""
|
||||
This will delete the user's current batch, then redirect user
|
||||
back to "List Orders" page.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
self.batch_handler.do_delete(batch, self.request.user)
|
||||
self.Session.flush()
|
||||
|
||||
# set flash msg just to be more obvious
|
||||
self.request.session.flash("New order has been deleted.")
|
||||
|
||||
# send user back to orders list, w/ no new batch generated
|
||||
url = self.get_index_url()
|
||||
return self.redirect(url)
|
||||
|
||||
def get_context_customer(self, batch):
|
||||
""" """
|
||||
context = {
|
||||
'customer_id': batch.customer_id,
|
||||
'customer_name': batch.customer_name,
|
||||
'phone_number': batch.phone_number,
|
||||
'email_address': batch.email_address,
|
||||
'new_customer_name': None,
|
||||
'new_customer_first_name': None,
|
||||
'new_customer_last_name': None,
|
||||
'new_customer_phone': None,
|
||||
'new_customer_email': None,
|
||||
}
|
||||
|
||||
pending = batch.pending_customer
|
||||
if pending:
|
||||
context.update({
|
||||
'new_customer_first_name': pending.first_name,
|
||||
'new_customer_last_name': pending.last_name,
|
||||
'new_customer_name': pending.full_name,
|
||||
'new_customer_phone': pending.phone_number,
|
||||
'new_customer_email': pending.email_address,
|
||||
})
|
||||
|
||||
# figure out if customer is "known" from user's perspective.
|
||||
# if we have an ID then it's definitely known, otherwise if we
|
||||
# have a pending customer then it's definitely *not* known,
|
||||
# but if no pending customer yet then we can still "assume" it
|
||||
# is known, by default, until user specifies otherwise.
|
||||
if batch.customer_id:
|
||||
context['customer_is_known'] = True
|
||||
else:
|
||||
context['customer_is_known'] = not pending
|
||||
|
||||
return context
|
||||
|
||||
def set_pending_customer(self, batch, data):
|
||||
"""
|
||||
This will set/update the batch pending customer info.
|
||||
|
||||
This calls
|
||||
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.set_pending_customer()`
|
||||
for the heavy lifting.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
data['created_by'] = self.request.user
|
||||
try:
|
||||
self.batch_handler.set_pending_customer(batch, data)
|
||||
except Exception as error:
|
||||
return {'error': self.app.render_error(error)}
|
||||
|
||||
self.Session.flush()
|
||||
context = self.get_context_customer(batch)
|
||||
return context
|
||||
|
||||
def add_item(self, batch, data):
|
||||
"""
|
||||
This adds a row to the user's current new order batch.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
order_qty = decimal.Decimal(data.get('order_qty') or '0')
|
||||
order_uom = data['order_uom']
|
||||
|
||||
if data.get('product_is_known'):
|
||||
raise NotImplementedError
|
||||
|
||||
else: # unknown product; add pending
|
||||
pending = data['pending_product']
|
||||
|
||||
for field in ('unit_cost', 'unit_price_reg', 'case_size'):
|
||||
if field in pending:
|
||||
try:
|
||||
pending[field] = decimal.Decimal(pending[field])
|
||||
except decimal.InvalidOperation:
|
||||
return {'error': f"Invalid entry for field: {field}"}
|
||||
|
||||
pending['created_by'] = self.request.user
|
||||
row = self.batch_handler.add_pending_product(batch, pending,
|
||||
order_qty, order_uom)
|
||||
|
||||
return {'batch': self.normalize_batch(batch),
|
||||
'row': self.normalize_row(row)}
|
||||
|
||||
def update_item(self, batch, data):
|
||||
"""
|
||||
This updates a row in the user's current new order batch.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
model = self.app.model
|
||||
enum = self.app.enum
|
||||
session = self.Session()
|
||||
|
||||
uuid = data.get('uuid')
|
||||
if not uuid:
|
||||
return {'error': "Must specify a row UUID"}
|
||||
|
||||
row = session.get(model.NewOrderBatchRow, uuid)
|
||||
if not row:
|
||||
return {'error': "Row not found"}
|
||||
|
||||
if row.batch is not batch:
|
||||
return {'error': "Row is for wrong batch"}
|
||||
|
||||
order_qty = decimal.Decimal(data.get('order_qty') or '0')
|
||||
order_uom = data['order_uom']
|
||||
|
||||
if data.get('product_is_known'):
|
||||
raise NotImplementedError
|
||||
|
||||
else: # pending product
|
||||
|
||||
# set these first, since row will be refreshed below
|
||||
row.order_qty = order_qty
|
||||
row.order_uom = order_uom
|
||||
|
||||
# nb. this will refresh the row
|
||||
self.batch_handler.set_pending_product(row, data['pending_product'])
|
||||
|
||||
return {'batch': self.normalize_batch(batch),
|
||||
'row': self.normalize_row(row)}
|
||||
|
||||
def delete_item(self, batch, data):
|
||||
"""
|
||||
This deletes a row from the user's current new order batch.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
model = self.app.model
|
||||
session = self.app.get_session(batch)
|
||||
|
||||
uuid = data.get('uuid')
|
||||
if not uuid:
|
||||
return {'error': "Must specify a row UUID"}
|
||||
|
||||
row = session.get(model.NewOrderBatchRow, uuid)
|
||||
if not row:
|
||||
return {'error': "Row not found"}
|
||||
|
||||
if row.batch is not batch:
|
||||
return {'error': "Row is for wrong batch"}
|
||||
|
||||
self.batch_handler.do_remove_row(row)
|
||||
session.flush()
|
||||
return {'batch': self.normalize_batch(batch)}
|
||||
|
||||
def submit_new_order(self, batch, data):
|
||||
"""
|
||||
This submits the user's current new order batch, hence
|
||||
executing the batch and creating the true order.
|
||||
|
||||
This is a "batch action" method which may be called from
|
||||
:meth:`create()`.
|
||||
"""
|
||||
user = self.request.user
|
||||
reason = self.batch_handler.why_not_execute(batch, user=user)
|
||||
if reason:
|
||||
return {'error': reason}
|
||||
|
||||
try:
|
||||
order = self.batch_handler.do_execute(batch, user)
|
||||
except Exception as error:
|
||||
log.warning("failed to execute new order batch: %s", batch,
|
||||
exc_info=True)
|
||||
return {'error': self.app.render_error(error)}
|
||||
|
||||
return {
|
||||
'next_url': self.get_action_url('view', order),
|
||||
}
|
||||
|
||||
def normalize_batch(self, batch):
|
||||
""" """
|
||||
return {
|
||||
'uuid': batch.uuid.hex,
|
||||
'total_price': str(batch.total_price or 0),
|
||||
'total_price_display': self.app.render_currency(batch.total_price),
|
||||
'status_code': batch.status_code,
|
||||
'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):
|
||||
""" """
|
||||
enum = self.app.enum
|
||||
|
||||
data = {
|
||||
'uuid': row.uuid.hex,
|
||||
'sequence': row.sequence,
|
||||
'product_scancode': row.product_scancode,
|
||||
'product_brand': row.product_brand,
|
||||
'product_description': row.product_description,
|
||||
'product_size': row.product_size,
|
||||
'product_weighed': row.product_weighed,
|
||||
'department_display': row.department_name,
|
||||
'special_order': row.special_order,
|
||||
'case_size': self.app.render_quantity(row.case_size),
|
||||
'order_qty': self.app.render_quantity(row.order_qty),
|
||||
'order_uom': row.order_uom,
|
||||
'order_uom_choices': self.get_default_uom_choices(),
|
||||
'unit_price_quoted': float(row.unit_price_quoted) if row.unit_price_quoted is not None else None,
|
||||
'unit_price_quoted_display': self.app.render_currency(row.unit_price_quoted),
|
||||
'case_price_quoted': float(row.case_price_quoted) if row.case_price_quoted is not None else None,
|
||||
'case_price_quoted_display': self.app.render_currency(row.case_price_quoted),
|
||||
'total_price': float(row.total_price) if row.total_price is not None else None,
|
||||
'total_price_display': self.app.render_currency(row.total_price),
|
||||
'status_code': row.status_code,
|
||||
'status_text': row.status_text,
|
||||
}
|
||||
|
||||
if row.unit_price_reg:
|
||||
data['unit_price_reg'] = float(row.unit_price_reg)
|
||||
data['unit_price_reg_display'] = self.app.render_currency(row.unit_price_reg)
|
||||
|
||||
if row.unit_price_sale:
|
||||
data['unit_price_sale'] = float(row.unit_price_sale)
|
||||
data['unit_price_sale_display'] = self.app.render_currency(row.unit_price_sale)
|
||||
if row.sale_ends:
|
||||
sale_ends = row.sale_ends
|
||||
data['sale_ends'] = str(row.sale_ends)
|
||||
data['sale_ends_display'] = self.app.render_date(row.sale_ends)
|
||||
|
||||
# if row.unit_price_sale and row.unit_price_quoted == row.unit_price_sale:
|
||||
# data['pricing_reflects_sale'] = True
|
||||
|
||||
# TODO
|
||||
if row.pending_product:
|
||||
data['product_full_description'] = row.pending_product.full_description
|
||||
# else:
|
||||
# data['product_full_description'] = row.product_description
|
||||
|
||||
# if row.pending_product:
|
||||
# data['vendor_display'] = row.pending_product.vendor_name
|
||||
|
||||
if row.pending_product:
|
||||
pending = row.pending_product
|
||||
# data['vendor_display'] = pending.vendor_name
|
||||
data['pending_product'] = {
|
||||
'uuid': pending.uuid.hex,
|
||||
'scancode': pending.scancode,
|
||||
'brand_name': pending.brand_name,
|
||||
'description': pending.description,
|
||||
'size': pending.size,
|
||||
'department_id': pending.department_id,
|
||||
'department_name': pending.department_name,
|
||||
'unit_price_reg': float(pending.unit_price_reg) if pending.unit_price_reg is not None else None,
|
||||
'vendor_name': pending.vendor_name,
|
||||
'vendor_item_code': pending.vendor_item_code,
|
||||
'unit_cost': float(pending.unit_cost) if pending.unit_cost is not None else None,
|
||||
'case_size': float(pending.case_size) if pending.case_size is not None else None,
|
||||
'notes': pending.notes,
|
||||
'special_order': pending.special_order,
|
||||
}
|
||||
|
||||
# TODO: remove this
|
||||
data['product_key'] = row.product_scancode
|
||||
|
||||
# display text for order qty/uom
|
||||
if row.order_uom == enum.ORDER_UOM_CASE:
|
||||
if row.case_size is None:
|
||||
case_qty = unit_qty = '??'
|
||||
else:
|
||||
case_qty = data['case_size']
|
||||
unit_qty = self.app.render_quantity(row.order_qty * row.case_size)
|
||||
CS = enum.ORDER_UOM[enum.ORDER_UOM_CASE]
|
||||
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
|
||||
data['order_qty_display'] = (f"{data['order_qty']} {CS} "
|
||||
f"(× {case_qty} = {unit_qty} {EA})")
|
||||
else:
|
||||
unit_qty = self.app.render_quantity(row.order_qty)
|
||||
EA = enum.ORDER_UOM[enum.ORDER_UOM_UNIT]
|
||||
data['order_qty_display'] = f"{unit_qty} {EA}"
|
||||
|
||||
return data
|
||||
|
||||
def get_instance_title(self, order):
|
||||
""" """
|
||||
return f"#{order.order_id} for {order.customer_name}"
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
|
||||
# pending_customer
|
||||
f.set_node('pending_customer', PendingCustomerRef(self.request))
|
||||
|
||||
# total_price
|
||||
f.set_node('total_price', WuttaMoney(self.request))
|
||||
|
||||
# created_by
|
||||
f.set_node('created_by', UserRef(self.request))
|
||||
f.set_readonly('created_by')
|
||||
|
||||
def get_xref_buttons(self, order):
|
||||
""" """
|
||||
buttons = super().get_xref_buttons(order)
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
|
||||
if self.request.has_perm('neworder_batches.view'):
|
||||
batch = session.query(model.NewOrderBatch)\
|
||||
.filter(model.NewOrderBatch.id == order.order_id)\
|
||||
.first()
|
||||
if batch:
|
||||
url = self.request.route_url('neworder_batches.view', uuid=batch.uuid)
|
||||
buttons.append(
|
||||
self.make_button("View the Batch", primary=True, icon_left='eye', url=url))
|
||||
|
||||
return buttons
|
||||
|
||||
def get_row_grid_data(self, order):
|
||||
""" """
|
||||
model = self.app.model
|
||||
session = self.Session()
|
||||
return session.query(model.OrderItem)\
|
||||
.filter(model.OrderItem.order == order)
|
||||
|
||||
def configure_row_grid(self, g):
|
||||
""" """
|
||||
super().configure_row_grid(g)
|
||||
enum = self.app.enum
|
||||
|
||||
# sequence
|
||||
g.set_label('sequence', "Seq.", column_only=True)
|
||||
g.set_link('sequence')
|
||||
|
||||
# product_scancode
|
||||
g.set_link('product_scancode')
|
||||
|
||||
# product_brand
|
||||
g.set_link('product_brand')
|
||||
|
||||
# product_description
|
||||
g.set_link('product_description')
|
||||
|
||||
# product_size
|
||||
g.set_link('product_size')
|
||||
|
||||
# TODO
|
||||
# order_uom
|
||||
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', g.render_currency)
|
||||
|
||||
# status_code
|
||||
g.set_renderer('status_code', self.render_status_code)
|
||||
|
||||
def render_status_code(self, item, key, value):
|
||||
""" """
|
||||
enum = self.app.enum
|
||||
return enum.ORDER_ITEM_STATUS[value]
|
||||
|
||||
def get_row_action_url_view(self, item, i):
|
||||
""" """
|
||||
return self.request.route_url('order_items.view', uuid=item.uuid)
|
||||
|
||||
|
||||
class OrderItemView(MasterView):
|
||||
"""
|
||||
Master view for :class:`~sideshow.db.model.orders.OrderItem`;
|
||||
route prefix is ``order_items``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/order-items/``
|
||||
* ``/order-items/XXX``
|
||||
|
||||
Note that this does not expose create, edit or delete. The user
|
||||
must perform various other workflow actions to modify the item.
|
||||
"""
|
||||
model_class = OrderItem
|
||||
model_title = "Order Item"
|
||||
route_prefix = 'order_items'
|
||||
url_prefix = '/order-items'
|
||||
creatable = False
|
||||
editable = False
|
||||
deletable = False
|
||||
|
||||
labels = {
|
||||
'order_id': "Order ID",
|
||||
'product_id': "Product ID",
|
||||
'product_scancode': "Scancode",
|
||||
'product_brand': "Brand",
|
||||
'product_description': "Description",
|
||||
'product_size': "Size",
|
||||
'department_name': "Department",
|
||||
'order_uom': "Order UOM",
|
||||
'status_code': "Status",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'order_id',
|
||||
'customer_name',
|
||||
# 'sequence',
|
||||
'product_scancode',
|
||||
'product_brand',
|
||||
'product_description',
|
||||
'product_size',
|
||||
'department_name',
|
||||
'special_order',
|
||||
'order_qty',
|
||||
'order_uom',
|
||||
'total_price',
|
||||
'status_code',
|
||||
]
|
||||
|
||||
sort_defaults = ('order_id', 'desc')
|
||||
|
||||
form_fields = [
|
||||
'order',
|
||||
# 'customer_name',
|
||||
'sequence',
|
||||
'product_id',
|
||||
'pending_product',
|
||||
'product_scancode',
|
||||
'product_brand',
|
||||
'product_description',
|
||||
'product_size',
|
||||
'product_weighed',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'special_order',
|
||||
'order_qty',
|
||||
'order_uom',
|
||||
'case_size',
|
||||
'unit_cost',
|
||||
'unit_price_reg',
|
||||
'unit_price_sale',
|
||||
'sale_ends',
|
||||
'unit_price_quoted',
|
||||
'case_price_quoted',
|
||||
'discount_percent',
|
||||
'total_price',
|
||||
'status_code',
|
||||
'paid_amount',
|
||||
'payment_transaction_number',
|
||||
]
|
||||
|
||||
def get_query(self, session=None):
|
||||
""" """
|
||||
query = super().get_query(session=session)
|
||||
model = self.app.model
|
||||
return query.join(model.Order)
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
model = self.app.model
|
||||
# enum = self.app.enum
|
||||
|
||||
# order_id
|
||||
g.set_sorter('order_id', model.Order.order_id)
|
||||
g.set_renderer('order_id', self.render_order_id)
|
||||
g.set_link('order_id')
|
||||
|
||||
# customer_name
|
||||
g.set_label('customer_name', "Customer", column_only=True)
|
||||
|
||||
# # sequence
|
||||
# g.set_label('sequence', "Seq.", column_only=True)
|
||||
|
||||
# product_scancode
|
||||
g.set_link('product_scancode')
|
||||
|
||||
# product_brand
|
||||
g.set_link('product_brand')
|
||||
|
||||
# product_description
|
||||
g.set_link('product_description')
|
||||
|
||||
# product_size
|
||||
g.set_link('product_size')
|
||||
|
||||
# order_uom
|
||||
# TODO
|
||||
#g.set_renderer('order_uom', self.grid_render_enum, enum=enum.OrderUOM)
|
||||
|
||||
# total_price
|
||||
g.set_renderer('total_price', g.render_currency)
|
||||
|
||||
# status_code
|
||||
g.set_renderer('status_code', self.render_status_code)
|
||||
|
||||
def render_order_id(self, item, key, value):
|
||||
""" """
|
||||
return item.order.order_id
|
||||
|
||||
def render_status_code(self, item, key, value):
|
||||
""" """
|
||||
enum = self.app.enum
|
||||
return enum.ORDER_ITEM_STATUS[value]
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
enum = self.app.enum
|
||||
|
||||
# order
|
||||
f.set_node('order', OrderRef(self.request))
|
||||
|
||||
# pending_product
|
||||
f.set_node('pending_product', PendingProductRef(self.request))
|
||||
|
||||
# order_qty
|
||||
f.set_node('order_qty', WuttaQuantity(self.request))
|
||||
|
||||
# order_uom
|
||||
# TODO
|
||||
#f.set_node('order_uom', WuttaEnum(self.request, enum.OrderUOM))
|
||||
|
||||
# case_size
|
||||
f.set_node('case_size', WuttaQuantity(self.request))
|
||||
|
||||
# unit_price_quoted
|
||||
f.set_node('unit_price_quoted', WuttaMoney(self.request))
|
||||
|
||||
# case_price_quoted
|
||||
f.set_node('case_price_quoted', WuttaMoney(self.request))
|
||||
|
||||
# total_price
|
||||
f.set_node('total_price', WuttaMoney(self.request))
|
||||
|
||||
# paid_amount
|
||||
f.set_node('paid_amount', WuttaMoney(self.request))
|
||||
|
||||
def get_xref_buttons(self, item):
|
||||
""" """
|
||||
buttons = super().get_xref_buttons(item)
|
||||
model = self.app.model
|
||||
|
||||
if self.request.has_perm('orders.view'):
|
||||
url = self.request.route_url('orders.view', uuid=item.order_uuid)
|
||||
buttons.append(
|
||||
self.make_button("View the Order", primary=True, icon_left='eye', url=url))
|
||||
|
||||
return buttons
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
OrderView = kwargs.get('OrderView', base['OrderView'])
|
||||
OrderView.defaults(config)
|
||||
|
||||
OrderItemView = kwargs.get('OrderItemView', base['OrderItemView'])
|
||||
OrderItemView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
257
src/sideshow/web/views/products.py
Normal file
257
src/sideshow/web/views/products.py
Normal file
|
@ -0,0 +1,257 @@
|
|||
# -*- 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/>.
|
||||
#
|
||||
################################################################################
|
||||
"""
|
||||
Views for Products
|
||||
"""
|
||||
|
||||
from wuttaweb.views import MasterView
|
||||
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney
|
||||
|
||||
from sideshow.db.model import PendingProduct
|
||||
|
||||
|
||||
class PendingProductView(MasterView):
|
||||
"""
|
||||
Master view for
|
||||
:class:`~sideshow.db.model.products.PendingProduct`; route
|
||||
prefix is ``pending_products``.
|
||||
|
||||
Notable URLs provided by this class:
|
||||
|
||||
* ``/pending/products/``
|
||||
* ``/pending/products/new``
|
||||
* ``/pending/products/XXX``
|
||||
* ``/pending/products/XXX/edit``
|
||||
* ``/pending/products/XXX/delete``
|
||||
"""
|
||||
model_class = PendingProduct
|
||||
model_title = "Pending Product"
|
||||
route_prefix = 'pending_products'
|
||||
url_prefix = '/pending/products'
|
||||
|
||||
labels = {
|
||||
'product_id': "Product ID",
|
||||
}
|
||||
|
||||
grid_columns = [
|
||||
'scancode',
|
||||
'department_name',
|
||||
'brand_name',
|
||||
'description',
|
||||
'size',
|
||||
'unit_cost',
|
||||
'case_size',
|
||||
'unit_price_reg',
|
||||
'special_order',
|
||||
'status',
|
||||
'created',
|
||||
'created_by',
|
||||
]
|
||||
|
||||
sort_defaults = 'scancode'
|
||||
|
||||
form_fields = [
|
||||
'product_id',
|
||||
'scancode',
|
||||
'department_id',
|
||||
'department_name',
|
||||
'brand_name',
|
||||
'description',
|
||||
'size',
|
||||
'vendor_name',
|
||||
'vendor_item_code',
|
||||
'unit_cost',
|
||||
'case_size',
|
||||
'unit_price_reg',
|
||||
'special_order',
|
||||
'notes',
|
||||
'status',
|
||||
'created',
|
||||
'created_by',
|
||||
'orders',
|
||||
'new_order_batches',
|
||||
]
|
||||
|
||||
def configure_grid(self, g):
|
||||
""" """
|
||||
super().configure_grid(g)
|
||||
enum = self.app.enum
|
||||
|
||||
# unit_cost
|
||||
g.set_renderer('unit_cost', 'currency', scale=4)
|
||||
|
||||
# unit_price_reg
|
||||
g.set_label('unit_price_reg', "Reg. Price", column_only=True)
|
||||
g.set_renderer('unit_price_reg', 'currency')
|
||||
|
||||
# status
|
||||
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
|
||||
|
||||
# links
|
||||
g.set_link('scancode')
|
||||
g.set_link('brand_name')
|
||||
g.set_link('description')
|
||||
g.set_link('size')
|
||||
|
||||
def configure_form(self, f):
|
||||
""" """
|
||||
super().configure_form(f)
|
||||
enum = self.app.enum
|
||||
product = f.model_instance
|
||||
|
||||
# product_id
|
||||
if self.creating:
|
||||
f.remove('product_id')
|
||||
else:
|
||||
f.set_readonly('product_id')
|
||||
|
||||
# unit_price_reg
|
||||
f.set_node('unit_price_reg', WuttaMoney(self.request))
|
||||
|
||||
# notes
|
||||
f.set_widget('notes', 'notes')
|
||||
|
||||
# status
|
||||
if self.creating:
|
||||
f.remove('status')
|
||||
else:
|
||||
f.set_node('status', WuttaEnum(self.request, enum.PendingProductStatus))
|
||||
f.set_readonly('status')
|
||||
|
||||
# created
|
||||
if self.creating:
|
||||
f.remove('created')
|
||||
else:
|
||||
f.set_readonly('created')
|
||||
|
||||
# created_by
|
||||
if self.creating:
|
||||
f.remove('created_by')
|
||||
else:
|
||||
f.set_node('created_by', UserRef(self.request))
|
||||
f.set_readonly('created_by')
|
||||
|
||||
# orders
|
||||
if self.creating or self.editing:
|
||||
f.remove('orders')
|
||||
else:
|
||||
f.set_grid('orders', self.make_orders_grid(product))
|
||||
|
||||
# new_order_batches
|
||||
if self.creating or self.editing:
|
||||
f.remove('new_order_batches')
|
||||
else:
|
||||
f.set_grid('new_order_batches', self.make_new_order_batches_grid(product))
|
||||
|
||||
def make_orders_grid(self, product):
|
||||
"""
|
||||
Make and return the grid for the Orders field.
|
||||
"""
|
||||
model = self.app.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
orders = set([item.order for item in product.order_items])
|
||||
orders = sorted(orders, key=lambda order: order.order_id)
|
||||
|
||||
grid = self.make_grid(key=f'{route_prefix}.view.orders',
|
||||
model_class=model.Order,
|
||||
data=orders,
|
||||
columns=[
|
||||
'order_id',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
],
|
||||
labels={
|
||||
'order_id': "Order ID",
|
||||
},
|
||||
renderers={
|
||||
'total_price': 'currency',
|
||||
})
|
||||
|
||||
if self.request.has_perm('orders.view'):
|
||||
url = lambda order, i: self.request.route_url('orders.view', uuid=order.uuid)
|
||||
grid.add_action('view', icon='eye', url=url)
|
||||
grid.set_link('order_id')
|
||||
|
||||
return grid
|
||||
|
||||
def make_new_order_batches_grid(self, product):
|
||||
"""
|
||||
Make and return the grid for the New Order Batches field.
|
||||
"""
|
||||
model = self.app.model
|
||||
route_prefix = self.get_route_prefix()
|
||||
|
||||
batches = set([row.batch for row in product.new_order_batch_rows])
|
||||
batches = sorted(batches, key=lambda batch: batch.id)
|
||||
|
||||
grid = self.make_grid(key=f'{route_prefix}.view.new_order_batches',
|
||||
model_class=model.NewOrderBatch,
|
||||
data=batches,
|
||||
columns=[
|
||||
'id',
|
||||
'total_price',
|
||||
'created',
|
||||
'created_by',
|
||||
'executed',
|
||||
],
|
||||
labels={
|
||||
'id': "Batch ID",
|
||||
'status_code': "Status",
|
||||
},
|
||||
renderers={
|
||||
'id': 'batch_id',
|
||||
})
|
||||
|
||||
if self.request.has_perm('neworder_batches.view'):
|
||||
url = lambda batch, i: self.request.route_url('neworder_batches.view', uuid=batch.uuid)
|
||||
grid.add_action('view', icon='eye', url=url)
|
||||
grid.set_link('id')
|
||||
|
||||
return grid
|
||||
|
||||
def delete_instance(self, product):
|
||||
""" """
|
||||
|
||||
# avoid deleting if still referenced by new order batch(es)
|
||||
for row in product.new_order_batch_rows:
|
||||
if not row.batch.executed:
|
||||
model_title = self.get_model_title()
|
||||
self.request.session.flash(f"Cannot delete {model_title} still attached "
|
||||
"to New Order Batch(es)", 'warning')
|
||||
raise self.redirect(self.get_action_url('view', product))
|
||||
|
||||
# go ahead and delete per usual
|
||||
super().delete_instance(product)
|
||||
|
||||
|
||||
def defaults(config, **kwargs):
|
||||
base = globals()
|
||||
|
||||
PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
|
||||
PendingProductView.defaults(config)
|
||||
|
||||
|
||||
def includeme(config):
|
||||
defaults(config)
|
Loading…
Add table
Add a link
Reference in a new issue