Misc. changes for vendor catalog batch and related features
not very targeted, but all is solid i think..
This commit is contained in:
		
							parent
							
								
									0bec971b8b
								
							
						
					
					
						commit
						1ac0139fd3
					
				
					 16 changed files with 519 additions and 28 deletions
				
			
		| 
						 | 
				
			
			@ -20,6 +20,7 @@ attributes and method signatures etc.
 | 
			
		|||
   rattail/batch/pricing
 | 
			
		||||
   rattail/batch/product
 | 
			
		||||
   rattail/batch/purchase
 | 
			
		||||
   rattail/batch/vendorcatalog
 | 
			
		||||
   rattail/board
 | 
			
		||||
   rattail/bouncer/index
 | 
			
		||||
   rattail/clientele
 | 
			
		||||
| 
						 | 
				
			
			@ -33,6 +34,7 @@ attributes and method signatures etc.
 | 
			
		|||
   rattail/db/index
 | 
			
		||||
   rattail/employment
 | 
			
		||||
   rattail/enum
 | 
			
		||||
   rattail/excel
 | 
			
		||||
   rattail/exceptions
 | 
			
		||||
   rattail/features/index
 | 
			
		||||
   rattail/filemon/index
 | 
			
		||||
| 
						 | 
				
			
			@ -62,4 +64,5 @@ attributes and method signatures etc.
 | 
			
		|||
   rattail/trainwreck/index
 | 
			
		||||
   rattail/upgrades
 | 
			
		||||
   rattail/util
 | 
			
		||||
   rattail/vendors.catalogs
 | 
			
		||||
   rattail/win32
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										6
									
								
								docs/api/rattail/batch/vendorcatalog.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/rattail/batch/vendorcatalog.rst
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
 | 
			
		||||
``rattail.batch.vendorcatalog``
 | 
			
		||||
===============================
 | 
			
		||||
 | 
			
		||||
.. automodule:: rattail.batch.vendorcatalog
 | 
			
		||||
   :members:
 | 
			
		||||
							
								
								
									
										6
									
								
								docs/api/rattail/excel.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/rattail/excel.rst
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
 | 
			
		||||
``rattail.excel``
 | 
			
		||||
=================
 | 
			
		||||
 | 
			
		||||
.. automodule:: rattail.excel
 | 
			
		||||
   :members:
 | 
			
		||||
							
								
								
									
										6
									
								
								docs/api/rattail/vendors.catalogs.rst
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								docs/api/rattail/vendors.catalogs.rst
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
 | 
			
		||||
``rattail.vendors.catalogs``
 | 
			
		||||
============================
 | 
			
		||||
 | 
			
		||||
.. automodule:: rattail.vendors.catalogs
 | 
			
		||||
   :members:
 | 
			
		||||
| 
						 | 
				
			
			@ -123,7 +123,7 @@ todo_include_todos = True
 | 
			
		|||
 | 
			
		||||
# The theme to use for HTML and HTML Help pages.  See the documentation for
 | 
			
		||||
# a list of builtin themes.
 | 
			
		||||
html_theme = 'alabaster'
 | 
			
		||||
html_theme = 'classic'
 | 
			
		||||
 | 
			
		||||
# Theme options are theme-specific and customize the look and feel of a theme
 | 
			
		||||
# further.  For a list of options available for each theme, see the
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -38,6 +38,7 @@ from rattail.util import (load_object, load_entry_points,
 | 
			
		|||
from rattail.files import temp_path
 | 
			
		||||
from rattail.mail import send_email
 | 
			
		||||
from rattail.config import parse_list
 | 
			
		||||
from rattail.core import get_uuid
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class AppHandler(object):
 | 
			
		||||
| 
						 | 
				
			
			@ -763,6 +764,14 @@ class AppHandler(object):
 | 
			
		|||
                kwargs['dir'] = tmpdir
 | 
			
		||||
        return temp_path(**kwargs)
 | 
			
		||||
 | 
			
		||||
    def make_uuid(self):
 | 
			
		||||
        """
 | 
			
		||||
        Generate a new UUID value.
 | 
			
		||||
 | 
			
		||||
        :returns: UUID value as 32-character string.
 | 
			
		||||
        """
 | 
			
		||||
        return get_uuid()
 | 
			
		||||
 | 
			
		||||
    def normalize_phone_number(self, number, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Normalize the given phone number, to a "common" format that
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
################################################################################
 | 
			
		||||
#
 | 
			
		||||
#  Rattail -- Retail Software Framework
 | 
			
		||||
#  Copyright © 2010-2021 Lance Edgar
 | 
			
		||||
#  Copyright © 2010-2022 Lance Edgar
 | 
			
		||||
#
 | 
			
		||||
#  This file is part of Rattail.
 | 
			
		||||
#
 | 
			
		||||
| 
						 | 
				
			
			@ -26,6 +26,8 @@ Handler for Vendor Catalog batches
 | 
			
		|||
 | 
			
		||||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
import decimal
 | 
			
		||||
 | 
			
		||||
from sqlalchemy import orm
 | 
			
		||||
 | 
			
		||||
from rattail.db import model
 | 
			
		||||
| 
						 | 
				
			
			@ -88,22 +90,43 @@ class VendorCatalogHandler(BatchHandler):
 | 
			
		|||
 | 
			
		||||
    def populate_from_file(self, batch, progress=None):
 | 
			
		||||
        """
 | 
			
		||||
        Pre-fill batch with row data from an input data file, leveraging a
 | 
			
		||||
        specific catalog parser.
 | 
			
		||||
        Populate the given batch using data from its input file.  A
 | 
			
		||||
        catalog parser will be instantiated and asked to read row data
 | 
			
		||||
        from the file.  Each row is then added to the batch.
 | 
			
		||||
 | 
			
		||||
        The batch must have valid
 | 
			
		||||
        :attr:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatch.filename`
 | 
			
		||||
        and
 | 
			
		||||
        :attr:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatch.parser_key`
 | 
			
		||||
        attributes.  The path to the input file will be determined by
 | 
			
		||||
        invoking the
 | 
			
		||||
        :meth:`~rattail.db.model.batch.core.BatchMixin.filepath()`
 | 
			
		||||
        method on the batch.
 | 
			
		||||
 | 
			
		||||
        :param batch: The batch to be populated.
 | 
			
		||||
 | 
			
		||||
        :param progress: Optional progress factory.
 | 
			
		||||
        """
 | 
			
		||||
        assert batch.filename and batch.parser_key
 | 
			
		||||
        if not batch.filename:
 | 
			
		||||
            raise ValueError("batch does not have a filename: {}".format(batch))
 | 
			
		||||
        if not batch.parser_key:
 | 
			
		||||
            raise ValueError("batch does not have a parser_key: {}".format(batch))
 | 
			
		||||
 | 
			
		||||
        session = orm.object_session(batch)
 | 
			
		||||
        path = batch.filepath(self.config)
 | 
			
		||||
        parser = require_catalog_parser(batch.parser_key)
 | 
			
		||||
        # TODO: should add `config` kwarg to CatalogParser constructor
 | 
			
		||||
        parser.config = self.config
 | 
			
		||||
        parser.app = self.app
 | 
			
		||||
        parser.model = self.model
 | 
			
		||||
        parser.enum = self.enum
 | 
			
		||||
        parser.session = session
 | 
			
		||||
        parser.vendor = batch.vendor
 | 
			
		||||
        batch.effective = parser.parse_effective_date(path)
 | 
			
		||||
 | 
			
		||||
        def append(row, i):
 | 
			
		||||
            self.add_row(batch, row)
 | 
			
		||||
            if i % 1000 == 0:
 | 
			
		||||
            if i % 1000 == 0: # pragma: no cover
 | 
			
		||||
                session.flush()
 | 
			
		||||
 | 
			
		||||
        data = list(parser.parse_rows(path, progress=progress))
 | 
			
		||||
| 
						 | 
				
			
			@ -111,27 +134,75 @@ class VendorCatalogHandler(BatchHandler):
 | 
			
		|||
                           message="Adding initial rows to batch")
 | 
			
		||||
 | 
			
		||||
    def identify_product(self, row):
 | 
			
		||||
        """
 | 
			
		||||
        Try to locate the product represented by the given row.
 | 
			
		||||
        Lookups are done using either the ``upc`` or ``vendor_code``
 | 
			
		||||
        attributes of the row.
 | 
			
		||||
 | 
			
		||||
        Under normal circumstances the batch handler will have
 | 
			
		||||
        pre-cached all existing products, for quicker lookup.  For
 | 
			
		||||
        instance this is the case for the full populate and refresh
 | 
			
		||||
        actions.  But this logic is able to do its own slower lookups
 | 
			
		||||
        if there is no cache available.
 | 
			
		||||
 | 
			
		||||
        :param row: A
 | 
			
		||||
           :class:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatchRow`
 | 
			
		||||
           instance.
 | 
			
		||||
 | 
			
		||||
        :returns: A :class:`~rattail.db.model.products.Product`
 | 
			
		||||
           instance, or ``None`` if no match could be found.
 | 
			
		||||
        """
 | 
			
		||||
        products_handler = self.app.get_products_handler()
 | 
			
		||||
        session = self.app.get_session(row)
 | 
			
		||||
        product = None
 | 
			
		||||
 | 
			
		||||
        if row.upc:
 | 
			
		||||
            product = self.products['upc'].get(row.upc)
 | 
			
		||||
            if hasattr(self, 'products'):
 | 
			
		||||
                product = self.products['upc'].get(row.upc)
 | 
			
		||||
            else:
 | 
			
		||||
                product = products_handler.locate_product_for_gpc(
 | 
			
		||||
                    session, row.upc)
 | 
			
		||||
 | 
			
		||||
        if not product and row.vendor_code:
 | 
			
		||||
            product = self.products['vendor_code'].get(row.vendor_code)
 | 
			
		||||
            if hasattr(self, 'products'):
 | 
			
		||||
                product = self.products['vendor_code'].get(row.vendor_code)
 | 
			
		||||
            else:
 | 
			
		||||
                product = products_handler.locate_product_for_entry(
 | 
			
		||||
                    session, row.vendor_code, product_key='ignore',
 | 
			
		||||
                    lookup_vendor_code=True, vendor=row.batch.vendor)
 | 
			
		||||
 | 
			
		||||
        return product
 | 
			
		||||
 | 
			
		||||
    def refresh_row(self, row):
 | 
			
		||||
        """
 | 
			
		||||
        Inspect a single row from a catalog, and set its attributes based on
 | 
			
		||||
        whether or not the product exists, if we already have a cost record for
 | 
			
		||||
        the vendor, if the catalog contains a change etc.  Note that the
 | 
			
		||||
        product lookup is done first by UPC and then by vendor item code.
 | 
			
		||||
        Refresh data attributes and status for the given row.
 | 
			
		||||
 | 
			
		||||
        For a vendor catalog, the typical thing is done for basic
 | 
			
		||||
        product attributes.
 | 
			
		||||
 | 
			
		||||
        If case cost is known but unit cost is not, the latter will be
 | 
			
		||||
        calculated if possible.
 | 
			
		||||
 | 
			
		||||
        "Old" (i.e. "current" prior to batch execution) values will
 | 
			
		||||
        all be re-fetched from the main database(s), and "diff" values
 | 
			
		||||
        will be re-calculated.
 | 
			
		||||
 | 
			
		||||
        :param row: A
 | 
			
		||||
           :class:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatchRow`
 | 
			
		||||
           instance.
 | 
			
		||||
        """
 | 
			
		||||
        batch = row.batch
 | 
			
		||||
 | 
			
		||||
        # clear this first in case it's set
 | 
			
		||||
        row.status_text = None
 | 
			
		||||
 | 
			
		||||
        row.product = self.identify_product(row)
 | 
			
		||||
        if not row.product:
 | 
			
		||||
            row.status_code = row.STATUS_PRODUCT_NOT_FOUND
 | 
			
		||||
            return
 | 
			
		||||
            row.product = self.identify_product(row)
 | 
			
		||||
            if not row.product:
 | 
			
		||||
                row.status_code = row.STATUS_PRODUCT_NOT_FOUND
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
        product = row.product
 | 
			
		||||
 | 
			
		||||
        row.upc = row.product.upc
 | 
			
		||||
        row.item_id = row.product.item_id
 | 
			
		||||
| 
						 | 
				
			
			@ -139,7 +210,19 @@ class VendorCatalogHandler(BatchHandler):
 | 
			
		|||
        row.description = row.product.description
 | 
			
		||||
        row.size = row.product.size
 | 
			
		||||
 | 
			
		||||
        old_cost = row.product.vendor_cost
 | 
			
		||||
        # maybe calculate unit cost from case cost
 | 
			
		||||
        if row.unit_cost is None and row.case_cost is not None:
 | 
			
		||||
            if row.case_size is not None:
 | 
			
		||||
                # nb. sometimes both case size and cost are integers,
 | 
			
		||||
                # and simple division yields a float!  this approach
 | 
			
		||||
                # should hopefully work regardless, to get a decimal.
 | 
			
		||||
                row.unit_cost = decimal.Decimal('{:0.4f}'.format(
 | 
			
		||||
                    row.case_cost / row.case_size))
 | 
			
		||||
 | 
			
		||||
        if hasattr(product, 'vendor_cost'):
 | 
			
		||||
            old_cost = product.vendor_cost
 | 
			
		||||
        else:
 | 
			
		||||
            old_cost = product.cost_for_vendor(batch.vendor)
 | 
			
		||||
        if not old_cost:
 | 
			
		||||
            row.status_code = row.STATUS_NEW_COST
 | 
			
		||||
            return
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								rattail/contrib/vendors/catalogs/generic.py
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								rattail/contrib/vendors/catalogs/generic.py
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -62,6 +62,7 @@ class GenericCatalogParser(CatalogParser):
 | 
			
		|||
            if not upc or not six.text_type(upc).strip():
 | 
			
		||||
                continue        # skip lines with no UPC value
 | 
			
		||||
            upc = str(upc).replace(' ', '').replace('-', '')
 | 
			
		||||
            row.item_entry = upc
 | 
			
		||||
            row.upc = GPC(upc, calc_check_digit=False if len(upc) in (12, 13) else 'upc')
 | 
			
		||||
 | 
			
		||||
            # cost values (required in some combination)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
################################################################################
 | 
			
		||||
#
 | 
			
		||||
#  Rattail -- Retail Software Framework
 | 
			
		||||
#  Copyright © 2010-2021 Lance Edgar
 | 
			
		||||
#  Copyright © 2010-2022 Lance Edgar
 | 
			
		||||
#
 | 
			
		||||
#  This file is part of Rattail.
 | 
			
		||||
#
 | 
			
		||||
| 
						 | 
				
			
			@ -111,10 +111,23 @@ class ExcelReaderXLSX(object):
 | 
			
		|||
    """
 | 
			
		||||
    Basic class for reading Excel 2010 (.xslx) files.
 | 
			
		||||
 | 
			
		||||
    Uses the ``openpyxl`` package to read the files.
 | 
			
		||||
    Uses the `openpyxl`_ package to read the files.
 | 
			
		||||
 | 
			
		||||
    .. _openpyxl: https://openpyxl.readthedocs.io/en/stable/
 | 
			
		||||
 | 
			
		||||
    :param path: Path to the Excel data file.
 | 
			
		||||
 | 
			
		||||
    :param header_row: 1-based row number which contains the header,
 | 
			
		||||
       with field names.
 | 
			
		||||
 | 
			
		||||
    :param strip_fieldnames: If true (the default), any whitespace
 | 
			
		||||
       surrounding the field names will be stripped, i.e. after they
 | 
			
		||||
       are read from the header row.  Pass ``False`` here to suppress
 | 
			
		||||
       the behavior and leave whitespace intact.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, path, header_row=1, **kwargs):
 | 
			
		||||
    def __init__(self, path, header_row=1, strip_fieldnames=True,
 | 
			
		||||
                 **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Constructor; opens an Excel file for reading.
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -123,7 +136,11 @@ class ExcelReaderXLSX(object):
 | 
			
		|||
        :param header_row: Which row contains the column headers.  This is
 | 
			
		||||
           1-based, so the 1 is the default (i.e. the first row).
 | 
			
		||||
        """
 | 
			
		||||
        self.book = openpyxl.load_workbook(filename=path)
 | 
			
		||||
        # nb. after much use with no problems, eventually did come
 | 
			
		||||
        # across a spreadsheet which contained formula instead of
 | 
			
		||||
        # values for certain cells.  so now using ``data_only=True``
 | 
			
		||||
        # to avoid the formula, hopefully nothing else breaks..
 | 
			
		||||
        self.book = openpyxl.load_workbook(filename=path, data_only=True)
 | 
			
		||||
        self.sheet = self.book.active
 | 
			
		||||
 | 
			
		||||
        self.header_row = header_row
 | 
			
		||||
| 
						 | 
				
			
			@ -137,6 +154,9 @@ class ExcelReaderXLSX(object):
 | 
			
		|||
            assert self.fields is None
 | 
			
		||||
            self.fields = list(row)
 | 
			
		||||
 | 
			
		||||
        if strip_fieldnames:
 | 
			
		||||
            self.fields = [field.strip() for field in self.fields]
 | 
			
		||||
 | 
			
		||||
    def iter_rows(self):
 | 
			
		||||
        return self.sheet.iter_rows(min_row=self.header_row + 1,
 | 
			
		||||
                                    values_only=True)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
################################################################################
 | 
			
		||||
#
 | 
			
		||||
#  Rattail -- Retail Software Framework
 | 
			
		||||
#  Copyright © 2010-2021 Lance Edgar
 | 
			
		||||
#  Copyright © 2010-2022 Lance Edgar
 | 
			
		||||
#
 | 
			
		||||
#  This file is part of Rattail.
 | 
			
		||||
#
 | 
			
		||||
| 
						 | 
				
			
			@ -47,12 +47,26 @@ class ProductsHandler(GenericHandler):
 | 
			
		|||
    particular product can be deleted, etc.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def make_gpc(self, value, **kwargs):
 | 
			
		||||
    def make_gpc(self, value, ignore_errors=False, **kwargs):
 | 
			
		||||
        """
 | 
			
		||||
        Try to convert the given value to a :class:`~rattail.gpc.GPC`
 | 
			
		||||
        instance, and return the result.
 | 
			
		||||
        Try to convert the given value to a GPC, and return the
 | 
			
		||||
        result.
 | 
			
		||||
 | 
			
		||||
        :param value: Value to be converted.  This should be either a
 | 
			
		||||
           string or integer value.
 | 
			
		||||
 | 
			
		||||
        :param ignore_errors: If ``value`` is not valid for a GPC, an
 | 
			
		||||
           error will be raised unless this param is set to true.
 | 
			
		||||
 | 
			
		||||
        :returns: A :class:`~rattail.gpc.GPC` instance.  Or, if the
 | 
			
		||||
           ``value`` is not valid, and ``ignore_errors`` was true,
 | 
			
		||||
           then returns ``None``.
 | 
			
		||||
        """
 | 
			
		||||
        return GPC(value, **kwargs)
 | 
			
		||||
        try:
 | 
			
		||||
            return GPC(value, **kwargs)
 | 
			
		||||
        except:
 | 
			
		||||
            if not ignore_errors:
 | 
			
		||||
                raise
 | 
			
		||||
 | 
			
		||||
    def make_full_description(self, product=None,
 | 
			
		||||
                              brand_name=None, description=None, size=None,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										32
									
								
								rattail/vendors/catalogs.py
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										32
									
								
								rattail/vendors/catalogs.py
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -2,7 +2,7 @@
 | 
			
		|||
################################################################################
 | 
			
		||||
#
 | 
			
		||||
#  Rattail -- Retail Software Framework
 | 
			
		||||
#  Copyright © 2010-2018 Lance Edgar
 | 
			
		||||
#  Copyright © 2010-2022 Lance Edgar
 | 
			
		||||
#
 | 
			
		||||
#  This file is part of Rattail.
 | 
			
		||||
#
 | 
			
		||||
| 
						 | 
				
			
			@ -38,7 +38,15 @@ class CatalogParser(object):
 | 
			
		|||
    """
 | 
			
		||||
    Base class for all vendor catalog parsers.
 | 
			
		||||
 | 
			
		||||
    .. attr:: vendor_key
 | 
			
		||||
    .. note::
 | 
			
		||||
 | 
			
		||||
       As of this writing the ``config`` param is technically optional
 | 
			
		||||
       for the class constructor method, but that will certainly
 | 
			
		||||
       change some day.  Please be sure to pass a ``config`` param
 | 
			
		||||
       when instantiating parsers in your code.
 | 
			
		||||
 | 
			
		||||
    .. attribute:: vendor_key
 | 
			
		||||
 | 
			
		||||
       Key for the vendor.  This key will be used to locate an entry in the
 | 
			
		||||
       settings table, e.g. ``'rattail.vendor.unfi'`` for a key of ``'unfi'``.
 | 
			
		||||
       The value of this setting must be an exact match to either a
 | 
			
		||||
| 
						 | 
				
			
			@ -49,7 +57,12 @@ class CatalogParser(object):
 | 
			
		|||
    """
 | 
			
		||||
    vendor_key = None
 | 
			
		||||
 | 
			
		||||
    # TODO: should add constructor, to accept `config` kwarg at least
 | 
			
		||||
    def __init__(self, config=None, **kwargs):
 | 
			
		||||
        if config:
 | 
			
		||||
            self.config = config
 | 
			
		||||
            self.app = config.get_app()
 | 
			
		||||
            self.model = config.get_model()
 | 
			
		||||
            self.enum = config.get_enum()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def key(self):
 | 
			
		||||
| 
						 | 
				
			
			@ -69,6 +82,19 @@ class CatalogParser(object):
 | 
			
		|||
        """
 | 
			
		||||
        raise NotImplementedError("Catalog parser has no `parse_rows()` method: {0}".format(repr(self.key)))
 | 
			
		||||
 | 
			
		||||
    def make_row(self):
 | 
			
		||||
        """
 | 
			
		||||
        Create and return a new row, suitable for use in a vendor
 | 
			
		||||
        catalog batch.  The row will be empty and not yet part of any
 | 
			
		||||
        database session.
 | 
			
		||||
 | 
			
		||||
        :returns: A
 | 
			
		||||
           :class:`~rattail.db.model.batch.vendorcatalog.VendorCatalogBatchRow`
 | 
			
		||||
           instance.
 | 
			
		||||
        """
 | 
			
		||||
        model = self.model
 | 
			
		||||
        return model.VendorCatalogBatchRow()
 | 
			
		||||
 | 
			
		||||
    def decimal(self, value, scale=4):
 | 
			
		||||
        """
 | 
			
		||||
        Convert a value to a decimal, unless it's ``None``.
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										201
									
								
								tests/batch/test_vendorcatalog.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								tests/batch/test_vendorcatalog.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,201 @@
 | 
			
		|||
# -*- coding: utf-8; -*-
 | 
			
		||||
 | 
			
		||||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
import shutil
 | 
			
		||||
import decimal
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
from rattail.batch import vendorcatalog as mod
 | 
			
		||||
from rattail.config import make_config
 | 
			
		||||
from rattail.db import Session
 | 
			
		||||
from rattail.excel import ExcelWriter
 | 
			
		||||
from rattail.gpc import GPC
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestProductBatchHandler(TestCase):
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.config = self.make_config()
 | 
			
		||||
        self.handler = self.make_handler()
 | 
			
		||||
 | 
			
		||||
    def make_config(self):
 | 
			
		||||
        return make_config([], extend=False)
 | 
			
		||||
 | 
			
		||||
    def make_handler(self):
 | 
			
		||||
        return mod.VendorCatalogHandler(self.config)
 | 
			
		||||
 | 
			
		||||
    def test_populate_from_file(self):
 | 
			
		||||
        engine = sa.create_engine('sqlite://')
 | 
			
		||||
        model = self.config.get_model()
 | 
			
		||||
        model.Base.metadata.create_all(bind=engine)
 | 
			
		||||
        session = Session(bind=engine)
 | 
			
		||||
        app = self.config.get_app()
 | 
			
		||||
 | 
			
		||||
        # we'll need a user to create the batches
 | 
			
		||||
        user = model.User(username='ralph')
 | 
			
		||||
        session.add(user)
 | 
			
		||||
 | 
			
		||||
        # make root folder to contain all temp files
 | 
			
		||||
        tempdir = app.make_temp_dir()
 | 
			
		||||
 | 
			
		||||
        # generate sample xlsx file
 | 
			
		||||
        path = os.path.join(tempdir, 'sample.xlsx')
 | 
			
		||||
        writer = ExcelWriter(path, ['UPC', 'Vendor Code', 'Unit Cost'])
 | 
			
		||||
        writer.write_header()
 | 
			
		||||
        writer.write_row(['074305001321', '123456', 4.19], row=2)
 | 
			
		||||
        writer.save()
 | 
			
		||||
 | 
			
		||||
        # make, configure folder for batch files
 | 
			
		||||
        filesdir = os.path.join(tempdir, 'batch_files')
 | 
			
		||||
        os.makedirs(filesdir)
 | 
			
		||||
        self.config.setdefault('rattail', 'batch.files', filesdir)
 | 
			
		||||
 | 
			
		||||
        # make the basic batch
 | 
			
		||||
        batch = model.VendorCatalogBatch(uuid=app.make_uuid(),
 | 
			
		||||
                                         id=1, created_by=user)
 | 
			
		||||
        session.add(batch)
 | 
			
		||||
 | 
			
		||||
        # batch must have certain attributes, else error
 | 
			
		||||
        self.assertRaises(ValueError, self.handler.populate_from_file, batch)
 | 
			
		||||
        self.handler.set_input_file(batch, path) # sets batch.filename
 | 
			
		||||
        self.assertRaises(ValueError, self.handler.populate_from_file, batch)
 | 
			
		||||
        batch.parser_key = 'rattail.contrib.generic'
 | 
			
		||||
 | 
			
		||||
        # and finally, test our method proper
 | 
			
		||||
        self.handler.setup_populate(batch)
 | 
			
		||||
        self.handler.populate_from_file(batch)
 | 
			
		||||
        self.assertEqual(len(batch.data_rows), 1)
 | 
			
		||||
        row = batch.data_rows[0]
 | 
			
		||||
        self.assertEqual(row.item_entry, '074305001321')
 | 
			
		||||
        self.assertEqual(row.vendor_code, '123456')
 | 
			
		||||
        self.assertEqual(row.unit_cost, decimal.Decimal('4.19'))
 | 
			
		||||
 | 
			
		||||
        shutil.rmtree(tempdir)
 | 
			
		||||
        session.rollback()
 | 
			
		||||
        session.close()
 | 
			
		||||
 | 
			
		||||
    def test_identify_product(self):
 | 
			
		||||
        engine = sa.create_engine('sqlite://')
 | 
			
		||||
        model = self.config.get_model()
 | 
			
		||||
        model.Base.metadata.create_all(bind=engine)
 | 
			
		||||
        session = Session(bind=engine)
 | 
			
		||||
        app = self.config.get_app()
 | 
			
		||||
 | 
			
		||||
        # make a test user, vendor, product, cost
 | 
			
		||||
        user = model.User(username='ralph')
 | 
			
		||||
        session.add(user)
 | 
			
		||||
        vendor = model.Vendor()
 | 
			
		||||
        session.add(vendor)
 | 
			
		||||
        product = model.Product(upc=GPC('074305001321'))
 | 
			
		||||
        session.add(product)
 | 
			
		||||
        cost = model.ProductCost(vendor=vendor,
 | 
			
		||||
                                 code='123456',
 | 
			
		||||
                                 case_size=12,
 | 
			
		||||
                                 case_cost=decimal.Decimal('54.00'),
 | 
			
		||||
                                 unit_cost=decimal.Decimal('4.50'))
 | 
			
		||||
        product.costs.append(cost)
 | 
			
		||||
 | 
			
		||||
        # also a batch to contain the rows
 | 
			
		||||
        batch = model.VendorCatalogBatch(uuid=app.make_uuid(),
 | 
			
		||||
                                         id=1, created_by=user,
 | 
			
		||||
                                         vendor=vendor,
 | 
			
		||||
                                         filename='sample.xlsx',
 | 
			
		||||
                                         parser_key='rattail.contrib.generic')
 | 
			
		||||
        session.add(batch)
 | 
			
		||||
 | 
			
		||||
        # row w/ no interesting attributes cannot yield a product
 | 
			
		||||
        row = model.VendorCatalogBatchRow()
 | 
			
		||||
        batch.data_rows.append(row)
 | 
			
		||||
        result = self.handler.identify_product(row)
 | 
			
		||||
        self.assertIsNone(result)
 | 
			
		||||
 | 
			
		||||
        # but if we give row a upc, product is found
 | 
			
		||||
        row.upc = GPC('074305001321')
 | 
			
		||||
        result = self.handler.identify_product(row)
 | 
			
		||||
        self.assertIs(result, product)
 | 
			
		||||
 | 
			
		||||
        # now try one with vendor code instead of upc
 | 
			
		||||
        row = model.VendorCatalogBatchRow(vendor_code='123456')
 | 
			
		||||
        batch.data_rows.append(row)
 | 
			
		||||
        result = self.handler.identify_product(row)
 | 
			
		||||
        self.assertIs(result, product)
 | 
			
		||||
 | 
			
		||||
        session.rollback()
 | 
			
		||||
        session.close()
 | 
			
		||||
 | 
			
		||||
    def test_refresh_row(self):
 | 
			
		||||
        engine = sa.create_engine('sqlite://')
 | 
			
		||||
        model = self.config.get_model()
 | 
			
		||||
        model.Base.metadata.create_all(bind=engine)
 | 
			
		||||
        session = Session(bind=engine)
 | 
			
		||||
        app = self.config.get_app()
 | 
			
		||||
 | 
			
		||||
        # make a test user, vendor, product
 | 
			
		||||
        user = model.User(username='ralph')
 | 
			
		||||
        session.add(user)
 | 
			
		||||
        vendor = model.Vendor()
 | 
			
		||||
        session.add(vendor)
 | 
			
		||||
        product = model.Product(upc=GPC('074305001321'))
 | 
			
		||||
        session.add(product)
 | 
			
		||||
 | 
			
		||||
        # also a batch to contain the rows
 | 
			
		||||
        batch = model.VendorCatalogBatch(uuid=app.make_uuid(),
 | 
			
		||||
                                         id=1, created_by=user,
 | 
			
		||||
                                         vendor=vendor,
 | 
			
		||||
                                         filename='sample.xlsx',
 | 
			
		||||
                                         parser_key='rattail.contrib.generic')
 | 
			
		||||
        session.add(batch)
 | 
			
		||||
 | 
			
		||||
        # empty row is just marked as product not found
 | 
			
		||||
        row = model.VendorCatalogBatchRow()
 | 
			
		||||
        batch.data_rows.append(row)
 | 
			
		||||
        self.handler.refresh_row(row)
 | 
			
		||||
        self.assertEqual(row.status_code, row.STATUS_PRODUCT_NOT_FOUND)
 | 
			
		||||
 | 
			
		||||
        # row with upc is matched with product; also make sure unit
 | 
			
		||||
        # cost is calculated from case cost
 | 
			
		||||
        row = model.VendorCatalogBatchRow(upc=GPC('074305001321'),
 | 
			
		||||
                                          case_size=12,
 | 
			
		||||
                                          case_cost=decimal.Decimal('58.00'))
 | 
			
		||||
        batch.data_rows.append(row)
 | 
			
		||||
        self.handler.refresh_row(row)
 | 
			
		||||
        self.assertIs(row.product, product)
 | 
			
		||||
        self.assertEqual(row.status_code, row.STATUS_NEW_COST)
 | 
			
		||||
        self.assertEqual(row.case_cost, 58)
 | 
			
		||||
        self.assertEqual(row.case_size, 12)
 | 
			
		||||
        self.assertEqual(row.unit_cost, decimal.Decimal('4.8333'))
 | 
			
		||||
 | 
			
		||||
        # now we add a cost to the master product, and make sure new
 | 
			
		||||
        # row will reflect an update for that cost
 | 
			
		||||
        cost = model.ProductCost(vendor=vendor, 
 | 
			
		||||
                                 case_size=12,
 | 
			
		||||
                                 case_cost=decimal.Decimal('54.00'),
 | 
			
		||||
                                 unit_cost=decimal.Decimal('4.50'))
 | 
			
		||||
        product.costs.append(cost)
 | 
			
		||||
        row = model.VendorCatalogBatchRow(upc=GPC('074305001321'),
 | 
			
		||||
                                          case_size=12,
 | 
			
		||||
                                          case_cost=decimal.Decimal('58.00'))
 | 
			
		||||
        batch.data_rows.append(row)
 | 
			
		||||
        self.handler.refresh_row(row)
 | 
			
		||||
        self.assertIs(row.product, product)
 | 
			
		||||
        self.assertEqual(row.status_code, row.STATUS_CHANGE_COST)
 | 
			
		||||
        self.assertEqual(row.old_case_cost, 54)
 | 
			
		||||
        self.assertEqual(row.case_cost, 58)
 | 
			
		||||
        self.assertEqual(row.old_unit_cost, decimal.Decimal('4.50'))
 | 
			
		||||
        self.assertEqual(row.unit_cost, decimal.Decimal('4.8333'))
 | 
			
		||||
 | 
			
		||||
        # and finally let's refresh everything, note that row #2
 | 
			
		||||
        # should now *also* get "change cost" status
 | 
			
		||||
        row = batch.data_rows[1]
 | 
			
		||||
        self.assertEqual(row.status_code, row.STATUS_NEW_COST)
 | 
			
		||||
        self.handler.setup_refresh(batch)
 | 
			
		||||
        for row in batch.data_rows:
 | 
			
		||||
            self.handler.refresh_row(row)
 | 
			
		||||
        self.assertEqual(row.status_code, row.STATUS_CHANGE_COST)
 | 
			
		||||
 | 
			
		||||
        session.rollback()
 | 
			
		||||
        session.close()
 | 
			
		||||
							
								
								
									
										40
									
								
								tests/test_excel.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								tests/test_excel.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,40 @@
 | 
			
		|||
# -*- coding: utf-8; -*-
 | 
			
		||||
 | 
			
		||||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
import os
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
import openpyxl
 | 
			
		||||
 | 
			
		||||
from rattail import excel as mod
 | 
			
		||||
from rattail.config import make_config
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestExcelReaderXLSX(TestCase):
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.config = self.make_config()
 | 
			
		||||
 | 
			
		||||
    def make_config(self):
 | 
			
		||||
        return make_config([], extend=False)
 | 
			
		||||
 | 
			
		||||
    def test_strip_fieldnames(self):
 | 
			
		||||
        app = self.config.get_app()
 | 
			
		||||
        path = app.make_temp_file(suffix='.xlsx')
 | 
			
		||||
 | 
			
		||||
        # first make a workbook which has whitespace in column headers
 | 
			
		||||
        workbook = openpyxl.Workbook()
 | 
			
		||||
        sheet = workbook.active
 | 
			
		||||
        sheet.append([' first ', 'second   '])
 | 
			
		||||
        workbook.save(path)
 | 
			
		||||
 | 
			
		||||
        # reader should strip fieldnames by default
 | 
			
		||||
        reader = mod.ExcelReaderXLSX(path)
 | 
			
		||||
        self.assertEqual(reader.fields, ['first', 'second'])
 | 
			
		||||
 | 
			
		||||
        # unless we say not to strip them
 | 
			
		||||
        reader = mod.ExcelReaderXLSX(path, strip_fieldnames=False)
 | 
			
		||||
        self.assertEqual(reader.fields, [' first ', 'second   '])
 | 
			
		||||
 | 
			
		||||
        os.remove(path)
 | 
			
		||||
							
								
								
									
										43
									
								
								tests/test_products.py
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								tests/test_products.py
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,43 @@
 | 
			
		|||
# -*- coding: utf-8; -*-
 | 
			
		||||
 | 
			
		||||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
import six
 | 
			
		||||
 | 
			
		||||
from rattail import products as mod
 | 
			
		||||
from rattail.config import make_config
 | 
			
		||||
from rattail.gpc import GPC
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestProductsHandler(TestCase):
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.config = self.make_config()
 | 
			
		||||
        self.handler = self.make_handler()
 | 
			
		||||
 | 
			
		||||
    def make_config(self):
 | 
			
		||||
        return make_config([], extend=False)
 | 
			
		||||
 | 
			
		||||
    def make_handler(self):
 | 
			
		||||
        return mod.ProductsHandler(self.config)
 | 
			
		||||
 | 
			
		||||
    def test_make_gpc(self):
 | 
			
		||||
 | 
			
		||||
        # basic real-world example
 | 
			
		||||
        result = self.handler.make_gpc('74305001321')
 | 
			
		||||
        self.assertIsInstance(result, GPC)
 | 
			
		||||
        self.assertEqual(six.text_type(result), '00074305001321')
 | 
			
		||||
 | 
			
		||||
        # and let it calculate check digit
 | 
			
		||||
        result = self.handler.make_gpc('7430500132', calc_check_digit='upc')
 | 
			
		||||
        self.assertIsInstance(result, GPC)
 | 
			
		||||
        self.assertEqual(six.text_type(result), '00074305001321')
 | 
			
		||||
 | 
			
		||||
        # bad one should raise error
 | 
			
		||||
        self.assertRaises(ValueError, self.handler.make_gpc, 'BAD_VALUE')
 | 
			
		||||
 | 
			
		||||
        # unless we suppress errors
 | 
			
		||||
        result = self.handler.make_gpc('BAD_VALUE', ignore_errors=True)
 | 
			
		||||
        self.assertIsNone(result)
 | 
			
		||||
							
								
								
									
										0
									
								
								tests/vendors/__init__.py
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/vendors/__init__.py
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
								
								
									
										33
									
								
								tests/vendors/test_catalogs.py
									
										
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								tests/vendors/test_catalogs.py
									
										
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,33 @@
 | 
			
		|||
# -*- coding: utf-8; -*-
 | 
			
		||||
 | 
			
		||||
from __future__ import unicode_literals, absolute_import
 | 
			
		||||
 | 
			
		||||
from unittest import TestCase
 | 
			
		||||
 | 
			
		||||
from rattail.vendors import catalogs as mod
 | 
			
		||||
from rattail.config import make_config
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestCatalogParser(TestCase):
 | 
			
		||||
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.config = self.make_config()
 | 
			
		||||
        self.parser = self.make_parser()
 | 
			
		||||
 | 
			
		||||
    def make_config(self):
 | 
			
		||||
        return make_config([], extend=False)
 | 
			
		||||
 | 
			
		||||
    def make_parser(self):
 | 
			
		||||
        return mod.CatalogParser(self.config)
 | 
			
		||||
 | 
			
		||||
    def test_key_required(self):
 | 
			
		||||
 | 
			
		||||
        # someone must define the parser key
 | 
			
		||||
        self.assertRaises(NotImplementedError, getattr, self.parser, 'key')
 | 
			
		||||
 | 
			
		||||
    def test_make_row(self):
 | 
			
		||||
        model = self.config.get_model()
 | 
			
		||||
 | 
			
		||||
        # make a basic row, it should work
 | 
			
		||||
        row = self.parser.make_row()
 | 
			
		||||
        self.assertIsInstance(row, model.VendorCatalogBatchRow)
 | 
			
		||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue