feat: add basic support to "resolve" a pending product

This commit is contained in:
Lance Edgar 2025-02-21 18:13:57 -06:00
parent 6b4bc3da10
commit 1ee398e8fb
17 changed files with 780 additions and 69 deletions

View file

@ -1056,7 +1056,7 @@ class NewOrderBatchHandler(BatchHandler):
By default, this will call: By default, this will call:
* :meth:`make_local_customer()` * :meth:`make_local_customer()`
* :meth:`make_local_products()` * :meth:`process_pending_products()`
* :meth:`make_new_order()` * :meth:`make_new_order()`
And will return the new And will return the new
@ -1068,7 +1068,7 @@ class NewOrderBatchHandler(BatchHandler):
""" """
rows = self.get_effective_rows(batch) rows = self.get_effective_rows(batch)
self.make_local_customer(batch) self.make_local_customer(batch)
self.make_local_products(batch, rows) self.process_pending_products(batch, rows)
order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs) order = self.make_new_order(batch, rows, user=user, progress=progress, **kwargs)
return order return order
@ -1115,29 +1115,25 @@ class NewOrderBatchHandler(BatchHandler):
session.delete(pending) session.delete(pending)
session.flush() session.flush()
def make_local_products(self, batch, rows): def process_pending_products(self, batch, rows):
""" """
If applicable, this converts all :term:`pending products Process any :term:`pending products <pending product>` which
<pending product>` into :term:`local products <local are present in the batch.
product>`.
This is called automatically from :meth:`execute()`. This is called automatically from :meth:`execute()`.
This logic will happen only if :meth:`use_local_products()` If :term:`local products <local product>` are used, this will
returns true, and the batch has pending instead of local items convert the pending products to local products.
(so far).
For each affected row, it will create a new If :term:`external products <external product>` are used, this
:class:`~sideshow.db.model.products.LocalProduct` record and will update the pending product records' status to indicate
populate it from the row they are ready to be resolved.
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`.
The latter is then deleted.
""" """
if not self.use_local_products(): enum = self.app.enum
return
model = self.app.model model = self.app.model
session = self.app.get_session(batch) session = self.app.get_session(batch)
if self.use_local_products():
inspector = sa.inspect(model.LocalProduct) inspector = sa.inspect(model.LocalProduct)
for row in rows: for row in rows:
@ -1156,6 +1152,12 @@ class NewOrderBatchHandler(BatchHandler):
row.pending_product = None row.pending_product = None
session.delete(pending) session.delete(pending)
else: # external products; pending should be marked 'ready'
for row in rows:
pending = row.pending_product
if pending:
pending.status = enum.PendingProductStatus.READY
session.flush() session.flush()
def make_new_order(self, batch, rows, user=None, progress=None, **kwargs): def make_new_order(self, batch, rows, user=None, progress=None, **kwargs):

View file

@ -0,0 +1,61 @@
"""add ignored status
Revision ID: fd8a2527bd30
Revises: 13af2ffbc0e0
Create Date: 2025-02-20 12:08:27.374172
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import wuttjamaican.db.util
from alembic_postgresql_enum import TableReference
# revision identifiers, used by Alembic.
revision: str = 'fd8a2527bd30'
down_revision: Union[str, None] = '13af2ffbc0e0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# pendingcustomerstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingcustomerstatus',
new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')],
enum_values_to_rename=[],
)
# pendingproductstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingproductstatus',
new_values=['PENDING', 'READY', 'RESOLVED', 'IGNORED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')],
enum_values_to_rename=[],
)
def downgrade() -> None:
# pendingproductstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingproductstatus',
new_values=['PENDING', 'READY', 'RESOLVED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_product_pending', column_name='status')],
enum_values_to_rename=[],
)
# pendingcustomerstatus
op.sync_enum_values(
enum_schema='public',
enum_name='pendingcustomerstatus',
new_values=['PENDING', 'READY', 'RESOLVED'],
affected_columns=[TableReference(table_schema='public', table_name='sideshow_customer_pending', column_name='status')],
enum_values_to_rename=[],
)

View file

@ -185,7 +185,8 @@ class PendingProduct(ProductMixin, model.Base):
uuid = model.uuid_column() uuid = model.uuid_column()
product_id = sa.Column(sa.String(length=20), nullable=True, doc=""" product_id = sa.Column(sa.String(length=20), nullable=True, doc="""
ID of the true product associated with this record, if applicable. ID of the :term:`external product` associated with this record, if
applicable/known.
""") """)
status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc=""" status = sa.Column(sa.Enum(PendingProductStatus), nullable=False, doc="""

View file

@ -91,6 +91,7 @@ class PendingCustomerStatus(Enum):
PENDING = 'pending' PENDING = 'pending'
READY = 'ready' READY = 'ready'
RESOLVED = 'resolved' RESOLVED = 'resolved'
IGNORED = 'ignored'
class PendingProductStatus(Enum): class PendingProductStatus(Enum):
@ -101,6 +102,7 @@ class PendingProductStatus(Enum):
PENDING = 'pending' PENDING = 'pending'
READY = 'ready' READY = 'ready'
RESOLVED = 'resolved' RESOLVED = 'resolved'
IGNORED = 'ignored'
######################################## ########################################

View file

@ -105,6 +105,81 @@ class OrderHandler(GenericHandler):
enum.ORDER_ITEM_STATUS_INACTIVE): enum.ORDER_ITEM_STATUS_INACTIVE):
return 'warning' return 'warning'
def resolve_pending_product(self, pending_product, product_info, user, note=None):
"""
Resolve a :term:`pending product`, to reflect the given
product info.
At a high level this does 2 things:
* update the ``pending_product``
* find and update any related :term:`order item(s) <order item>`
The first step just sets
:attr:`~sideshow.db.model.products.PendingProduct.product_id`
from the provided info, and gives it the "resolved" status.
Note that it does *not* update the pending product record
further, so it will not fully "match" the product info.
The second step will fetch all
:class:`~sideshow.db.model.orders.OrderItem` records which
reference the ``pending_product`` **and** which do not yet
have a ``product_id`` value. For each, it then updates the
order item to contain all data from ``product_info``. And
finally, it adds an event to the item history, indicating who
resolved and when. (If ``note`` is specified, a *second*
event is added for that.)
:param pending_product:
:class:`~sideshow.db.model.products.PendingProduct` to be
resolved.
:param product_info: Dict of product info, as obtained from
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`.
:param user:
:class:`~wuttjamaican:wuttjamaican.db.model.auth.User` who
is performing the action.
:param note: Optional note to be added to event history for
related order item(s).
"""
enum = self.app.enum
model = self.app.model
session = self.app.get_session(pending_product)
if pending_product.status != enum.PendingProductStatus.READY:
raise ValueError("pending product does not have 'ready' status")
info = product_info
pending_product.product_id = info['product_id']
pending_product.status = enum.PendingProductStatus.RESOLVED
items = session.query(model.OrderItem)\
.filter(model.OrderItem.pending_product == pending_product)\
.filter(model.OrderItem.product_id == None)\
.all()
for item in items:
item.product_id = info['product_id']
item.product_scancode = info['scancode']
item.product_brand = info['brand_name']
item.product_description = info['description']
item.product_size = info['size']
item.product_weighed = info['weighed']
item.department_id = info['department_id']
item.department_name = info['department_name']
item.special_order = info['special_order']
item.vendor_name = info['vendor_name']
item.vendor_item_code = info['vendor_item_code']
item.case_size = info['case_size']
item.unit_cost = info['unit_cost']
item.unit_price_reg = info['unit_price_reg']
item.add_event(enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED, user)
if note:
item.add_event(enum.ORDER_ITEM_EVENT_NOTE_ADDED, user, note=note)
def process_placement(self, items, user, vendor_name=None, po_number=None, note=None): def process_placement(self, items, user, vendor_name=None, po_number=None, note=None):
""" """
Process the "placement" step for the given order items. Process the "placement" step for the given order items.

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Sideshow -- Case/Special Order Tracker # Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar # Copyright © 2024-2025 Lance Edgar
# #
# This file is part of Sideshow. # This file is part of Sideshow.
# #
@ -33,4 +33,6 @@ class WebTestCase(base.WebTestCase):
config = super().make_config(**kwargs) config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model') config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum') config.setdefault('wutta.enum_spec', 'sideshow.enum')
config.setdefault(f'{config.appname}.batch.neworder.handler.default_spec',
'sideshow.batch.neworder:NewOrderBatchHandler')
return config return config

View file

@ -0,0 +1,8 @@
## -*- coding: utf-8; -*-
<%inherit file="wuttaweb:templates/base.mako" />
<%namespace file="/sideshow-components.mako" import="make_sideshow_components" />
<%def name="render_vue_templates()">
${make_sideshow_components()}
${parent.render_vue_templates()}
</%def>

View file

@ -207,7 +207,7 @@
<div class="panel-block"> <div class="panel-block">
<div style="width: 100%;"> <div style="width: 100%;">
<b-field horizontal label="Product ID"> <b-field horizontal label="Product ID">
<span>${item.product_id}</span> <span>${item.product_id or ''}</span>
</b-field> </b-field>
% if not item.product_id and item.local_product: % if not item.product_id and item.local_product:
<b-field horizontal label="Local Product"> <b-field horizontal label="Local Product">
@ -220,34 +220,34 @@
</b-field> </b-field>
% endif % endif
<b-field horizontal label="Scancode"> <b-field horizontal label="Scancode">
<span>${item.product_scancode}</span> <span>${item.product_scancode or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Brand"> <b-field horizontal label="Brand">
<span>${item.product_brand}</span> <span>${item.product_brand or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Description"> <b-field horizontal label="Description">
<span>${item.product_description}</span> <span>${item.product_description or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Size"> <b-field horizontal label="Size">
<span>${item.product_size}</span> <span>${item.product_size or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Sold by Weight"> <b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span> <span>${app.render_boolean(item.product_weighed)}</span>
</b-field> </b-field>
<b-field horizontal label="Department ID"> <b-field horizontal label="Department ID">
<span>${item.department_id}</span> <span>${item.department_id or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Department Name"> <b-field horizontal label="Department Name">
<span>${item.department_name}</span> <span>${item.department_name or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Special Order"> <b-field horizontal label="Special Order">
<span>${app.render_boolean(item.special_order)}</span> <span>${app.render_boolean(item.special_order)}</span>
</b-field> </b-field>
<b-field horizontal label="Vendor Name"> <b-field horizontal label="Vendor Name">
<span>${item.vendor_name}</span> <span>${item.vendor_name or ''}</span>
</b-field> </b-field>
<b-field horizontal label="Vendor Item Code"> <b-field horizontal label="Vendor Item Code">
<span>${item.vendor_item_code}</span> <span>${item.vendor_item_code or ''}</span>
</b-field> </b-field>
</div> </div>
</div> </div>

View file

@ -523,11 +523,9 @@
<div style="flex-grow: 1;"> <div style="flex-grow: 1;">
<b-field label="Product"> <b-field label="Product">
<wutta-autocomplete ref="productAutocomplete" <sideshow-product-lookup v-model="productID"
v-model="productID" ref="productLookup"
:display="productDisplay" :display="productDisplay"
service-url="${url(f'{route_prefix}.product_autocomplete')}"
placeholder="Enter brand, description etc."
@input="productChanged" /> @input="productChanged" />
</b-field> </b-field>
@ -1696,8 +1694,7 @@
% endif % endif
this.editItemShowDialog = true this.editItemShowDialog = true
this.$nextTick(() => { this.$nextTick(() => {
// this.$refs.productLookup.focus() this.$refs.productLookup.focus()
this.$refs.productAutocomplete.focus()
}) })
}, },

View file

@ -0,0 +1,166 @@
## -*- coding: utf-8; -*-
<%inherit file="/master/view.mako" />
<%def name="tool_panels()">
${parent.tool_panels()}
<wutta-tool-panel heading="Status" style="white-space: nowrap;">
<b-field horizontal label="Current Status">
<span>${instance.status.value}</span>
</b-field>
% if instance.status.name == 'READY' and master.has_perm('resolve') and not use_local_products:
<b-button type="is-primary"
icon-pack="fas"
icon-left="object-ungroup"
@click="resolveInit()">
Resolve Product
</b-button>
<b-modal :active.sync="resolveShowDialog">
<div class="card">
<div class="card-content">
${h.form(master.get_action_url('resolve', instance), **{'@submit': 'resolveSubmitting = true'})}
${h.csrf_token(request)}
<div style="display: flex; gap: 1rem;">
<div style="flex-grow: 1;">
<p class="block has-text-weight-bold">
Please identify the corresponding External Product.
</p>
<p class="block">
All related orders etc. will be updated accordingly.
</p>
<b-field grouped>
<b-field label="Scancode">
<span>${instance.scancode or ''}</span>
</b-field>
<b-field label="Brand">
<span>${instance.brand_name or ''}</span>
</b-field>
<b-field label="Description">
<span>${instance.description or ''}</span>
</b-field>
<b-field label="Size">
<span>${instance.size or ''}</span>
</b-field>
</b-field>
<b-field grouped>
<b-field label="Vendor Name">
<span>${instance.vendor_name or ''}</span>
</b-field>
<b-field label="Vendor Item Code">
<span>${instance.vendor_item_code or ''}</span>
</b-field>
</b-field>
</div>
<div style="flex-grow: 1;">
<b-field label="External Product">
<div>
<sideshow-product-lookup v-model="resolveProductID"
ref="productLookup" />
${h.hidden('product_id', **{':value': 'resolveProductID'})}
</div>
</b-field>
</div>
</div>
<footer>
<div class="buttons">
<b-button @click="resolveShowDialog = false">
Cancel
</b-button>
<b-button type="is-primary"
native-type="submit"
icon-pack="fas"
icon-left="object-ungroup"
:disabled="resolveSubmitting">
{{ resolveSubmitting ? "Working, please wait..." : "Resolve" }}
</b-button>
</div>
</footer>
${h.end_form()}
</div>
</div>
</b-modal>
% endif
% if instance.status.name == 'READY' and master.has_perm('ignore') and not use_local_products:
<b-button type="is-warning"
icon-pack="fas"
icon-left="ban"
@click="ignoreShowDialog = true">
Ignore Product
</b-button>
<b-modal has-modal-card
:active.sync="ignoreShowDialog">
<div class="modal-card">
${h.form(master.get_action_url('ignore', instance), **{'@submit': 'ignoreSubmitting = true'})}
${h.csrf_token(request)}
<header class="modal-card-head">
<p class="modal-card-title">Ignore Product</p>
</header>
<section class="modal-card-body">
<p class="block has-text-weight-bold">
Really ignore this product?
</p>
<p class="block">
This will change the product status to "ignored"<br />
and you will no longer be prompted to resolve it.
</p>
</section>
<footer class="modal-card-foot">
<b-button @click="ignoreShowDialog = false">
Cancel
</b-button>
<b-button type="is-warning"
native-type="submit"
icon-pack="fas"
icon-left="ban"
:disabled="ignoreSubmitting">
{{ ignoreSubmitting ? "Working, please wait..." : "Ignore" }}
</b-button>
</footer>
${h.end_form()}
</div>
</b-modal>
% endif
</wutta-tool-panel>
</%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
<script>
% if instance.status.name == 'READY' and master.has_perm('resolve') and not use_local_products:
ThisPageData.resolveShowDialog = false
ThisPageData.resolveProductID = null
ThisPageData.resolveSubmitting = false
ThisPage.methods.resolveInit = function() {
this.resolveShowDialog = true
this.$nextTick(() => {
this.$refs.productLookup.focus()
})
}
% endif
% if instance.status.name == 'READY' and master.has_perm('ignore') and not use_local_products:
ThisPageData.ignoreShowDialog = false
ThisPageData.ignoreSubmitting = false
% endif
</script>
</%def>

View file

@ -0,0 +1,53 @@
<%def name="make_sideshow_components()">
${self.make_sideshow_product_lookup_component()}
</%def>
<%def name="make_sideshow_product_lookup_component()">
<script type="text/x-template" id="sideshow-product-lookup-template">
<wutta-autocomplete ref="autocomplete"
v-model="productID"
:display="display"
placeholder="Enter brand, description etc."
:service-url="serviceUrl"
@input="val => $emit('input', val)" />
</script>
<script>
const SideshowProductLookup = {
template: '#sideshow-product-lookup-template',
props: {
// this should contain the productID, or null
// caller specifies this as `v-model`
// component emits @input event when value changes
value: String,
// caller must specify initial display string, if the
// (v-model) value is not empty when component loads
display: String,
// the url from which search results are obtained
serviceUrl: {
type: String,
default: '${url('orders.product_autocomplete')}',
},
},
data() {
return {
productID: this.value,
}
},
methods: {
focus() {
this.$refs.autocomplete.focus()
},
},
}
Vue.component('sideshow-product-lookup', SideshowProductLookup)
</script>
</%def>

View file

@ -793,6 +793,8 @@ class OrderView(MasterView):
'department_id': row.department_id, 'department_id': row.department_id,
'department_name': row.department_name, 'department_name': row.department_name,
'special_order': row.special_order, 'special_order': row.special_order,
'vendor_name': row.vendor_name,
'vendor_item_code': row.vendor_item_code,
'case_size': float(row.case_size) if row.case_size is not None else None, 'case_size': float(row.case_size) if row.case_size is not None else None,
'order_qty': float(row.order_qty), 'order_qty': float(row.order_qty),
'order_uom': row.order_uom, 'order_uom': row.order_uom,

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Sideshow -- Case/Special Order Tracker # Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar # Copyright © 2024-2025 Lance Edgar
# #
# This file is part of Sideshow. # This file is part of Sideshow.
# #
@ -27,6 +27,7 @@ Views for Products
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity
from sideshow.enum import PendingProductStatus
from sideshow.db.model import LocalProduct, PendingProduct from sideshow.db.model import LocalProduct, PendingProduct
@ -254,7 +255,12 @@ class PendingProductView(MasterView):
'created_by', 'created_by',
] ]
sort_defaults = 'scancode' sort_defaults = ('created', 'desc')
filter_defaults = {
'status': {'active': True,
'value': PendingProductStatus.READY.name},
}
form_fields = [ form_fields = [
'product_id', 'product_id',
@ -271,7 +277,6 @@ class PendingProductView(MasterView):
'unit_price_reg', 'unit_price_reg',
'special_order', 'special_order',
'notes', 'notes',
'status',
'created', 'created',
'created_by', 'created_by',
'orders', 'orders',
@ -291,7 +296,7 @@ class PendingProductView(MasterView):
g.set_renderer('unit_price_reg', 'currency') g.set_renderer('unit_price_reg', 'currency')
# status # status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus) g.set_enum('status', enum.PendingProductStatus)
# links # links
g.set_link('scancode') g.set_link('scancode')
@ -299,6 +304,12 @@ class PendingProductView(MasterView):
g.set_link('description') g.set_link('description')
g.set_link('size') g.set_link('size')
def grid_row_class(self, product, data, i):
""" """
enum = self.app.enum
if product.status == enum.PendingProductStatus.IGNORED:
return 'has-background-warning'
def configure_form(self, f): def configure_form(self, f):
""" """ """ """
super().configure_form(f) super().configure_form(f)
@ -317,13 +328,6 @@ class PendingProductView(MasterView):
# notes # notes
f.set_widget('notes', '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 # created
if self.creating: if self.creating:
f.remove('created') f.remove('created')
@ -417,6 +421,19 @@ class PendingProductView(MasterView):
return grid return grid
def get_template_context(self, context):
""" """
enum = self.app.enum
if self.viewing:
product = context['instance']
if (product.status == enum.PendingProductStatus.READY
and self.has_any_perm('resolve', 'ignore')):
handler = self.app.get_batch_handler('neworder')
context['use_local_products'] = handler.use_local_products()
return context
def delete_instance(self, product): def delete_instance(self, product):
""" """ """ """
@ -431,6 +448,98 @@ class PendingProductView(MasterView):
# go ahead and delete per usual # go ahead and delete per usual
super().delete_instance(product) super().delete_instance(product)
def resolve(self):
"""
View to "resolve" a :term:`pending product` with the real
:term:`external product`.
This view requires POST, with ``product_id`` referencing the
desired external product.
It will call
:meth:`~sideshow.batch.neworder.NewOrderBatchHandler.get_product_info_external()`
to fetch product info, then with that it calls
:meth:`~sideshow.orders.OrderHandler.resolve_pending_product()`
to update related :term:`order items <order item>` etc.
See also :meth:`ignore()`.
"""
enum = self.app.enum
session = self.Session()
product = self.get_instance()
if product.status != enum.PendingProductStatus.READY:
self.request.session.flash("pending product does not have 'ready' status!", 'error')
return self.redirect(self.get_action_url('view', product))
product_id = self.request.POST.get('product_id')
if not product_id:
self.request.session.flash("must specify valid product_id", 'error')
return self.redirect(self.get_action_url('view', product))
batch_handler = self.app.get_batch_handler('neworder')
order_handler = self.app.get_order_handler()
info = batch_handler.get_product_info_external(session, product_id)
order_handler.resolve_pending_product(product, info, self.request.user)
return self.redirect(self.get_action_url('view', product))
def ignore(self):
"""
View to "ignore" a :term:`pending product` so the user is no
longer prompted to resolve it.
This view requires POST; it merely sets the product status to
"ignored".
See also :meth:`resolve()`.
"""
enum = self.app.enum
product = self.get_instance()
if product.status != enum.PendingProductStatus.READY:
self.request.session.flash("pending product does not have 'ready' status!", 'error')
return self.redirect(self.get_action_url('view', product))
product.status = enum.PendingProductStatus.IGNORED
return self.redirect(self.get_action_url('view', product))
@classmethod
def defaults(cls, config):
""" """
cls._defaults(config)
cls._pending_product_defaults(config)
@classmethod
def _pending_product_defaults(cls, config):
route_prefix = cls.get_route_prefix()
permission_prefix = cls.get_permission_prefix()
instance_url_prefix = cls.get_instance_url_prefix()
model_title = cls.get_model_title()
# resolve
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.resolve',
f"Resolve {model_title}")
config.add_route(f'{route_prefix}.resolve',
f'{instance_url_prefix}/resolve',
request_method='POST')
config.add_view(cls, attr='resolve',
route_name=f'{route_prefix}.resolve',
permission=f'{permission_prefix}.resolve')
# ignore
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.ignore',
f"Ignore {model_title}")
config.add_route(f'{route_prefix}.ignore',
f'{instance_url_prefix}/ignore',
request_method='POST')
config.add_view(cls, attr='ignore',
route_name=f'{route_prefix}.ignore',
permission=f'{permission_prefix}.ignore')
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()

View file

@ -1219,7 +1219,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.full_name, "Chuck Norris") self.assertEqual(local.full_name, "Chuck Norris")
self.assertEqual(local.phone_number, '555-1234') self.assertEqual(local.phone_number, '555-1234')
def test_make_local_products(self): def test_process_pending_products(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
handler = self.make_handler() handler = self.make_handler()
@ -1253,7 +1253,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(self.session.query(model.LocalProduct).count(), 1) self.assertEqual(self.session.query(model.LocalProduct).count(), 1)
self.assertIsNotNone(row2.pending_product) self.assertIsNotNone(row2.pending_product)
self.assertIsNone(row2.local_product) self.assertIsNone(row2.local_product)
handler.make_local_products(batch, batch.rows) handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product) self.assertIsNone(row2.pending_product)
@ -1266,7 +1266,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99')) self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99'))
# trying again does nothing # trying again does nothing
handler.make_local_products(batch, batch.rows) handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product) self.assertIsNone(row2.pending_product)
@ -1291,24 +1291,26 @@ class TestNewOrderBatchHandler(DataTestCase):
), 1, enum.ORDER_UOM_UNIT) ), 1, enum.ORDER_UOM_UNIT)
self.session.flush() self.session.flush()
# should do nothing if local products disabled # should update status if using external products
with patch.object(handler, 'use_local_products', return_value=False): with patch.object(handler, 'use_local_products', return_value=False):
self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product) self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.PENDING)
self.assertIsNone(row.local_product) self.assertIsNone(row.local_product)
handler.make_local_products(batch, batch.rows) handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product) self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.READY)
self.assertIsNone(row.local_product) self.assertIsNone(row.local_product)
# but things happen by default, since local products enabled # but if using local products (the default), pending is converted to local
self.assertEqual(self.session.query(model.PendingProduct).count(), 1) self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2) self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product) self.assertIsNotNone(row.pending_product)
self.assertIsNone(row.local_product) self.assertIsNone(row.local_product)
handler.make_local_products(batch, batch.rows) handler.process_pending_products(batch, batch.rows)
self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
self.assertEqual(self.session.query(model.LocalProduct).count(), 3) self.assertEqual(self.session.query(model.LocalProduct).count(), 3)
self.assertIsNone(row.pending_product) self.assertIsNone(row.pending_product)

View file

@ -70,6 +70,75 @@ class TestOrderHandler(DataTestCase):
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning') self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_EXPIRED), 'warning')
self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning') self.assertEqual(handler.item_status_to_variant(enum.ORDER_ITEM_STATUS_INACTIVE), 'warning')
def test_resolve_pending_product(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
# sample data
user = model.User(username='barney')
self.session.add(user)
pending = model.PendingProduct(description='vinegar', unit_price_reg=5.99,
status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(pending)
order = model.Order(order_id=100, customer_name="Fred Flintstone", created_by=user)
item = model.OrderItem(pending_product=pending,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
self.session.flush()
info = {
'product_id': '07430500132',
'scancode': '07430500132',
'brand_name': "Bragg's",
'description': "Apple Cider Vinegar",
'size': "32oz",
'weighed': False,
'department_id': None,
'department_name': None,
'special_order': False,
'vendor_name': None,
'vendor_item_code': None,
'case_size': 12,
'unit_cost': 2.99,
'unit_price_reg': 5.99,
}
# first try fails b/c pending status
self.assertEqual(len(item.events), 0)
self.assertRaises(ValueError, handler.resolve_pending_product, pending, info, user)
# resolves okay if ready status
pending.status = enum.PendingProductStatus.READY
handler.resolve_pending_product(pending, info, user)
self.assertEqual(len(item.events), 1)
self.assertEqual(item.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED)
self.assertIsNone(item.events[0].note)
# more sample data
pending2 = model.PendingProduct(description='vinegar', unit_price_reg=5.99,
status=enum.PendingProductStatus.READY,
created_by=user)
self.session.add(pending2)
order2 = model.Order(order_id=101, customer_name="Wilma Flintstone", created_by=user)
item2 = model.OrderItem(pending_product=pending2,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order2.items.append(item2)
self.session.add(order2)
self.session.flush()
# resolve with extra note
handler.resolve_pending_product(pending2, info, user, note='hello world')
self.assertEqual(len(item2.events), 2)
self.assertEqual(item2.events[0].type_code, enum.ORDER_ITEM_EVENT_PRODUCT_RESOLVED)
self.assertIsNone(item2.events[0].note)
self.assertEqual(item2.events[1].type_code, enum.ORDER_ITEM_EVENT_NOTE_ADDED)
self.assertEqual(item2.events[1].note, "hello world")
def test_process_placement(self): def test_process_placement(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum

View file

@ -1144,6 +1144,8 @@ class TestOrderView(WebTestCase):
row.department_id = 1 row.department_id = 1
row.department_name = "Bricks & Mortar" row.department_name = "Bricks & Mortar"
row.special_order = False row.special_order = False
row.vendor_name = 'Acme Distributors'
row.vendor_item_code = '1234'
row.case_size = None row.case_size = None
row.unit_cost = decimal.Decimal('599.99') row.unit_cost = decimal.Decimal('599.99')
row.unit_price_reg = decimal.Decimal('999.99') row.unit_price_reg = decimal.Decimal('999.99')
@ -1159,7 +1161,8 @@ class TestOrderView(WebTestCase):
self.assertEqual(data['product_scancode'], '012345') self.assertEqual(data['product_scancode'], '012345')
self.assertEqual(data['product_full_description'], 'Acme Bricks 1 ton') self.assertEqual(data['product_full_description'], 'Acme Bricks 1 ton')
self.assertIsNone(data['case_size']) self.assertIsNone(data['case_size'])
self.assertNotIn('vendor_name', data) # TODO self.assertEqual(data['vendor_name'], 'Acme Distributors')
self.assertEqual(data['vendor_item_code'], '1234')
self.assertEqual(data['order_qty'], 1) self.assertEqual(data['order_qty'], 1)
self.assertEqual(data['order_uom'], 'EA') self.assertEqual(data['order_uom'], 'EA')
self.assertEqual(data['order_qty_display'], '1 Units') self.assertEqual(data['order_qty_display'], '1 Units')

View file

@ -132,6 +132,19 @@ class TestPendingProductView(WebTestCase):
self.assertIn('brand_name', grid.linked_columns) self.assertIn('brand_name', grid.linked_columns)
self.assertIn('description', grid.linked_columns) self.assertIn('description', grid.linked_columns)
def test_grid_row_class(self):
enum = self.app.enum
model = self.app.model
view = self.make_view()
product = model.PendingProduct()
# null by default
self.assertIsNone(view.grid_row_class(product, {}, 1))
# warning for ignored
product.status = enum.PendingProductStatus.IGNORED
self.assertEqual(view.grid_row_class(product, {}, 1), 'has-background-warning')
def test_configure_form(self): def test_configure_form(self):
model = self.app.model model = self.app.model
enum = self.app.enum enum = self.app.enum
@ -141,7 +154,6 @@ class TestPendingProductView(WebTestCase):
with patch.object(view, 'creating', new=True): with patch.object(view, 'creating', new=True):
form = view.make_form(model_class=model.PendingProduct) form = view.make_form(model_class=model.PendingProduct)
view.configure_form(form) view.configure_form(form)
self.assertNotIn('status', form)
self.assertNotIn('created', form) self.assertNotIn('created', form)
self.assertNotIn('created_by', form) self.assertNotIn('created_by', form)
@ -216,6 +228,39 @@ class TestPendingProductView(WebTestCase):
self.assertEqual(len(grid.actions), 1) self.assertEqual(len(grid.actions), 1)
self.assertEqual(grid.actions[0].key, 'view') self.assertEqual(grid.actions[0].key, 'view')
def test_get_template_context(self):
enum = self.app.enum
model = self.app.model
view = self.make_view()
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING)
orig_context = {'instance': product}
# local setting omitted by default
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# still omitted even though 'viewing'
with patch.object(view, 'viewing', new=True):
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# still omitted even though correct status
product.status = enum.PendingProductStatus.READY
context = view.get_template_context(orig_context)
self.assertNotIn('use_local_products', context)
# no longer omitted if user has perm
with patch.object(self.request, 'is_root', new=True):
context = view.get_template_context(orig_context)
self.assertIn('use_local_products', context)
# nb. true by default
self.assertTrue(context['use_local_products'])
# accurately reflects config
self.config.setdefault('sideshow.orders.use_local_products', 'false')
context = view.get_template_context(orig_context)
self.assertFalse(context['use_local_products'])
def test_delete_instance(self): def test_delete_instance(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}') self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model model = self.app.model
@ -259,3 +304,117 @@ class TestPendingProductView(WebTestCase):
view.delete_instance(product) view.delete_instance(product)
self.session.flush() self.session.flush()
self.assertEqual(self.session.query(model.PendingProduct).count(), 0) self.assertEqual(self.session.query(model.PendingProduct).count(), 0)
def test_resolve(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
# sample data
user = model.User(username='barney')
self.session.add(user)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
self.session.flush()
info = {
'product_id': '07430500132',
'scancode': '07430500132',
'brand_name': "Bragg's",
'description': "Apple Cider Vinegar",
'size': "32oz",
'weighed': False,
'department_id': None,
'department_name': None,
'special_order': False,
'vendor_name': None,
'vendor_item_code': None,
'case_size': 12,
'unit_cost': 2.99,
'unit_price_reg': 5.99,
}
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}):
# flash error if wrong status
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["pending product does not have 'ready' status!"])
# flash error if product_id not specified
product.status = enum.PendingProductStatus.READY
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["must specify valid product_id"])
# more sample data
order = model.Order(order_id=100, created_by=user,
customer_name="Fred Flintstone")
item = model.OrderItem(pending_product=product,
order_qty=1, order_uom=enum.ORDER_UOM_UNIT,
status_code=enum.ORDER_ITEM_STATUS_READY)
order.items.append(item)
self.session.add(order)
# product + order items updated
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.READY)
self.assertIsNone(item.product_id)
batch_handler = NewOrderBatchHandler(self.config)
with patch.object(batch_handler, 'get_product_info_external',
return_value=info):
with patch.object(self.app, 'get_batch_handler',
return_value=batch_handler):
with patch.object(self.request, 'POST',
new={'product_id': '07430500132'}):
with patch.object(batch_handler, 'get_product_info_external',
return_value=info):
result = view.resolve()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertEqual(product.product_id, '07430500132')
self.assertEqual(product.status, enum.PendingProductStatus.RESOLVED)
self.assertEqual(item.product_id, '07430500132')
def test_ignore(self):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
enum = self.app.enum
view = self.make_view()
# sample data
user = model.User(username='barney')
self.session.add(user)
product = model.PendingProduct(status=enum.PendingProductStatus.PENDING,
created_by=user)
self.session.add(product)
self.session.flush()
with patch.object(view, 'Session', return_value=self.session):
with patch.object(self.request, 'user', new=user):
with patch.object(self.request, 'matchdict', new={'uuid': product.uuid}):
# flash error if wrong status
result = view.ignore()
self.assertIsInstance(result, HTTPFound)
self.assertTrue(self.request.session.peek_flash('error'))
self.assertEqual(self.request.session.pop_flash('error'),
["pending product does not have 'ready' status!"])
# product updated
product.status = enum.PendingProductStatus.READY
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.READY)
result = view.ignore()
self.assertIsInstance(result, HTTPFound)
self.assertFalse(self.request.session.peek_flash('error'))
self.assertIsNone(product.product_id)
self.assertEqual(product.status, enum.PendingProductStatus.IGNORED)