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:
* :meth:`make_local_customer()`
* :meth:`make_local_products()`
* :meth:`process_pending_products()`
* :meth:`make_new_order()`
And will return the new
@ -1068,7 +1068,7 @@ class NewOrderBatchHandler(BatchHandler):
"""
rows = self.get_effective_rows(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)
return order
@ -1115,46 +1115,48 @@ class NewOrderBatchHandler(BatchHandler):
session.delete(pending)
session.flush()
def make_local_products(self, batch, rows):
def process_pending_products(self, batch, rows):
"""
If applicable, this converts all :term:`pending products
<pending product>` into :term:`local products <local
product>`.
Process any :term:`pending products <pending product>` which
are present in the batch.
This is called automatically from :meth:`execute()`.
This logic will happen only if :meth:`use_local_products()`
returns true, and the batch has pending instead of local items
(so far).
If :term:`local products <local product>` are used, this will
convert the pending products to local products.
For each affected row, it will create a new
:class:`~sideshow.db.model.products.LocalProduct` record and
populate it from the row
:attr:`~sideshow.db.model.batch.neworder.NewOrderBatchRow.pending_product`.
The latter is then deleted.
If :term:`external products <external product>` are used, this
will update the pending product records' status to indicate
they are ready to be resolved.
"""
if not self.use_local_products():
return
enum = self.app.enum
model = self.app.model
session = self.app.get_session(batch)
inspector = sa.inspect(model.LocalProduct)
for row in rows:
if row.local_product or not row.pending_product:
continue
if self.use_local_products():
inspector = sa.inspect(model.LocalProduct)
for row in rows:
pending = row.pending_product
local = model.LocalProduct()
if row.local_product or not row.pending_product:
continue
for prop in inspector.column_attrs:
if hasattr(pending, prop.key):
setattr(local, prop.key, getattr(pending, prop.key))
session.add(local)
pending = row.pending_product
local = model.LocalProduct()
row.local_product = local
row.pending_product = None
session.delete(pending)
for prop in inspector.column_attrs:
if hasattr(pending, prop.key):
setattr(local, prop.key, getattr(pending, prop.key))
session.add(local)
row.local_product = local
row.pending_product = None
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()

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()
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="""

View file

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

View file

@ -105,6 +105,81 @@ class OrderHandler(GenericHandler):
enum.ORDER_ITEM_STATUS_INACTIVE):
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):
"""
Process the "placement" step for the given order items.

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -33,4 +33,6 @@ class WebTestCase(base.WebTestCase):
config = super().make_config(**kwargs)
config.setdefault('wutta.model_spec', 'sideshow.db.model')
config.setdefault('wutta.enum_spec', 'sideshow.enum')
config.setdefault(f'{config.appname}.batch.neworder.handler.default_spec',
'sideshow.batch.neworder:NewOrderBatchHandler')
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 style="width: 100%;">
<b-field horizontal label="Product ID">
<span>${item.product_id}</span>
<span>${item.product_id or ''}</span>
</b-field>
% if not item.product_id and item.local_product:
<b-field horizontal label="Local Product">
@ -220,34 +220,34 @@
</b-field>
% endif
<b-field horizontal label="Scancode">
<span>${item.product_scancode}</span>
<span>${item.product_scancode or ''}</span>
</b-field>
<b-field horizontal label="Brand">
<span>${item.product_brand}</span>
<span>${item.product_brand or ''}</span>
</b-field>
<b-field horizontal label="Description">
<span>${item.product_description}</span>
<span>${item.product_description or ''}</span>
</b-field>
<b-field horizontal label="Size">
<span>${item.product_size}</span>
<span>${item.product_size or ''}</span>
</b-field>
<b-field horizontal label="Sold by Weight">
<span>${app.render_boolean(item.product_weighed)}</span>
</b-field>
<b-field horizontal label="Department ID">
<span>${item.department_id}</span>
<span>${item.department_id or ''}</span>
</b-field>
<b-field horizontal label="Department Name">
<span>${item.department_name}</span>
<span>${item.department_name or ''}</span>
</b-field>
<b-field horizontal label="Special Order">
<span>${app.render_boolean(item.special_order)}</span>
</b-field>
<b-field horizontal label="Vendor Name">
<span>${item.vendor_name}</span>
<span>${item.vendor_name or ''}</span>
</b-field>
<b-field horizontal label="Vendor Item Code">
<span>${item.vendor_item_code}</span>
<span>${item.vendor_item_code or ''}</span>
</b-field>
</div>
</div>

View file

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

View file

@ -2,7 +2,7 @@
################################################################################
#
# Sideshow -- Case/Special Order Tracker
# Copyright © 2024 Lance Edgar
# Copyright © 2024-2025 Lance Edgar
#
# This file is part of Sideshow.
#
@ -27,6 +27,7 @@ Views for Products
from wuttaweb.views import MasterView
from wuttaweb.forms.schema import UserRef, WuttaEnum, WuttaMoney, WuttaQuantity
from sideshow.enum import PendingProductStatus
from sideshow.db.model import LocalProduct, PendingProduct
@ -254,7 +255,12 @@ class PendingProductView(MasterView):
'created_by',
]
sort_defaults = 'scancode'
sort_defaults = ('created', 'desc')
filter_defaults = {
'status': {'active': True,
'value': PendingProductStatus.READY.name},
}
form_fields = [
'product_id',
@ -271,7 +277,6 @@ class PendingProductView(MasterView):
'unit_price_reg',
'special_order',
'notes',
'status',
'created',
'created_by',
'orders',
@ -291,7 +296,7 @@ class PendingProductView(MasterView):
g.set_renderer('unit_price_reg', 'currency')
# status
g.set_renderer('status', self.grid_render_enum, enum=enum.PendingProductStatus)
g.set_enum('status', enum.PendingProductStatus)
# links
g.set_link('scancode')
@ -299,6 +304,12 @@ class PendingProductView(MasterView):
g.set_link('description')
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):
""" """
super().configure_form(f)
@ -317,13 +328,6 @@ class PendingProductView(MasterView):
# 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')
@ -417,6 +421,19 @@ class PendingProductView(MasterView):
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):
""" """
@ -431,6 +448,98 @@ class PendingProductView(MasterView):
# go ahead and delete per usual
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):
base = globals()

View file

@ -1219,7 +1219,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.full_name, "Chuck Norris")
self.assertEqual(local.phone_number, '555-1234')
def test_make_local_products(self):
def test_process_pending_products(self):
model = self.app.model
enum = self.app.enum
handler = self.make_handler()
@ -1253,7 +1253,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(self.session.query(model.LocalProduct).count(), 1)
self.assertIsNotNone(row2.pending_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.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product)
@ -1266,7 +1266,7 @@ class TestNewOrderBatchHandler(DataTestCase):
self.assertEqual(local.unit_price_reg, decimal.Decimal('5.99'))
# 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.LocalProduct).count(), 2)
self.assertIsNone(row2.pending_product)
@ -1291,24 +1291,26 @@ class TestNewOrderBatchHandler(DataTestCase):
), 1, enum.ORDER_UOM_UNIT)
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):
self.assertEqual(self.session.query(model.PendingProduct).count(), 1)
self.assertEqual(self.session.query(model.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.PENDING)
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.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_product)
self.assertEqual(row.pending_product.status, enum.PendingProductStatus.READY)
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.LocalProduct).count(), 2)
self.assertIsNotNone(row.pending_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.LocalProduct).count(), 3)
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_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):
model = self.app.model
enum = self.app.enum

View file

@ -1144,6 +1144,8 @@ class TestOrderView(WebTestCase):
row.department_id = 1
row.department_name = "Bricks & Mortar"
row.special_order = False
row.vendor_name = 'Acme Distributors'
row.vendor_item_code = '1234'
row.case_size = None
row.unit_cost = decimal.Decimal('599.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_full_description'], 'Acme Bricks 1 ton')
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_uom'], 'EA')
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('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):
model = self.app.model
enum = self.app.enum
@ -141,7 +154,6 @@ class TestPendingProductView(WebTestCase):
with patch.object(view, 'creating', new=True):
form = view.make_form(model_class=model.PendingProduct)
view.configure_form(form)
self.assertNotIn('status', form)
self.assertNotIn('created', form)
self.assertNotIn('created_by', form)
@ -216,6 +228,39 @@ class TestPendingProductView(WebTestCase):
self.assertEqual(len(grid.actions), 1)
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):
self.pyramid_config.add_route('pending_products.view', '/pending/products/{uuid}')
model = self.app.model
@ -259,3 +304,117 @@ class TestPendingProductView(WebTestCase):
view.delete_instance(product)
self.session.flush()
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)