From bbfffd45fcbf681f36b4e055ca6f6f59f8b0bd3c Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Wed, 29 Sep 2021 17:27:20 -0400 Subject: [PATCH] Initial (basic) views for invoice costing batches still a bit of feature preview at the moment, but maybe is mostly done? --- tailbone/views/purchasing/__init__.py | 3 +- tailbone/views/purchasing/batch.py | 42 ++- tailbone/views/purchasing/costing.py | 346 +++++++++++++++++++++++++ tailbone/views/purchasing/receiving.py | 41 +-- 4 files changed, 393 insertions(+), 39 deletions(-) create mode 100644 tailbone/views/purchasing/costing.py diff --git a/tailbone/views/purchasing/__init__.py b/tailbone/views/purchasing/__init__.py index 8f80b456..09d62909 100644 --- a/tailbone/views/purchasing/__init__.py +++ b/tailbone/views/purchasing/__init__.py @@ -2,7 +2,7 @@ ################################################################################ # # Rattail -- Retail Software Framework -# Copyright © 2010-2017 Lance Edgar +# Copyright © 2010-2021 Lance Edgar # # This file is part of Rattail. # @@ -32,3 +32,4 @@ from .batch import PurchasingBatchView def includeme(config): config.include('tailbone.views.purchasing.ordering') config.include('tailbone.views.purchasing.receiving') + config.include('tailbone.views.purchasing.costing') diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index 1ca8c21c..1d42f08d 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -30,6 +30,7 @@ import six from rattail.db import model, api from rattail.time import localtime +from rattail.vendors.invoices import iter_invoice_parsers import colander from deform import widget as dfwidget @@ -220,6 +221,7 @@ class PurchasingBatchView(BatchMasterView): super(PurchasingBatchView, self).configure_form(f) batch = f.model_instance today = localtime(self.rattail_config).date() + use_buefy = self.get_use_buefy() # mode f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE) @@ -313,6 +315,25 @@ class PurchasingBatchView(BatchMasterView): field_display=buyer_display, service_url=buyers_url)) f.set_label('buyer_uuid', "Buyer") + # invoice_file + if self.creating: + f.set_type('invoice_file', 'file', required=False) + else: + f.set_readonly('invoice_file') + f.set_renderer('invoice_file', self.render_downloadable_file) + + # invoice_parser_key + if self.creating: + parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) + parser_values = [(p.key, p.display) for p in parsers] + parser_values.insert(0, ('', "(please choose)")) + if use_buefy: + f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) + else: + f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) + else: + f.remove_field('invoice_parser_key') + # date_ordered f.set_type('date_ordered', 'date_jquery') if self.creating: @@ -582,8 +603,11 @@ class PurchasingBatchView(BatchMasterView): g.set_type('po_total_calculated', 'currency') g.set_type('credits', 'boolean') - # we only want the grid column to have abbreviated label, but *not* the filter + # we only want the grid columns to have abbreviated labels, + # but *not* the filters # TODO: would be nice to somehow make this simpler + g.set_label('department_name', "Department") + g.filters['department_name'].label = "Department Name" g.set_label('cases_ordered', "Cases Ord.") g.filters['cases_ordered'].label = "Cases Ordered" g.set_label('units_ordered', "Units Ord.") @@ -597,6 +621,16 @@ class PurchasingBatchView(BatchMasterView): g.set_label('units_received', "Units Rec.") g.filters['units_received'].label = "Units Received" + # catalog_unit_cost + g.set_renderer('catalog_unit_cost', self.render_row_grid_cost) + g.set_label('catalog_unit_cost', "Catalog Cost") + g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" + + # invoice_unit_cost + g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) + g.set_label('invoice_unit_cost', "Invoice Cost") + g.filters['invoice_unit_cost'].label = "Invoice Unit Cost" + # invoice_total g.set_type('invoice_total', 'currency') g.set_label('invoice_total', "Total") @@ -608,6 +642,12 @@ class PurchasingBatchView(BatchMasterView): g.set_label('po_total', "Total") g.set_label('credits', "Credits?") + def render_row_grid_cost(self, row, field): + cost = getattr(row, field) + if cost is None: + return "" + return "{:0,.3f}".format(cost) + def make_row_grid_tools(self, batch): return self.make_default_row_grid_tools(batch) diff --git a/tailbone/views/purchasing/costing.py b/tailbone/views/purchasing/costing.py new file mode 100644 index 00000000..0f07d77d --- /dev/null +++ b/tailbone/views/purchasing/costing.py @@ -0,0 +1,346 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# Rattail -- Retail Software Framework +# Copyright © 2010-2021 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 . +# +################################################################################ +""" +Views for 'costing' (purchasing) batches +""" + +from __future__ import unicode_literals, absolute_import + +import six + +import colander +from deform import widget as dfwidget + +from tailbone import forms +from tailbone.views.purchasing import PurchasingBatchView + + +class CostingBatchView(PurchasingBatchView): + """ + Master view for costing batches + """ + route_prefix = 'invoice_costing' + url_prefix = '/invoice-costing' + model_title = "Invoice Costing Batch" + model_title_plural = "Invoice Costing Batches" + index_title = "Invoice Costing" + downloadable = True + bulk_deletable = True + + purchase_order_fieldname = 'purchase' + + labels = { + 'invoice_parser_key': "Invoice Parser", + } + + grid_columns = [ + 'id', + 'vendor', + 'description', + 'department', + 'buyer', + 'date_ordered', + 'created', + 'created_by', + 'rowcount', + 'invoice_total', + 'status_code', + 'executed', + ] + + form_fields = [ + 'id', + 'store', + 'buyer', + 'vendor', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'department', + 'purchase', + 'vendor_email', + 'vendor_fax', + 'vendor_contact', + 'vendor_phone', + 'date_ordered', + 'date_received', + 'po_number', + 'po_total', + 'invoice_date', + 'invoice_number', + 'invoice_total', + 'invoice_total_calculated', + 'notes', + 'created', + 'created_by', + 'status_code', + 'complete', + 'executed', + 'executed_by', + ] + + row_grid_columns = [ + 'sequence', + 'upc', + # 'item_id', + 'vendor_code', + 'brand_name', + 'description', + 'size', + 'department_name', + 'cases_shipped', + 'units_shipped', + 'cases_received', + 'units_received', + 'catalog_unit_cost', + 'invoice_unit_cost', + # 'invoice_total_calculated', + 'invoice_total', + 'status_code', + ] + + @property + def batch_mode(self): + return self.enum.PURCHASE_BATCH_MODE_COSTING + + def create(self, form=None, **kwargs): + """ + Custom view for creating a new costing batch. We split the + process into two steps, 1) choose workflow and 2) create + batch. This is because the specific form details for creating + a batch will depend on which "type" of batch creation is to be + done, and it's much easier to keep conditional logic for that + in the server instead of client-side etc. + + See also + :meth:`tailbone.views.purchasing.receiving:ReceivingBatchView.create()` + which uses similar logic. + """ + route_prefix = self.get_route_prefix() + workflows = self.handler.supported_costing_workflows() + valid_workflows = [workflow['workflow_key'] + for workflow in workflows] + + # if user has already identified their desired workflow, then we can + # just farm out to the default logic. we will of course configure our + # form differently, based on workflow, but this create() method at + # least will not need customization for that. + if self.request.matched_route.name.endswith('create_workflow'): + + # however we do have one more thing to check - the workflow + # requested must of course be valid! + workflow_key = self.request.matchdict['workflow_key'] + if workflow_key not in valid_workflows: + self.request.session.flash( + "Not a supported workflow: {}".format(workflow_key), + 'error') + raise self.redirect(self.request.route_url('{}.create'.format(route_prefix))) + + # okay now do the normal thing, per workflow + return super(CostingBatchView, self).create(**kwargs) + + # okay, at this point we need the user to select a vendor and workflow + self.creating = True + use_buefy = self.get_use_buefy() + model = self.model + context = {} + + # form to accept user choice of vendor/workflow + schema = NewCostingBatch().bind(valid_workflows=valid_workflows) + form = forms.Form(schema=schema, request=self.request, + use_buefy=use_buefy) + if len(valid_workflows) == 1: + form.set_default('workflow', valid_workflows[0]) + + # configure vendor field + use_autocomplete = self.rattail_config.getbool( + 'rattail', 'vendor.use_autocomplete', default=True) + if use_autocomplete: + vendor_display = "" + if self.request.method == 'POST': + if self.request.POST.get('vendor'): + vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor']) + if vendor: + vendor_display = six.text_type(vendor) + vendors_url = self.request.route_url('vendors.autocomplete') + form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget( + field_display=vendor_display, service_url=vendors_url)) + else: + vendors = self.Session.query(model.Vendor)\ + .order_by(model.Vendor.id) + vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name)) + for vendor in vendors] + if use_buefy: + form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values)) + else: + form.set_widget('vendor', forms.widgets.JQuerySelectWidget(values=vendor_values)) + + # configure workflow field + values = [(workflow['workflow_key'], workflow['display']) + for workflow in workflows] + if use_buefy: + form.set_widget('workflow', + dfwidget.SelectWidget(values=values)) + else: + form.set_widget('workflow', + forms.widgets.JQuerySelectWidget(values=values)) + + form.submit_label = "Continue" + form.cancel_url = self.get_index_url() + + # if form validates, that means user has chosen a creation type, so we + # just redirect to the appropriate "new batch of type X" page + if form.validate(newstyle=True): + workflow_key = form.validated['workflow'] + vendor_uuid = form.validated['vendor'] + url = self.request.route_url('{}.create_workflow'.format(route_prefix), + workflow_key=workflow_key, + vendor_uuid=vendor_uuid) + raise self.redirect(url) + + context['form'] = form + if hasattr(form, 'make_deform_form'): + context['dform'] = form.make_deform_form() + return self.render_to_response('create', context) + + def configure_form(self, f): + super(CostingBatchView, self).configure_form(f) + route_prefix = self.get_route_prefix() + use_buefy = self.get_use_buefy() + model = self.model + workflow = self.request.matchdict.get('workflow_key') + + if self.creating: + f.set_fields([ + 'vendor_uuid', + 'costing_workflow', + 'invoice_file', + 'invoice_parser_key', + 'purchase', + ]) + f.set_required('invoice_file') + + # tweak some things if we are in "step 2" of creating new batch + if self.creating and workflow: + + # display vendor but do not allow changing + vendor = self.Session.query(model.Vendor).get( + self.request.matchdict['vendor_uuid']) + assert vendor + + f.set_hidden('vendor_uuid') + f.set_default('vendor_uuid', vendor.uuid) + f.set_widget('vendor_uuid', dfwidget.HiddenWidget()) + + f.insert_after('vendor_uuid', 'vendor_name') + f.set_readonly('vendor_name') + f.set_default('vendor_name', vendor.name) + f.set_label('vendor_name', "Vendor") + + # cancel should take us back to choosing a workflow + f.cancel_url = self.request.route_url('{}.create'.format(route_prefix)) + + # costing_workflow + if self.creating and workflow: + f.set_readonly('costing_workflow') + f.set_renderer('costing_workflow', self.render_costing_workflow) + else: + f.remove('costing_workflow') + + # batch_type + if self.creating: + f.set_widget('batch_type', dfwidget.HiddenWidget()) + f.set_default('batch_type', workflow) + f.set_hidden('batch_type') + else: + f.remove_field('batch_type') + + # purchase + if (self.creating and workflow == 'invoice_with_po' + and self.purchase_order_fieldname == 'purchase'): + if use_buefy: + f.replace('purchase', 'purchase_uuid') + purchases = self.handler.get_eligible_purchases( + vendor, self.enum.PURCHASE_BATCH_MODE_COSTING) + values = [(p.uuid, self.handler.render_eligible_purchase(p)) + for p in purchases] + f.set_widget('purchase_uuid', dfwidget.SelectWidget(values=values)) + f.set_label('purchase_uuid', "Purchase Order") + f.set_required('purchase_uuid') + + def render_costing_workflow(self, batch, field): + key = self.request.matchdict['workflow_key'] + info = self.handler.costing_workflow_info(key) + if info: + return info['display'] + + @classmethod + def defaults(cls, config): + cls._costing_defaults(config) + cls._purchasing_defaults(config) + cls._batch_defaults(config) + cls._defaults(config) + + @classmethod + def _costing_defaults(cls, config): + route_prefix = cls.get_route_prefix() + url_prefix = cls.get_url_prefix() + permission_prefix = cls.get_permission_prefix() + + # new costing batch using workflow X + config.add_route('{}.create_workflow'.format(route_prefix), + '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix)) + config.add_view(cls, attr='create', + route_name='{}.create_workflow'.format(route_prefix), + permission='{}.create'.format(permission_prefix)) + + +@colander.deferred +def valid_workflow(node, kw): + """ + Deferred validator for ``workflow`` field, for new batches. + """ + valid_workflows = kw['valid_workflows'] + + def validate(node, value): + # we just need to provide possible values, and let stock + # validator handle the rest + oneof = colander.OneOf(valid_workflows) + return oneof(node, value) + + return validate + + +class NewCostingBatch(colander.Schema): + """ + Schema for choosing which "type" of new receiving batch should be created. + """ + vendor = colander.SchemaNode(colander.String(), + label="Vendor") + + workflow = colander.SchemaNode(colander.String(), + validator=valid_workflow) + + +def includeme(config): + CostingBatchView.defaults(config) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 0d5606b1..0af4afe7 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -38,7 +38,6 @@ from rattail import pod from rattail.db import model, Session as RattailSession from rattail.time import localtime, make_utc from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error -from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser from rattail.threads import Thread import colander @@ -210,6 +209,10 @@ class ReceivingBatchView(PurchasingBatchView): form details for creating a batch will depend on which "type" of batch creation is to be done, and it's much easier to keep conditional logic for that in the server instead of client-side etc. + + See also + :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()` + which uses similar logic. """ route_prefix = self.get_route_prefix() workflows = self.handler.supported_receiving_workflows() @@ -440,25 +443,6 @@ class ReceivingBatchView(PurchasingBatchView): 'truck_dump_status', 'truck_dump_batch') - # invoice_file - if self.creating: - f.set_type('invoice_file', 'file', required=False) - else: - f.set_readonly('invoice_file') - f.set_renderer('invoice_file', self.render_downloadable_file) - - # invoice_parser_key - if self.creating: - parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display) - parser_values = [(p.key, p.display) for p in parsers] - parser_values.insert(0, ('', "(please choose)")) - if use_buefy: - f.set_widget('invoice_parser_key', dfwidget.SelectWidget(values=parser_values)) - else: - f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values)) - else: - f.remove_field('invoice_parser_key') - # store if self.creating: store = self.rattail_config.get_store(self.Session()) @@ -737,22 +721,11 @@ class ReceivingBatchView(PurchasingBatchView): def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) - g.set_label('department_name', "Department") # vendor_code g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_verb = 'contains' - # catalog_unit_cost - g.set_renderer('catalog_unit_cost', self.render_row_grid_cost) - g.set_label('catalog_unit_cost', "Catalog Cost") - g.filters['catalog_unit_cost'].label = "Catalog Unit Cost" - - # invoice_unit_cost - g.set_renderer('invoice_unit_cost', self.render_row_grid_cost) - g.set_label('invoice_unit_cost', "Invoice Cost") - g.filters['invoice_unit_cost'].label = "Invoice Unit Cost" - # credits # note that sorting by credits involves a subquery with group by clause. # seems likely there may be a better way? but this seems to work fine @@ -800,12 +773,6 @@ class ReceivingBatchView(PurchasingBatchView): return css_class - def render_row_grid_cost(self, row, field): - cost = getattr(row, field) - if cost is None: - return "" - return "{:0,.3f}".format(cost) - def transform_unit_url(self, row, i): # grid action is shown only when we return a URL here if self.row_editable(row):