From 1177ca6591838064b4e2bb940da887c8210b4f1b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Mon, 15 Feb 2021 13:11:47 -0600 Subject: [PATCH] Add basic support for vendor catalog batch feature --- corporal/batch/__init__.py | 0 corporal/batch/vendorcatalog.py | 208 ++++++++++++++++++++++ corporal/config.py | 4 + corporal/db/__init__.py | 0 corporal/db/model.py | 10 ++ corporal/web/menus.py | 13 ++ corporal/web/views/__init__.py | 19 +- corporal/web/views/batch/__init__.py | 0 corporal/web/views/batch/vendorcatalog.py | 63 +++++++ 9 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 corporal/batch/__init__.py create mode 100644 corporal/batch/vendorcatalog.py create mode 100644 corporal/db/__init__.py create mode 100644 corporal/db/model.py create mode 100644 corporal/web/views/batch/__init__.py create mode 100644 corporal/web/views/batch/vendorcatalog.py diff --git a/corporal/batch/__init__.py b/corporal/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/corporal/batch/vendorcatalog.py b/corporal/batch/vendorcatalog.py new file mode 100644 index 0000000..2a9810f --- /dev/null +++ b/corporal/batch/vendorcatalog.py @@ -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") diff --git a/corporal/config.py b/corporal/config.py index b9b02ed..92e0e36 100644 --- a/corporal/config.py +++ b/corporal/config.py @@ -14,7 +14,11 @@ class CorporalConfig(ConfigExtension): def configure(self, config): # set some default config values + config.setdefault('rattail', 'model', 'corporal.db.model') config.setdefault('rattail.mail', 'emails', 'corporal.emails') config.setdefault('rattail', 'settings', 'corporal.settings') config.setdefault('tailbone', 'menus', 'corporal.web.menus') config.setdefault('rattail.config', 'templates', 'corporal:data/config rattail:data/config') + + # batches + config.setdefault('rattail.batch', 'vendor_catalog.handler', 'corporal.batch.vendorcatalog:VendorCatalogHandler') diff --git a/corporal/db/__init__.py b/corporal/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/corporal/db/model.py b/corporal/db/model.py new file mode 100644 index 0000000..b359cf4 --- /dev/null +++ b/corporal/db/model.py @@ -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 * diff --git a/corporal/web/menus.py b/corporal/web/menus.py index aa8879c..5db48e8 100644 --- a/corporal/web/menus.py +++ b/corporal/web/menus.py @@ -11,6 +11,18 @@ def simple_menus(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 = { 'title': "Admin", 'type': 'menu', @@ -67,6 +79,7 @@ def simple_menus(request): menus = [ corepos_menu, + batch_menu, admin_menu, ] diff --git a/corporal/web/views/__init__.py b/corporal/web/views/__init__.py index 9ffb896..afc6a0c 100644 --- a/corporal/web/views/__init__.py +++ b/corporal/web/views/__init__.py @@ -14,27 +14,14 @@ def includeme(config): config.include('tailbone.views.progress') # main table views - config.include('tailbone.views.customergroups') - config.include('tailbone.views.datasync') 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.views.reportcodes') config.include('tailbone.views.roles') 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.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 config.include('tailbone_corepos.views.corepos') + + # batches + config.include('corporal.web.views.batch.vendorcatalog') diff --git a/corporal/web/views/batch/__init__.py b/corporal/web/views/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/corporal/web/views/batch/vendorcatalog.py b/corporal/web/views/batch/vendorcatalog.py new file mode 100644 index 0000000..2c32cc6 --- /dev/null +++ b/corporal/web/views/batch/vendorcatalog.py @@ -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)