Initial (basic) views for invoice costing batches

still a bit of feature preview at the moment, but maybe is mostly done?
This commit is contained in:
Lance Edgar 2021-09-29 17:27:20 -04:00
parent ed705ff867
commit bbfffd45fc
4 changed files with 393 additions and 39 deletions

View file

@ -2,7 +2,7 @@
################################################################################ ################################################################################
# #
# Rattail -- Retail Software Framework # Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar # Copyright © 2010-2021 Lance Edgar
# #
# This file is part of Rattail. # This file is part of Rattail.
# #
@ -32,3 +32,4 @@ from .batch import PurchasingBatchView
def includeme(config): def includeme(config):
config.include('tailbone.views.purchasing.ordering') config.include('tailbone.views.purchasing.ordering')
config.include('tailbone.views.purchasing.receiving') config.include('tailbone.views.purchasing.receiving')
config.include('tailbone.views.purchasing.costing')

View file

@ -30,6 +30,7 @@ import six
from rattail.db import model, api from rattail.db import model, api
from rattail.time import localtime from rattail.time import localtime
from rattail.vendors.invoices import iter_invoice_parsers
import colander import colander
from deform import widget as dfwidget from deform import widget as dfwidget
@ -220,6 +221,7 @@ class PurchasingBatchView(BatchMasterView):
super(PurchasingBatchView, self).configure_form(f) super(PurchasingBatchView, self).configure_form(f)
batch = f.model_instance batch = f.model_instance
today = localtime(self.rattail_config).date() today = localtime(self.rattail_config).date()
use_buefy = self.get_use_buefy()
# mode # mode
f.set_enum('mode', self.enum.PURCHASE_BATCH_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)) field_display=buyer_display, service_url=buyers_url))
f.set_label('buyer_uuid', "Buyer") 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 # date_ordered
f.set_type('date_ordered', 'date_jquery') f.set_type('date_ordered', 'date_jquery')
if self.creating: if self.creating:
@ -582,8 +603,11 @@ class PurchasingBatchView(BatchMasterView):
g.set_type('po_total_calculated', 'currency') g.set_type('po_total_calculated', 'currency')
g.set_type('credits', 'boolean') 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 # 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.set_label('cases_ordered', "Cases Ord.")
g.filters['cases_ordered'].label = "Cases Ordered" g.filters['cases_ordered'].label = "Cases Ordered"
g.set_label('units_ordered', "Units Ord.") g.set_label('units_ordered', "Units Ord.")
@ -597,6 +621,16 @@ class PurchasingBatchView(BatchMasterView):
g.set_label('units_received', "Units Rec.") g.set_label('units_received', "Units Rec.")
g.filters['units_received'].label = "Units Received" 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 # invoice_total
g.set_type('invoice_total', 'currency') g.set_type('invoice_total', 'currency')
g.set_label('invoice_total', "Total") g.set_label('invoice_total', "Total")
@ -608,6 +642,12 @@ class PurchasingBatchView(BatchMasterView):
g.set_label('po_total', "Total") g.set_label('po_total', "Total")
g.set_label('credits', "Credits?") 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): def make_row_grid_tools(self, batch):
return self.make_default_row_grid_tools(batch) return self.make_default_row_grid_tools(batch)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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)

View file

@ -38,7 +38,6 @@ from rattail import pod
from rattail.db import model, Session as RattailSession from rattail.db import model, Session as RattailSession
from rattail.time import localtime, make_utc from rattail.time import localtime, make_utc
from rattail.util import pretty_quantity, prettify, OrderedDict, simple_error 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 from rattail.threads import Thread
import colander import colander
@ -210,6 +209,10 @@ class ReceivingBatchView(PurchasingBatchView):
form details for creating a batch will depend on which "type" of batch 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 creation is to be done, and it's much easier to keep conditional logic
for that in the server instead of client-side etc. 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() route_prefix = self.get_route_prefix()
workflows = self.handler.supported_receiving_workflows() workflows = self.handler.supported_receiving_workflows()
@ -440,25 +443,6 @@ class ReceivingBatchView(PurchasingBatchView):
'truck_dump_status', 'truck_dump_status',
'truck_dump_batch') '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 # store
if self.creating: if self.creating:
store = self.rattail_config.get_store(self.Session()) store = self.rattail_config.get_store(self.Session())
@ -737,22 +721,11 @@ class ReceivingBatchView(PurchasingBatchView):
def configure_row_grid(self, g): def configure_row_grid(self, g):
super(ReceivingBatchView, self).configure_row_grid(g) super(ReceivingBatchView, self).configure_row_grid(g)
g.set_label('department_name', "Department")
# vendor_code # vendor_code
g.filters['vendor_code'].default_active = True g.filters['vendor_code'].default_active = True
g.filters['vendor_code'].default_verb = 'contains' 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 # credits
# note that sorting by credits involves a subquery with group by clause. # 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 # seems likely there may be a better way? but this seems to work fine
@ -800,12 +773,6 @@ class ReceivingBatchView(PurchasingBatchView):
return css_class 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): def transform_unit_url(self, row, i):
# grid action is shown only when we return a URL here # grid action is shown only when we return a URL here
if self.row_editable(row): if self.row_editable(row):