diff --git a/docs/api/index.rst b/docs/api/index.rst index 6da7af0a18665a44367c2f4fa81d278749457699..44da5dee7305d1bb99bfb203557ecd0a60fcc495 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -74,4 +74,5 @@ attributes and method signatures etc. rattail/util rattail/vendors.catalogs rattail/vendors.handler + rattail/vendors.orders rattail/win32 diff --git a/docs/api/rattail/vendors.orders.rst b/docs/api/rattail/vendors.orders.rst new file mode 100644 index 0000000000000000000000000000000000000000..37ad05adf76e15f77e0e09e4773128898ce637f0 --- /dev/null +++ b/docs/api/rattail/vendors.orders.rst @@ -0,0 +1,6 @@ + +``rattail.vendors.orders`` +========================== + +.. automodule:: rattail.vendors.orders + :members: diff --git a/pyproject.toml b/pyproject.toml index 260adfd405179297c9496d79ea39d427a48ef96a..55a88789572f94f18caf9447ccce29c8c9f70abb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -142,6 +142,10 @@ rattail = "rattail.sil.columns:provide_columns" "rattail.contrib.unfi" = "rattail.contrib.vendors.invoices.unfi:UnfiInvoiceParser" +[project.entry-points."rattail.vendors.orders.parsers"] +"default" = "rattail.vendors.orders:DefaultOrderParser" + + [project.urls] Homepage = "https://rattailproject.org" Repository = "https://forgejo.wuttaproject.org/rattail/rattail" diff --git a/rattail/batch/purchase.py b/rattail/batch/purchase.py index 81a193ac682fe6a462bb22f54dd0e36dc1d0fe32..fa41fd950d363cb39a66bcce40b5514a2d91f95d 100644 --- a/rattail/batch/purchase.py +++ b/rattail/batch/purchase.py @@ -85,6 +85,30 @@ class PurchaseBatchHandler(BatchHandler): return self.config.get_bool('rattail.batch.purchase.allow_decimal_quantities', default=False) + def allow_ordering_any_vendor(self): + """ + Return boolean indicating whether ordering from "any" vendor + is allowed, vs. only supported vendors. + """ + return self.config.get_bool('rattail.batch.purchase.allow_ordering_any_vendor', + default=True) + + def allow_ordering_from_scratch(self): + """ + Return boolean indicating whether ordering "from scratch" is + allowed. + """ + return self.config.get_bool('rattail.batch.purchase.allow_ordering_from_scratch', + default=True) + + def allow_ordering_from_file(self): + """ + Return boolean indicating whether ordering "from file" is + allowed. + """ + return self.config.get_bool('rattail.batch.purchase.allow_ordering_from_file', + default=True) + def allow_receiving_from_scratch(self, **kwargs): """ Return boolean indicating whether receiving "from scratch" is allowed. @@ -144,6 +168,64 @@ class PurchaseBatchHandler(BatchHandler): 'purchase.receiving.auto_missing_credits', default=False) + def possible_ordering_workflows(self, **kwargs): + """ + Returns a list representing all "ordering workflows" which are + *possible* when making a new batch. Note that possible != + supported. See :meth:`supported_ordering_workflows()` for the + latter. + + Each element of the list will be a dict with 3 items; keys of which + are: ``'workflow_key'``, ``'display'`` and ``'description'``. + + Maybe these "creation type key" strings should be defined + within the ``enum`` module, but for now they're sort of just + magical hard-coded values I guess: + + * ``'from_scratch'`` + * ``'from_file'`` + """ + return [ + { + 'workflow_key': 'from_scratch', + 'display': "From Scratch", + 'description': "Create a new empty batch and start scanning.", + }, + { + 'workflow_key': 'from_file', + 'display': "From File", + 'description': "Upload a single file representing the order.", + }, + ] + + def supported_ordering_workflows(self, **kwargs): + """ + Returns a list representing which "creation types" are + *supported* by the app, when making a new ordering batch. + Elements of this list will be of the same type as returned by + :meth:`possible_ordering_workflows()`. + """ + possible = self.possible_ordering_workflows() + possible = dict([(item['workflow_key'], item) + for item in possible]) + + workflows = [] + if self.allow_ordering_from_scratch(): + workflows.append(possible['from_scratch']) + if self.allow_ordering_from_file(): + workflows.append(possible['from_file']) + return workflows + + def ordering_workflow_info(self, workflow_key): + """ + Returns the info dict for the given "creation type" key. The + dict will be one of those as returned by + :meth:`possible_ordering_workflows()`. + """ + for workflow in self.possible_ordering_workflows(): + if workflow['workflow_key'] == workflow_key: + return workflow + def possible_receiving_workflows(self, **kwargs): """ Returns a list representing all "receiving workflows" which are @@ -330,7 +412,7 @@ class PurchaseBatchHandler(BatchHandler): # remove some kwargs which are not meant for the primary batch # constructor; they will be dealt with within init_batch() - kwargs.pop('receiving_workflow', None) + kwargs.pop('workflow', None) kwargs.pop('purchase_key', None) # continue with usual logic @@ -347,9 +429,9 @@ class PurchaseBatchHandler(BatchHandler): self.assign_purchase_order(batch, purchase_key, session=kwargs.get('session')) - receiving_workflow = kwargs.get('receiving_workflow') - if receiving_workflow: - batch.set_param('receiving_workflow', receiving_workflow) + workflow = kwargs.get('workflow') + if workflow: + batch.set_param('workflow', workflow) def set_input_file(self, batch, path, attr=None, **kwargs): """ @@ -358,7 +440,7 @@ class PurchaseBatchHandler(BatchHandler): """ # special logic for receiving from multiple invoice files if (batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING - and batch.get_param('receiving_workflow') == 'from_multi_invoice' + and batch.get_param('workflow') == 'from_multi_invoice' and attr == 'invoice_files'): # store file as normal, but instead of setting filename @@ -406,16 +488,20 @@ class PurchaseBatchHandler(BatchHandler): Must populate when e.g. making new receiving batch from PO or invoice, but otherwise not, e.g. receiving from scratch. """ - if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + if batch.order_file: + return True + + elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: - workflow = batch.get_param('receiving_workflow') + workflow = batch.get_param('workflow') if workflow in ('from_invoice', 'from_multi_invoice', 'from_po', 'from_po_with_invoice'): return True - if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING: + elif batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING: if self.has_purchase_order(batch): return True if self.has_invoice_file(batch): @@ -524,7 +610,13 @@ class PurchaseBatchHandler(BatchHandler): If the batch is a "truck dump child" and does not yet have a receiving date, it is given the same one as the parent batch. """ - if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: + if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING: + + if batch.order_file: + self.populate_from_order_file(batch, progress=progress) + return + + elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING: if batch.is_truck_dump_parent(): pass # TODO? @@ -547,7 +639,7 @@ class PurchaseBatchHandler(BatchHandler): if not batch.date_received: batch.date_received = self.app.today() - workflow = batch.get_param('receiving_workflow') + workflow = batch.get_param('workflow') if workflow == 'from_invoice': self.populate_from_invoice(batch, progress=progress) @@ -565,7 +657,7 @@ class PurchaseBatchHandler(BatchHandler): self.populate_from_invoice_with_po(batch, progress=progress) return - if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING: + elif batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING: if self.has_purchase_order(batch) and self.has_invoice_file(batch): self.populate_from_invoice_with_po(batch, progress=progress) @@ -644,6 +736,60 @@ class PurchaseBatchHandler(BatchHandler): parser.vendor_key, batch.vendor)) return vendor + def populate_from_order_file(self, batch, progress=None): + """ + Populate an ordering batch from file. + """ + if not batch.order_file: + raise ValueError(f"batch does not have an order file: {batch}") + + vend = self.app.get_vendor_handler() + parser = vend.get_order_parser(batch.order_parser_key, require=True) + path = batch.filepath(self.config, batch.order_file) + + try: + batch.date_ordered = parser.parse_order_date(path) + except: + log.warning("failed to parse order date from file: %s", path, + exc_info=True) + + try: + batch.po_number = parser.parse_order_number(path) + except: + log.warning("failed to parse order number from file: %s", path, + exc_info=True) + + def append(item, i): + row = self.make_row_from_order_parser(batch, item) + self.add_row(batch, row) + + try: + lines = list(parser.parse_order_items(path)) + except Exception as error: + log.warning("failed to parse invoice lines from file: %s", path, + exc_info=True) + error = simple_error(error) + raise RuntimeError(f"Failed to parse invoice: {error}") + + self.app.progress_loop(append, lines, progress, + message="Adding initial rows to batch") + + # set overall totals + total = None + try: + total = parser.parse_order_total(path) + except: + pass + if total is None: + total = sum([row.po_total or 0 + for row in batch.active_rows()]) + batch.po_total = total + batch.po_total_calculated = sum([row.po_total_calculated or 0 + for row in batch.active_rows()]) + + # set overall status + self.refresh_batch_status(batch) + def populate_from_invoice(self, batch, progress=None): """ Populate a batch from vendor invoice file. @@ -1065,6 +1211,42 @@ class PurchaseBatchHandler(BatchHandler): if rows: return rows[0] + def make_row_from_order_parser(self, batch, item): + """ + Create a new batch row, from the given order item, as returned + by an order file parser. + """ + row = self.make_row() + row.po_line_number = item.sequence + + product = item.product + row.product = product + if product: + row.upc = product.upc + row.item_id = product.item_id + else: + row.upc = item.upc + row.item_id = item.item_id + row.brand_name = item.brand_name + row.description = item.description + row.size = item.size + row.department_name = item.department_name + row.vendor_code = item.vendor_code + row.case_quantity = item.case_quantity + + row.cases_ordered = item.cases_ordered + row.units_ordered = item.units_ordered + row.po_case_size = item.po_case_size + row.po_unit_cost = item.po_unit_cost + row.po_total = item.po_total + + # calculated PO total + case_size = row.po_case_size or row.case_quantity or 1 + units = self.get_units_ordered(row, case_quantity=case_size) + row.po_total_calculated = units * (row.po_unit_cost or 0) + + return row + def make_row_from_po_item(self, batch, item): """ Create a new batch row, from the given ``po_item`` object, diff --git a/rattail/db/alembic/versions/6f0659414074_add_purchasebatch_order_file.py b/rattail/db/alembic/versions/6f0659414074_add_purchasebatch_order_file.py new file mode 100644 index 0000000000000000000000000000000000000000..0c2ed437a6afe1702a8c0fdd66d825882415ff1f --- /dev/null +++ b/rattail/db/alembic/versions/6f0659414074_add_purchasebatch_order_file.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8; -*- +"""add PurchaseBatch.order_file + +Revision ID: 6f0659414074 +Revises: b9d58e803208 +Create Date: 2024-10-21 14:53:02.779348 + +""" + +# revision identifiers, used by Alembic. +revision = '6f0659414074' +down_revision = 'b9d58e803208' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +import rattail.db.types + + + +def upgrade(): + + # batch_purchase + op.add_column('batch_purchase', sa.Column('order_file', sa.String(length=255), nullable=True)) + op.add_column('batch_purchase', sa.Column('order_parser_key', sa.String(length=100), nullable=True)) + + +def downgrade(): + + # batch_purchase + op.drop_column('batch_purchase', 'order_parser_key') + op.drop_column('batch_purchase', 'order_file') diff --git a/rattail/db/model/batch/purchase.py b/rattail/db/model/batch/purchase.py index 18165872d4dfbbef18028889f7ebb39d85667584..285dd704dbf92f12eae2bbd0259e24e47c4742e1 100644 --- a/rattail/db/model/batch/purchase.py +++ b/rattail/db/model/batch/purchase.py @@ -88,6 +88,15 @@ class PurchaseBatch(BatchMixin, PurchaseBase, Base): Numeric "mode" for the purchase batch, to indicate new/receiving etc. """) + order_file = filename_column(nullable=True, doc=""" + Base name for the associated order file, if any. + """) + + order_parser_key = sa.Column(sa.String(length=100), nullable=True, doc=""" + The key of the parser used to read the contents of the order file, + if applicable. + """) + invoice_file = filename_column(doc="Base name for the associated invoice file, if any.") invoice_parser_key = sa.Column(sa.String(length=100), nullable=True, doc=""" diff --git a/rattail/vendors/handler.py b/rattail/vendors/handler.py index e81aa30668818437422a4ae14ba3b2b212881c1a..a4dd29bd7ef6f028039fd78ef3424cc45869213b 100644 --- a/rattail/vendors/handler.py +++ b/rattail/vendors/handler.py @@ -162,3 +162,85 @@ class VendorHandler(GenericHandler): if require: raise CatalogParserNotFound(key) + + def get_all_order_parsers(self): + """ + Should return *all* order parsers known to exist. + + Note that this returns classes and not instances. + + See also :meth:`get_supported_order_parsers()` and + :meth:`get_order_parser()`. + + :returns: List of :class:`~rattail.vendors.orders.OrderParser` + classes. + """ + Parsers = list( + load_entry_points('rattail.vendors.orders.parsers').values()) + Parsers.sort(key=lambda Parser: Parser.title) + return Parsers + + def get_supported_order_parsers(self, vendor=None, **kwargs): + """ + Should return a list of order file parsers which are + *supported* and should be exposed to the user. + + See also :meth:`get_all_order_parsers()` and + :meth:`get_order_parser()`. + + :param vendor: Vendor for whom the parser is needed, if known. + If you specify a vendor then parsers which indicate support + for a *different* vendor will be excluded from the list. + + :returns: List of :class:`~rattail.vendors.orders.OrderParser` + classes. + """ + all_parsers = self.get_all_order_parsers() + + supported = self.config.get_list('rattail.vendors.supported_order_parsers') + if supported: + parsers = [Parser for Parser in all_parsers + if Parser.key in supported] + else: + parsers = all_parsers + + result = [] + if vendor: + session = self.app.get_session(vendor) + for Parser in parsers: + parser = Parser(self.config) + if parser.vendor_key: + # parser declares a vendor, so only add if it's a match + pvendor = parser.get_vendor(session) + if pvendor and pvendor is vendor: + result.append(parser) + else: # parser is vendor-neutral; always add + result.append(parser) + + else: # no vendor specified, so show all parsers + result = parsers + + return result + + def get_order_parser(self, key, require=False): + """ + Retrieve the order file parser for the given key. + + Note that this returns an instance, not the class. + + See also :meth:`get_all_order_parsers()` and + :meth:`get_supported_order_parsers()`. + + :param key: Unique key indicating which parser to get. + + :returns: An :class:`~rattail.vendors.orders.OrderParser` + instance. + """ + from rattail.vendors.orders import OrderParserNotFound + + for Parser in self.get_all_order_parsers(): + if Parser.key == key: + return Parser(self.config) + + if require: + raise OrderParserNotFound(key) diff --git a/rattail/vendors/orders.py b/rattail/vendors/orders.py new file mode 100644 index 0000000000000000000000000000000000000000..6f4c99f32011a27d5a71a9acc899be5335145650 --- /dev/null +++ b/rattail/vendors/orders.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2024 Lance Edgar +# +# This file is part of Rattail. +# +# Rattail is free software: you can redistribute it and/or modify it under the +# terms of the GNU General Public License as published by the Free Software +# Foundation, either version 3 of the License, or (at your option) any later +# version. +# +# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +# details. +# +# You should have received a copy of the GNU General Public License along with +# Rattail. If not, see . +# +################################################################################ +""" +Vendor Order Files +""" + +import decimal + +from rattail.exceptions import RattailError +from rattail.excel import ExcelReaderXLSX + + +class OrderParserNotFound(RattailError): + """ + Exception raised when an order file parser is required, but cannot + be located. + """ + + def __init__(self, key): + self.key = key + + def __str__(self): + return f"Vendor order parser not found for key: {self.key}" + + +class OrderParser: + """ + Base class for all vendor order parsers. + """ + vendor_key = None + + def __init__(self, config): + self.config = config + self.app = self.config.get_app() + + @property + def key(self): + """ + Key for the parser. Must be unique among all order parsers. + """ + raise NotImplementedError(f"Order parser has no key: {repr(self)}") + + @property + def title(self): + """ + Human-friendly title for the parser. + """ + raise NotImplementedError(f"Order parser has no title: {self.key}") + + def get_vendor(self, session): + """ + Fetch the :class:`~rattail.db.model.vendors.Vendor` record + which is associated with the current parser, if any. + """ + if self.vendor_key: + return self.app.get_vendor_handler().get_vendor(session, self.vendor_key) + + def parse_order_date(self, path): + """ + Parse the order date from the order file. + """ + + def parse_order_number(self, path): + """ + Parse the order number from the order file. + """ + + def parse_order_items(self, path, progress=None): + """ + Parse all data items (rows) from the order file. + """ + raise NotImplementedError + + def make_order_item(self, **kwargs): + """ + Make and return a + :class:`~rattail.db.model.purchase.PurchaseItem` instance. + """ + model = self.app.model + return model.PurchaseItem(**kwargs) + + +class ExcelOrderParser(OrderParser): + """ + Base class for Excel vendor order parsers. + """ + + def get_excel_reader(self, path): + """ + Return an :class:`~rattail.excel.ExcelReaderXLSX` instance for + the given path. + """ + if not hasattr(self, 'excel_reader'): + kwargs = self.get_excel_reader_kwargs() + self.excel_reader = ExcelReaderXLSX(path, **kwargs) + return self.excel_reader + + def get_excel_reader_kwargs(self, **kwargs): + """ + Should return kwargs for the Excel reader factory. + """ + return kwargs + + def decimal(self, value, scale=2): + """ + Convert a value to a decimal. + """ + if value is None: + return + + # no reason to convert integers, really + if isinstance(value, (int, decimal.Decimal)): + return value + + # float becomes rounded decimal + if isinstance(value, float): + return decimal.Decimal(f"{{:0.{scale}f}}".format(value)) + + # string becomes decimal + value = value.strip() + if value: + return decimal.Decimal(value) + + +class DefaultOrderParser(ExcelOrderParser): + """ + Default order parser for Excel files. + + .. autoattribute:: key + + .. autoattribute:: title + """ + key = 'default' + title = "Default Excel Parser" + + # TODO: needs sane default parser logic