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
|
@ -20,6 +20,7 @@ attributes and method signatures etc.
|
||||||
rattail/batch/pricing
|
rattail/batch/pricing
|
||||||
rattail/batch/product
|
rattail/batch/product
|
||||||
rattail/batch/purchase
|
rattail/batch/purchase
|
||||||
|
rattail/batch/vendorcatalog
|
||||||
rattail/board
|
rattail/board
|
||||||
rattail/bouncer/index
|
rattail/bouncer/index
|
||||||
rattail/clientele
|
rattail/clientele
|
||||||
|
@ -33,6 +34,7 @@ attributes and method signatures etc.
|
||||||
rattail/db/index
|
rattail/db/index
|
||||||
rattail/employment
|
rattail/employment
|
||||||
rattail/enum
|
rattail/enum
|
||||||
|
rattail/excel
|
||||||
rattail/exceptions
|
rattail/exceptions
|
||||||
rattail/features/index
|
rattail/features/index
|
||||||
rattail/filemon/index
|
rattail/filemon/index
|
||||||
|
@ -62,4 +64,5 @@ attributes and method signatures etc.
|
||||||
rattail/trainwreck/index
|
rattail/trainwreck/index
|
||||||
rattail/upgrades
|
rattail/upgrades
|
||||||
rattail/util
|
rattail/util
|
||||||
|
rattail/vendors.catalogs
|
||||||
rattail/win32
|
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
|
# The theme to use for HTML and HTML Help pages. See the documentation for
|
||||||
# a list of builtin themes.
|
# 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
|
# 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
|
# 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.files import temp_path
|
||||||
from rattail.mail import send_email
|
from rattail.mail import send_email
|
||||||
from rattail.config import parse_list
|
from rattail.config import parse_list
|
||||||
|
from rattail.core import get_uuid
|
||||||
|
|
||||||
|
|
||||||
class AppHandler(object):
|
class AppHandler(object):
|
||||||
|
@ -763,6 +764,14 @@ class AppHandler(object):
|
||||||
kwargs['dir'] = tmpdir
|
kwargs['dir'] = tmpdir
|
||||||
return temp_path(**kwargs)
|
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):
|
def normalize_phone_number(self, number, **kwargs):
|
||||||
"""
|
"""
|
||||||
Normalize the given phone number, to a "common" format that
|
Normalize the given phone number, to a "common" format that
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2021 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -26,6 +26,8 @@ Handler for Vendor Catalog batches
|
||||||
|
|
||||||
from __future__ import unicode_literals, absolute_import
|
from __future__ import unicode_literals, absolute_import
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
|
|
||||||
from rattail.db import model
|
from rattail.db import model
|
||||||
|
@ -88,22 +90,43 @@ class VendorCatalogHandler(BatchHandler):
|
||||||
|
|
||||||
def populate_from_file(self, batch, progress=None):
|
def populate_from_file(self, batch, progress=None):
|
||||||
"""
|
"""
|
||||||
Pre-fill batch with row data from an input data file, leveraging a
|
Populate the given batch using data from its input file. A
|
||||||
specific catalog parser.
|
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)
|
session = orm.object_session(batch)
|
||||||
path = batch.filepath(self.config)
|
path = batch.filepath(self.config)
|
||||||
parser = require_catalog_parser(batch.parser_key)
|
parser = require_catalog_parser(batch.parser_key)
|
||||||
# TODO: should add `config` kwarg to CatalogParser constructor
|
# TODO: should add `config` kwarg to CatalogParser constructor
|
||||||
parser.config = self.config
|
parser.config = self.config
|
||||||
|
parser.app = self.app
|
||||||
|
parser.model = self.model
|
||||||
|
parser.enum = self.enum
|
||||||
parser.session = session
|
parser.session = session
|
||||||
parser.vendor = batch.vendor
|
parser.vendor = batch.vendor
|
||||||
batch.effective = parser.parse_effective_date(path)
|
batch.effective = parser.parse_effective_date(path)
|
||||||
|
|
||||||
def append(row, i):
|
def append(row, i):
|
||||||
self.add_row(batch, row)
|
self.add_row(batch, row)
|
||||||
if i % 1000 == 0:
|
if i % 1000 == 0: # pragma: no cover
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
data = list(parser.parse_rows(path, progress=progress))
|
data = list(parser.parse_rows(path, progress=progress))
|
||||||
|
@ -111,35 +134,95 @@ class VendorCatalogHandler(BatchHandler):
|
||||||
message="Adding initial rows to batch")
|
message="Adding initial rows to batch")
|
||||||
|
|
||||||
def identify_product(self, row):
|
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
|
product = None
|
||||||
|
|
||||||
if row.upc:
|
if row.upc:
|
||||||
|
if hasattr(self, 'products'):
|
||||||
product = self.products['upc'].get(row.upc)
|
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:
|
if not product and row.vendor_code:
|
||||||
|
if hasattr(self, 'products'):
|
||||||
product = self.products['vendor_code'].get(row.vendor_code)
|
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
|
return product
|
||||||
|
|
||||||
def refresh_row(self, row):
|
def refresh_row(self, row):
|
||||||
"""
|
"""
|
||||||
Inspect a single row from a catalog, and set its attributes based on
|
Refresh data attributes and status for the given row.
|
||||||
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
|
For a vendor catalog, the typical thing is done for basic
|
||||||
product lookup is done first by UPC and then by vendor item code.
|
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
|
# clear this first in case it's set
|
||||||
row.status_text = None
|
row.status_text = None
|
||||||
|
|
||||||
|
if not row.product:
|
||||||
row.product = self.identify_product(row)
|
row.product = self.identify_product(row)
|
||||||
if not row.product:
|
if not row.product:
|
||||||
row.status_code = row.STATUS_PRODUCT_NOT_FOUND
|
row.status_code = row.STATUS_PRODUCT_NOT_FOUND
|
||||||
return
|
return
|
||||||
|
|
||||||
|
product = row.product
|
||||||
|
|
||||||
row.upc = row.product.upc
|
row.upc = row.product.upc
|
||||||
row.item_id = row.product.item_id
|
row.item_id = row.product.item_id
|
||||||
row.brand_name = row.product.brand.name if row.product.brand else None
|
row.brand_name = row.product.brand.name if row.product.brand else None
|
||||||
row.description = row.product.description
|
row.description = row.product.description
|
||||||
row.size = row.product.size
|
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:
|
if not old_cost:
|
||||||
row.status_code = row.STATUS_NEW_COST
|
row.status_code = row.STATUS_NEW_COST
|
||||||
return
|
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():
|
if not upc or not six.text_type(upc).strip():
|
||||||
continue # skip lines with no UPC value
|
continue # skip lines with no UPC value
|
||||||
upc = str(upc).replace(' ', '').replace('-', '')
|
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')
|
row.upc = GPC(upc, calc_check_digit=False if len(upc) in (12, 13) else 'upc')
|
||||||
|
|
||||||
# cost values (required in some combination)
|
# cost values (required in some combination)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2021 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -111,10 +111,23 @@ class ExcelReaderXLSX(object):
|
||||||
"""
|
"""
|
||||||
Basic class for reading Excel 2010 (.xslx) files.
|
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.
|
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
|
:param header_row: Which row contains the column headers. This is
|
||||||
1-based, so the 1 is the default (i.e. the first row).
|
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.sheet = self.book.active
|
||||||
|
|
||||||
self.header_row = header_row
|
self.header_row = header_row
|
||||||
|
@ -137,6 +154,9 @@ class ExcelReaderXLSX(object):
|
||||||
assert self.fields is None
|
assert self.fields is None
|
||||||
self.fields = list(row)
|
self.fields = list(row)
|
||||||
|
|
||||||
|
if strip_fieldnames:
|
||||||
|
self.fields = [field.strip() for field in self.fields]
|
||||||
|
|
||||||
def iter_rows(self):
|
def iter_rows(self):
|
||||||
return self.sheet.iter_rows(min_row=self.header_row + 1,
|
return self.sheet.iter_rows(min_row=self.header_row + 1,
|
||||||
values_only=True)
|
values_only=True)
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
# Rattail -- Retail Software Framework
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2021 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -47,12 +47,26 @@ class ProductsHandler(GenericHandler):
|
||||||
particular product can be deleted, etc.
|
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`
|
Try to convert the given value to a GPC, and return the
|
||||||
instance, and return the result.
|
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``.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
return GPC(value, **kwargs)
|
return GPC(value, **kwargs)
|
||||||
|
except:
|
||||||
|
if not ignore_errors:
|
||||||
|
raise
|
||||||
|
|
||||||
def make_full_description(self, product=None,
|
def make_full_description(self, product=None,
|
||||||
brand_name=None, description=None, size=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
|
# Rattail -- Retail Software Framework
|
||||||
# Copyright © 2010-2018 Lance Edgar
|
# Copyright © 2010-2022 Lance Edgar
|
||||||
#
|
#
|
||||||
# This file is part of Rattail.
|
# This file is part of Rattail.
|
||||||
#
|
#
|
||||||
|
@ -38,7 +38,15 @@ class CatalogParser(object):
|
||||||
"""
|
"""
|
||||||
Base class for all vendor catalog parsers.
|
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
|
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'``.
|
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
|
The value of this setting must be an exact match to either a
|
||||||
|
@ -49,7 +57,12 @@ class CatalogParser(object):
|
||||||
"""
|
"""
|
||||||
vendor_key = None
|
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
|
@property
|
||||||
def key(self):
|
def key(self):
|
||||||
|
@ -69,6 +82,19 @@ class CatalogParser(object):
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError("Catalog parser has no `parse_rows()` method: {0}".format(repr(self.key)))
|
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):
|
def decimal(self, value, scale=4):
|
||||||
"""
|
"""
|
||||||
Convert a value to a decimal, unless it's ``None``.
|
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…
Reference in a new issue