feat: add basic support to "resolve" a pending product
This commit is contained in:
parent
6b4bc3da10
commit
1ee398e8fb
|
@ -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):
|
||||||
|
|
|
@ -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=[],
|
||||||
|
)
|
|
@ -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="""
|
||||||
|
|
|
@ -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'
|
||||||
|
|
||||||
|
|
||||||
########################################
|
########################################
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
8
src/sideshow/web/templates/base.mako
Normal file
8
src/sideshow/web/templates/base.mako
Normal 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>
|
|
@ -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>
|
||||||
|
|
|
@ -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()
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
166
src/sideshow/web/templates/pending/products/view.mako
Normal file
166
src/sideshow/web/templates/pending/products/view.mako
Normal 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>
|
53
src/sideshow/web/templates/sideshow-components.mako
Normal file
53
src/sideshow/web/templates/sideshow-components.mako
Normal 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>
|
|
@ -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,
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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')
|
||||||
|
|
|
@ -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)
|
||||||
|
|
Loading…
Reference in a new issue