400 lines
15 KiB
Python
400 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2016 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 Affero 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 Affero General Public License for
|
|
# more details.
|
|
#
|
|
# You should have received a copy of the GNU Affero General Public License
|
|
# along with Rattail. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
################################################################################
|
|
"""
|
|
Views for purchase order batches
|
|
"""
|
|
|
|
from __future__ import unicode_literals, absolute_import
|
|
|
|
from rattail.db import model, api
|
|
from rattail.gpc import GPC
|
|
from rattail.db.batch.purchase.handler import PurchaseBatchHandler
|
|
from rattail.time import localtime
|
|
from rattail.core import Object
|
|
|
|
import formalchemy as fa
|
|
|
|
from tailbone import forms
|
|
from tailbone.db import Session
|
|
from tailbone.views.batch import BatchMasterView
|
|
|
|
|
|
class PurchaseBatchView(BatchMasterView):
|
|
"""
|
|
Master view for purchase order batches.
|
|
"""
|
|
model_class = model.PurchaseBatch
|
|
model_title_plural = "Purchase Batches"
|
|
model_row_class = model.PurchaseBatchRow
|
|
batch_handler_class = PurchaseBatchHandler
|
|
route_prefix = 'purchases.batch'
|
|
url_prefix = '/purchases/batches'
|
|
rows_creatable = True
|
|
rows_editable = True
|
|
edit_with_rows = False
|
|
|
|
def _preconfigure_grid(self, g):
|
|
super(PurchaseBatchView, self)._preconfigure_grid(g)
|
|
|
|
g.joiners['vendor'] = lambda q: q.join(model.Vendor)
|
|
g.filters['vendor'] = g.make_filter('vendor', model.Vendor.name,
|
|
default_active=True, default_verb='contains')
|
|
g.sorters['vendor'] = g.make_sorter(model.Vendor.name)
|
|
|
|
g.joiners['buyer'] = lambda q: q.join(model.Employee).join(model.Person)
|
|
g.filters['buyer'] = g.make_filter('buyer', model.Person.display_name,
|
|
default_active=True, default_verb='contains')
|
|
g.sorters['buyer'] = g.make_sorter(model.Person.display_name)
|
|
|
|
if self.request.has_perm('purchases.batch.execute'):
|
|
g.filters['complete'].default_active = True
|
|
g.filters['complete'].default_verb = 'is_true'
|
|
|
|
g.date_ordered.set(label="Ordered")
|
|
|
|
def configure_grid(self, g):
|
|
g.configure(
|
|
include=[
|
|
g.id,
|
|
g.vendor,
|
|
g.buyer,
|
|
g.date_ordered,
|
|
g.created,
|
|
g.created_by,
|
|
g.executed,
|
|
],
|
|
readonly=True)
|
|
|
|
def _preconfigure_fieldset(self, fs):
|
|
super(PurchaseBatchView, self)._preconfigure_fieldset(fs)
|
|
fs.vendor.set(renderer=forms.renderers.VendorFieldRenderer)
|
|
fs.buyer.set(renderer=forms.renderers.EmployeeFieldRenderer)
|
|
fs.po_number.set(label="PO Number")
|
|
fs.po_total.set(label="PO Total", readonly=True)
|
|
|
|
def configure_fieldset(self, fs):
|
|
fs.configure(
|
|
include=[
|
|
fs.id,
|
|
fs.store,
|
|
fs.vendor,
|
|
fs.buyer,
|
|
fs.date_ordered,
|
|
fs.po_number,
|
|
fs.po_total,
|
|
fs.created,
|
|
fs.created_by,
|
|
fs.complete,
|
|
fs.executed,
|
|
fs.executed_by,
|
|
])
|
|
|
|
if self.creating:
|
|
del fs.po_total
|
|
del fs.complete
|
|
|
|
# default store may be configured
|
|
store = self.rattail_config.get('rattail', 'store')
|
|
if store:
|
|
store = api.get_store(Session(), store)
|
|
if store:
|
|
fs.model.store = store
|
|
|
|
# default buyer is current user
|
|
if self.request.method != 'POST':
|
|
buyer = self.request.user.employee
|
|
if buyer:
|
|
fs.model.buyer = buyer
|
|
|
|
# default order date is today
|
|
fs.model.date_ordered = localtime(self.rattail_config).date()
|
|
|
|
elif self.editing:
|
|
fs.store.set(readonly=True)
|
|
fs.vendor.set(readonly=True)
|
|
|
|
def template_kwargs_view(self, **kwargs):
|
|
kwargs = super(PurchaseBatchView, self).template_kwargs_view(**kwargs)
|
|
vendor = kwargs['batch'].vendor
|
|
kwargs['vendor_cost_count'] = Session.query(model.ProductCost)\
|
|
.filter(model.ProductCost.vendor == vendor)\
|
|
.count()
|
|
kwargs['vendor_cost_threshold'] = self.rattail_config.getint(
|
|
'tailbone', 'purchases.order_form.vendor_cost_warning_threshold', default=699)
|
|
return kwargs
|
|
|
|
def _preconfigure_row_grid(self, g):
|
|
super(PurchaseBatchView, self)._preconfigure_row_grid(g)
|
|
|
|
g.filters['upc'].label = "UPC"
|
|
g.filters['brand_name'].label = "Brand"
|
|
|
|
g.upc.set(label="UPC")
|
|
g.brand_name.set(label="Brand")
|
|
g.cases_ordered.set(label="Cases")
|
|
g.units_ordered.set(label="Units")
|
|
g.po_total.set(label="Total")
|
|
|
|
def configure_row_grid(self, g):
|
|
g.configure(
|
|
include=[
|
|
g.sequence,
|
|
g.upc,
|
|
g.brand_name,
|
|
g.description,
|
|
g.size,
|
|
g.cases_ordered,
|
|
g.units_ordered,
|
|
g.po_total,
|
|
g.status_code,
|
|
],
|
|
readonly=True)
|
|
|
|
def make_row_grid_tools(self, batch):
|
|
return self.make_default_row_grid_tools(batch)
|
|
|
|
# def row_grid_row_attrs(self, row, i):
|
|
# attrs = {}
|
|
# if row.status_code in (row.STATUS_NOT_IN_PURCHASE,
|
|
# row.STATUS_NOT_IN_INVOICE,
|
|
# row.STATUS_DIFFERS_FROM_PURCHASE):
|
|
# attrs['class_'] = 'notice'
|
|
# if row.status_code in (row.STATUS_NOT_IN_DB,
|
|
# row.STATUS_COST_NOT_IN_DB,
|
|
# row.STATUS_NO_CASE_QUANTITY):
|
|
# attrs['class_'] = 'warning'
|
|
# return attrs
|
|
|
|
def _preconfigure_row_fieldset(self, fs):
|
|
super(PurchaseBatchView, self)._preconfigure_row_fieldset(fs)
|
|
fs.upc.set(label="UPC")
|
|
fs.brand_name.set(label="Brand")
|
|
fs.po_unit_cost.set(label="PO Unit Cost")
|
|
fs.po_total.set(label="PO Total")
|
|
fs.append(fa.Field('item_lookup', label="Item Lookup Code", required=True,
|
|
validate=self.item_lookup))
|
|
|
|
def item_lookup(self, value, field=None):
|
|
"""
|
|
Try to locate a single product using ``value`` as a lookup code.
|
|
"""
|
|
batch = self.get_instance()
|
|
product = api.get_product_by_vendor_code(Session(), value, vendor=batch.vendor)
|
|
if product:
|
|
return product.uuid
|
|
if value.isdigit():
|
|
product = api.get_product_by_upc(Session(), GPC(value))
|
|
if not product:
|
|
product = api.get_product_by_upc(Session(), GPC(value, calc_check_digit='upc'))
|
|
if product:
|
|
if not product.cost_for_vendor(batch.vendor):
|
|
raise fa.ValidationError("Product {} exists but has no cost for vendor {}".format(
|
|
product.upc.pretty(), batch.vendor))
|
|
return product.uuid
|
|
raise fa.ValidationError("Product not found")
|
|
|
|
def configure_row_fieldset(self, fs):
|
|
|
|
if self.creating:
|
|
fs.configure(
|
|
include=[
|
|
fs.item_lookup,
|
|
fs.cases_ordered,
|
|
fs.units_ordered,
|
|
])
|
|
|
|
elif self.editing:
|
|
fs.configure(
|
|
include=[
|
|
fs.upc.readonly(),
|
|
fs.product.readonly(),
|
|
fs.cases_ordered,
|
|
fs.units_ordered,
|
|
])
|
|
|
|
def before_create_row(self, form):
|
|
row = form.fieldset.model
|
|
batch = self.get_instance()
|
|
row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1
|
|
row.batch = batch
|
|
# TODO: this seems heavy-handed but works..
|
|
row.product_uuid = self.item_lookup(form.fieldset.item_lookup.value)
|
|
|
|
def after_create_row(self, row):
|
|
self.handler.refresh_row(row)
|
|
|
|
def redirect_after_create_row(self, row):
|
|
self.request.session.flash("Added item: {} {}".format(row.upc.pretty(), row.product))
|
|
return self.redirect(self.request.current_route_url())
|
|
|
|
def delete_row(self):
|
|
"""
|
|
Update the PO total in addition to marking row as removed.
|
|
"""
|
|
row = self.Session.query(self.model_row_class).get(self.request.matchdict['uuid'])
|
|
if not row:
|
|
raise httpexceptions.HTTPNotFound()
|
|
if row.po_total:
|
|
row.batch.po_total -= row.po_total
|
|
row.removed = True
|
|
return self.redirect(self.get_action_url('view', row.batch))
|
|
|
|
# TODO: redirect to new purchase...
|
|
# def get_execute_success_url(self, batch, result, **kwargs):
|
|
# # return self.get_action_url('view', batch)
|
|
# return
|
|
|
|
def order_form(self):
|
|
"""
|
|
View for editing a purchase batch as an order form.
|
|
"""
|
|
batch = self.get_instance()
|
|
if batch.executed:
|
|
return self.redirect(self.get_action_url('view', batch))
|
|
vendor = batch.vendor
|
|
costs = Session.query(model.ProductCost)\
|
|
.join(model.Product)\
|
|
.outerjoin(model.Brand)\
|
|
.filter(model.ProductCost.vendor == vendor)\
|
|
.order_by(model.Brand.name,
|
|
model.Product.description,
|
|
model.Product.size)
|
|
|
|
# organize existing batch rows by product
|
|
order_items = {}
|
|
for row in batch.data_rows:
|
|
if not row.removed:
|
|
order_items[row.product_uuid] = row
|
|
|
|
# organize product costs by dept / subdept
|
|
departments = {}
|
|
for cost in costs:
|
|
|
|
department = cost.product.department
|
|
if department:
|
|
departments.setdefault(department.uuid, department)
|
|
else:
|
|
if None not in departments:
|
|
department = Object()
|
|
departments[None] = department
|
|
department = departments[None]
|
|
|
|
subdepartments = getattr(department, '_order_subdepartments', None)
|
|
if subdepartments is None:
|
|
subdepartments = department._order_subdepartments = {}
|
|
|
|
subdepartment = cost.product.subdepartment
|
|
if subdepartment:
|
|
subdepartments.setdefault(subdepartment.uuid, subdepartment)
|
|
else:
|
|
if None not in subdepartments:
|
|
subdepartment = Object()
|
|
subdepartments[None] = subdepartment
|
|
subdepartment = subdepartments[None]
|
|
|
|
subdept_costs = getattr(subdepartment, '_order_costs', None)
|
|
if subdept_costs is None:
|
|
subdept_costs = subdepartment._order_costs = []
|
|
subdept_costs.append(cost)
|
|
cost._batchrow = order_items.get(cost.product_uuid)
|
|
|
|
title = self.get_instance_title(batch)
|
|
return self.render_to_response('order_form', {
|
|
'batch': batch,
|
|
'instance': batch,
|
|
'instance_title': title,
|
|
'index_title': "{}: {}".format(self.get_model_title(), title),
|
|
'index_url': self.get_action_url('view', batch),
|
|
'vendor': vendor,
|
|
'departments': departments,
|
|
'get_upc': lambda p: p.upc.pretty() if p.upc else '',
|
|
})
|
|
|
|
def order_form_update(self):
|
|
"""
|
|
Handles AJAX requests to update current batch, from Order Form view.
|
|
"""
|
|
batch = self.get_instance()
|
|
|
|
quantity = self.request.POST.get('cases_ordered')
|
|
if not quantity or not quantity.isdigit():
|
|
return {'error': "Invalid quantity: {}".format(quantity)}
|
|
quantity = int(quantity)
|
|
|
|
uuid = self.request.POST.get('product_uuid')
|
|
product = Session.query(model.Product).get(uuid) if uuid else None
|
|
if not product:
|
|
return {'error': "Product not found"}
|
|
|
|
rows = [row for row in batch.data_rows if row.product_uuid == uuid]
|
|
if rows:
|
|
assert len(rows) == 1
|
|
row = rows[0]
|
|
if row.po_total and not row.removed:
|
|
batch.po_total -= row.po_total
|
|
if quantity:
|
|
row.cases_ordered = quantity
|
|
row.removed = False
|
|
self.handler.refresh_row(row)
|
|
else:
|
|
row.removed = True
|
|
|
|
elif quantity:
|
|
row = model.PurchaseBatchRow()
|
|
row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1
|
|
row.product = product
|
|
batch.data_rows.append(row)
|
|
row.cases_ordered = quantity
|
|
self.handler.refresh_row(row)
|
|
|
|
return {
|
|
'row_cases_ordered': '' if row.removed else int(row.cases_ordered),
|
|
'row_po_total': '' if row.removed else '${:0,.2f}'.format(row.po_total),
|
|
'batch_po_total': '${:0,.2f}'.format(batch.po_total),
|
|
}
|
|
|
|
@classmethod
|
|
def defaults(cls, config):
|
|
route_prefix = cls.get_route_prefix()
|
|
url_prefix = cls.get_url_prefix()
|
|
permission_prefix = cls.get_permission_prefix()
|
|
model_key = cls.get_model_key()
|
|
model_title = cls.get_model_title()
|
|
|
|
cls._batch_defaults(config)
|
|
cls._defaults(config)
|
|
|
|
# order form
|
|
config.add_tailbone_permission(permission_prefix, '{}.order_form'.format(permission_prefix),
|
|
"Edit new {} in Order Form mode".format(model_title))
|
|
config.add_route('{}.order_form'.format(route_prefix), '{}/{{{}}}/order-form'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='order_form', route_name='{}.order_form'.format(route_prefix),
|
|
permission='{}.order_form'.format(permission_prefix))
|
|
config.add_route('{}.order_form_update'.format(route_prefix), '{}/{{{}}}/order-form/update'.format(url_prefix, model_key))
|
|
config.add_view(cls, attr='order_form_update', route_name='{}.order_form_update'.format(route_prefix),
|
|
renderer='json', permission='{}.order_form'.format(permission_prefix))
|
|
|
|
|
|
def includeme(config):
|
|
PurchaseBatchView.defaults(config)
|