Add basic support for vendor catalog batch feature
This commit is contained in:
parent
b8ce21b6c0
commit
1177ca6591
0
corporal/batch/__init__.py
Normal file
0
corporal/batch/__init__.py
Normal file
208
corporal/batch/vendorcatalog.py
Normal file
208
corporal/batch/vendorcatalog.py
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
"""
|
||||||
|
Handler for Vendor Catalog batches
|
||||||
|
"""
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
from sqlalchemy import orm
|
||||||
|
|
||||||
|
from corepos.db.office_op import Session as CoreSession, model as corepos
|
||||||
|
|
||||||
|
from rattail_corepos.corepos.api import make_corepos_api
|
||||||
|
from rattail_corepos.batch import vendorcatalog as base
|
||||||
|
|
||||||
|
|
||||||
|
class VendorCatalogHandler(base.VendorCatalogHandler):
|
||||||
|
"""
|
||||||
|
Handler for vendor catalog batches.
|
||||||
|
"""
|
||||||
|
# upstream logic requires versioning hacks, but we do not
|
||||||
|
populate_with_versioning = True
|
||||||
|
refresh_with_versioning = True
|
||||||
|
execute_with_versioning = True
|
||||||
|
|
||||||
|
# catalog parsers will favor case cost, but CORE favors unit cost. so we
|
||||||
|
# always "ignore" unit cost diffs less than one penny to avoid useless
|
||||||
|
# updates caused by simple rounding
|
||||||
|
unit_cost_diff_threshold = decimal.Decimal('0.01')
|
||||||
|
|
||||||
|
def setup_common(self, batch, progress=None):
|
||||||
|
self.core_session = CoreSession()
|
||||||
|
|
||||||
|
query = self.core_session.query(corepos.Product)\
|
||||||
|
.options(orm.joinedload(corepos.Product.vendor_items))
|
||||||
|
self.core_products_by_upc = self.cache_model(self.core_session,
|
||||||
|
corepos.Product,
|
||||||
|
key='upc',
|
||||||
|
query=query)
|
||||||
|
|
||||||
|
query = self.core_session.query(corepos.VendorItem)\
|
||||||
|
.filter(corepos.VendorItem.vendor_id == int(batch.vendor_id))
|
||||||
|
self.core_vendor_items_by_sku = self.cache_model(self.core_session,
|
||||||
|
corepos.VendorItem,
|
||||||
|
key='sku',
|
||||||
|
query=query)
|
||||||
|
|
||||||
|
setup_populate = setup_common
|
||||||
|
setup_refresh = setup_common
|
||||||
|
|
||||||
|
def teardown_common(self, batch, progress=None):
|
||||||
|
self.core_session.rollback()
|
||||||
|
self.core_session.close()
|
||||||
|
del self.core_session
|
||||||
|
|
||||||
|
teardown_populate = teardown_common
|
||||||
|
teardown_refresh = teardown_common
|
||||||
|
|
||||||
|
def add_row(self, batch, row):
|
||||||
|
|
||||||
|
# parser logic sets upc but we want to use item_id
|
||||||
|
row.item_id = str(row.upc)[:-1]
|
||||||
|
|
||||||
|
# okay now continue as normal
|
||||||
|
# (note, this must come last b/c it will refresh row)
|
||||||
|
super(VendorCatalogHandler, self).add_row(batch, row)
|
||||||
|
|
||||||
|
def refresh_row(self, row):
|
||||||
|
|
||||||
|
# clear this first in case it's set
|
||||||
|
row.status_text = None
|
||||||
|
|
||||||
|
# find the CORE `products` record, matching by `products.upc`
|
||||||
|
core_product = self.core_products_by_upc.get(row.item_id)
|
||||||
|
|
||||||
|
# find the CORE `vendorItems` record, matching by `vendorItems.sku`
|
||||||
|
core_vendor_item = self.core_vendor_items_by_sku.get(row.vendor_code)
|
||||||
|
|
||||||
|
# if the catalog UPC is not found in `products` but the SKU *is* found
|
||||||
|
# in `vendorItems` *and* the latter ties back to valid `products`
|
||||||
|
# record, then we want to pretend that matched all along. and not just
|
||||||
|
# for the moment, but going forward; so we also update `row.item_id`
|
||||||
|
if not core_product and core_vendor_item:
|
||||||
|
core_product = core_vendor_item.product
|
||||||
|
if core_product:
|
||||||
|
row.item_id = core_product.upc
|
||||||
|
|
||||||
|
# figure out if this vendor is already default for the product. if the
|
||||||
|
# product does not yet have a default, let this vendor be it.
|
||||||
|
row.is_preferred_vendor = False
|
||||||
|
row.make_preferred_vendor = False
|
||||||
|
if core_product:
|
||||||
|
if core_product.default_vendor_id and (
|
||||||
|
str(core_product.default_vendor_id) == row.batch.vendor_id):
|
||||||
|
row.is_preferred_vendor = True
|
||||||
|
if not core_product.default_vendor_id:
|
||||||
|
row.make_preferred_vendor = True
|
||||||
|
|
||||||
|
# declare "product not found" if we did not find any matches in CORE
|
||||||
|
if not core_vendor_item and not core_product:
|
||||||
|
row.status_code = row.STATUS_PRODUCT_NOT_FOUND
|
||||||
|
return
|
||||||
|
|
||||||
|
# declare "new cost" if we found `products` match but not `vendorItems`
|
||||||
|
if not core_vendor_item:
|
||||||
|
row.status_code = row.STATUS_NEW_COST
|
||||||
|
return
|
||||||
|
|
||||||
|
# declare "change product" if `vendorItems.upc` != `products.upc`
|
||||||
|
if core_vendor_item.upc != row.item_id:
|
||||||
|
row.status_code = row.STATUS_CHANGE_PRODUCT
|
||||||
|
row.status_text = "new UPC {} differs from old UPC {}".format(
|
||||||
|
row.item_id, core_vendor_item.upc)
|
||||||
|
return
|
||||||
|
|
||||||
|
# declare "old" `vendorItems` data
|
||||||
|
row.old_vendor_code = core_vendor_item.sku
|
||||||
|
row.old_case_size = core_vendor_item.units
|
||||||
|
row.old_unit_cost = core_vendor_item.cost
|
||||||
|
row.old_case_cost = (core_vendor_item.cost * decimal.Decimal(core_vendor_item.units))\
|
||||||
|
.quantize(decimal.Decimal('0.12345'))
|
||||||
|
|
||||||
|
self.refresh_cost_diffs(row)
|
||||||
|
self.set_status_per_diffs(row)
|
||||||
|
|
||||||
|
def describe_execution(self, batch, **kwargs):
|
||||||
|
return ("The `vendorItems` table in CORE-POS will be updated directly "
|
||||||
|
"via API, for all rows indicating a change etc. In some cases "
|
||||||
|
"`products` may also be updated as appropriate.")
|
||||||
|
|
||||||
|
def execute(self, batch, progress=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Update CORE-POS etc.
|
||||||
|
"""
|
||||||
|
rows = [row for row in batch.active_rows()
|
||||||
|
if row.status_code in (row.STATUS_NEW_COST,
|
||||||
|
# row.STATUS_UPDATE_COST,
|
||||||
|
# row.STATUS_CHANGE_VENDOR_ITEM_CODE,
|
||||||
|
row.STATUS_CHANGE_CASE_SIZE,
|
||||||
|
row.STATUS_CHANGE_COST,
|
||||||
|
row.STATUS_CHANGE_PRODUCT)]
|
||||||
|
|
||||||
|
if rows:
|
||||||
|
self.api = make_corepos_api(self.config)
|
||||||
|
self.update_corepos(batch, rows, batch.vendor_id, progress=progress,
|
||||||
|
# update_product_costs=kwargs.get('update_product_costs', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_corepos(self, batch, rows, vendor_id, progress=None,
|
||||||
|
# TODO: this kwarg seems perhaps useful, but for now we
|
||||||
|
# are auto-detecting when such an update is needed
|
||||||
|
#update_product_costs=False,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update the `vendorItems` table in CORE-POS (and maybe `products` too).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def update(row, i):
|
||||||
|
# we may need this value in a couple places
|
||||||
|
unit_cost = float(row.unit_cost)
|
||||||
|
|
||||||
|
# figure out if we are "updating the same, primary" cost record,
|
||||||
|
# b/c if so we will want to update product accordingly also. this
|
||||||
|
# is always the case when this is the first vendor for product.
|
||||||
|
# updating_primary = first_vendor
|
||||||
|
updating_primary = row.make_preferred_vendor
|
||||||
|
if not updating_primary:
|
||||||
|
core_vendor_items = self.api.get_vendor_items(upc=row.item_id)
|
||||||
|
if core_vendor_items:
|
||||||
|
core_vendor_item = core_vendor_items[0]
|
||||||
|
if core_vendor_item['sku'] == row.vendor_code:
|
||||||
|
updating_primary = True
|
||||||
|
|
||||||
|
# create or update the `vendorItems` record in CORE
|
||||||
|
self.api.set_vendor_item(sku=row.vendor_code,
|
||||||
|
vendorID=vendor_id,
|
||||||
|
upc=row.item_id,
|
||||||
|
brand=row.brand_name,
|
||||||
|
description=row.description,
|
||||||
|
size=row.size,
|
||||||
|
units=row.case_size,
|
||||||
|
cost=unit_cost,
|
||||||
|
# TODO: we (may) have vendor SRP, but pretty
|
||||||
|
# sure CORE has different plans for its `srp`
|
||||||
|
#srp=row.suggested_retail,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: CORE does not have the concept of a true "default"
|
||||||
|
# `vendorItems` record, but rather uses the `modified` timestamp
|
||||||
|
# for pseudo-default. this means any given product may wind up
|
||||||
|
# with a new/different pseudo-default when the above operation
|
||||||
|
# completes. in which case, perhaps we should *always* update
|
||||||
|
# `products.cost` accordingly (below)..? er, still only if the
|
||||||
|
# product's `default_vendor_id` matches at least, i guess... for
|
||||||
|
# now we are only doing so if it "obviously" needs it.
|
||||||
|
|
||||||
|
# maybe also update `products` record in CORE
|
||||||
|
kwargs = {}
|
||||||
|
if updating_primary:
|
||||||
|
kwargs['cost'] = unit_cost
|
||||||
|
if row.make_preferred_vendor:
|
||||||
|
kwargs['default_vendor_id'] = vendor_id
|
||||||
|
if kwargs:
|
||||||
|
self.api.set_product(upc=row.item_id, **kwargs)
|
||||||
|
|
||||||
|
self.progress_loop(update, rows, progress,
|
||||||
|
message="Updating CORE-POS via API")
|
|
@ -14,7 +14,11 @@ class CorporalConfig(ConfigExtension):
|
||||||
def configure(self, config):
|
def configure(self, config):
|
||||||
|
|
||||||
# set some default config values
|
# set some default config values
|
||||||
|
config.setdefault('rattail', 'model', 'corporal.db.model')
|
||||||
config.setdefault('rattail.mail', 'emails', 'corporal.emails')
|
config.setdefault('rattail.mail', 'emails', 'corporal.emails')
|
||||||
config.setdefault('rattail', 'settings', 'corporal.settings')
|
config.setdefault('rattail', 'settings', 'corporal.settings')
|
||||||
config.setdefault('tailbone', 'menus', 'corporal.web.menus')
|
config.setdefault('tailbone', 'menus', 'corporal.web.menus')
|
||||||
config.setdefault('rattail.config', 'templates', 'corporal:data/config rattail:data/config')
|
config.setdefault('rattail.config', 'templates', 'corporal:data/config rattail:data/config')
|
||||||
|
|
||||||
|
# batches
|
||||||
|
config.setdefault('rattail.batch', 'vendor_catalog.handler', 'corporal.batch.vendorcatalog:VendorCatalogHandler')
|
||||||
|
|
0
corporal/db/__init__.py
Normal file
0
corporal/db/__init__.py
Normal file
10
corporal/db/model.py
Normal file
10
corporal/db/model.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
"""
|
||||||
|
Corporal data model w/ CORE-POS integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
# bring in all the normal stuff from Rattail
|
||||||
|
from rattail.db.model import *
|
||||||
|
|
||||||
|
# also bring in CORE integration models
|
||||||
|
from rattail_corepos.db.model import *
|
|
@ -11,6 +11,18 @@ def simple_menus(request):
|
||||||
|
|
||||||
corepos_menu = make_corepos_menu(request)
|
corepos_menu = make_corepos_menu(request)
|
||||||
|
|
||||||
|
batch_menu = {
|
||||||
|
'title': "Batches",
|
||||||
|
'type': 'menu',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'title': "Vendor Catalogs",
|
||||||
|
'url': url('vendorcatalogs'),
|
||||||
|
'perm': 'vendorcatalogs.list',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
admin_menu = {
|
admin_menu = {
|
||||||
'title': "Admin",
|
'title': "Admin",
|
||||||
'type': 'menu',
|
'type': 'menu',
|
||||||
|
@ -67,6 +79,7 @@ def simple_menus(request):
|
||||||
|
|
||||||
menus = [
|
menus = [
|
||||||
corepos_menu,
|
corepos_menu,
|
||||||
|
batch_menu,
|
||||||
admin_menu,
|
admin_menu,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -14,27 +14,14 @@ def includeme(config):
|
||||||
config.include('tailbone.views.progress')
|
config.include('tailbone.views.progress')
|
||||||
|
|
||||||
# main table views
|
# main table views
|
||||||
config.include('tailbone.views.customergroups')
|
|
||||||
config.include('tailbone.views.datasync')
|
|
||||||
config.include('tailbone.views.email')
|
config.include('tailbone.views.email')
|
||||||
config.include('tailbone.views.families')
|
|
||||||
config.include('tailbone_corepos.views.members')
|
|
||||||
config.include('tailbone.views.messages')
|
|
||||||
config.include('tailbone_corepos.views.people')
|
config.include('tailbone_corepos.views.people')
|
||||||
config.include('tailbone.views.reportcodes')
|
|
||||||
config.include('tailbone.views.roles')
|
config.include('tailbone.views.roles')
|
||||||
config.include('tailbone.views.settings')
|
config.include('tailbone.views.settings')
|
||||||
config.include('tailbone_corepos.views.subdepartments')
|
|
||||||
config.include('tailbone.views.shifts')
|
|
||||||
config.include('tailbone.views.users')
|
config.include('tailbone.views.users')
|
||||||
config.include('tailbone.views.stores')
|
|
||||||
config.include('tailbone_corepos.views.customers')
|
|
||||||
config.include('tailbone.views.employees')
|
|
||||||
config.include('tailbone.views.taxes')
|
|
||||||
config.include('tailbone_corepos.views.departments')
|
|
||||||
config.include('tailbone.views.brands')
|
|
||||||
config.include('tailbone_corepos.views.vendors')
|
|
||||||
config.include('tailbone_corepos.views.products')
|
|
||||||
|
|
||||||
# CORE-POS direct data views
|
# CORE-POS direct data views
|
||||||
config.include('tailbone_corepos.views.corepos')
|
config.include('tailbone_corepos.views.corepos')
|
||||||
|
|
||||||
|
# batches
|
||||||
|
config.include('corporal.web.views.batch.vendorcatalog')
|
||||||
|
|
0
corporal/web/views/batch/__init__.py
Normal file
0
corporal/web/views/batch/__init__.py
Normal file
63
corporal/web/views/batch/vendorcatalog.py
Normal file
63
corporal/web/views/batch/vendorcatalog.py
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
"""
|
||||||
|
Vendor Catalog Batch views for Corporal
|
||||||
|
"""
|
||||||
|
|
||||||
|
from corepos.db.office_op import model as corepos
|
||||||
|
|
||||||
|
from deform import widget as dfwidget
|
||||||
|
|
||||||
|
from tailbone.views.batch import vendorcatalog as base
|
||||||
|
from tailbone_corepos.db import CoreOfficeSession
|
||||||
|
|
||||||
|
|
||||||
|
class VendorCatalogView(base.VendorCatalogView):
|
||||||
|
"""
|
||||||
|
Master view for vendor catalog batches.
|
||||||
|
"""
|
||||||
|
form_fields = [
|
||||||
|
'id',
|
||||||
|
'description',
|
||||||
|
'vendor_id',
|
||||||
|
'vendor_name',
|
||||||
|
'filename',
|
||||||
|
'notes',
|
||||||
|
'created',
|
||||||
|
'created_by',
|
||||||
|
'rowcount',
|
||||||
|
'executed',
|
||||||
|
'executed_by',
|
||||||
|
]
|
||||||
|
|
||||||
|
def configure_form(self, f):
|
||||||
|
super(VendorCatalogView, self).configure_form(f)
|
||||||
|
model = self.model
|
||||||
|
|
||||||
|
# vendor_id
|
||||||
|
if self.creating:
|
||||||
|
vendors = CoreOfficeSession.query(corepos.Vendor)\
|
||||||
|
.order_by(corepos.Vendor.name)\
|
||||||
|
.all()
|
||||||
|
values = [(str(vendor.id), vendor.name)
|
||||||
|
for vendor in vendors]
|
||||||
|
f.set_widget('vendor_id', dfwidget.SelectWidget(values=values))
|
||||||
|
f.set_required('vendor_id')
|
||||||
|
f.set_label('vendor_id', "Vendor")
|
||||||
|
|
||||||
|
# vendor_name
|
||||||
|
if self.creating:
|
||||||
|
f.remove('vendor_name')
|
||||||
|
|
||||||
|
def get_batch_kwargs(self, batch):
|
||||||
|
kwargs = super(VendorCatalogView, self).get_batch_kwargs(batch)
|
||||||
|
|
||||||
|
if 'vendor_name' not in kwargs and batch.vendor_id:
|
||||||
|
vendor = CoreOfficeSession.query(corepos.Vendor).get(batch.vendor_id)
|
||||||
|
if vendor:
|
||||||
|
kwargs['vendor_name'] = vendor.name
|
||||||
|
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def includeme(config):
|
||||||
|
VendorCatalogView.defaults(config)
|
Loading…
Reference in a new issue