# -*- coding: utf-8; -*- ################################################################################ # # Rattail -- Retail Software Framework # Copyright © 2010-2018 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 'receiving' (purchasing) batches """ from __future__ import unicode_literals, absolute_import import re import logging import six import sqlalchemy as sa from rattail import pod from rattail.db import model, api from rattail.db.util import maxlen from rattail.gpc import GPC from rattail.time import localtime from rattail.util import pretty_quantity, prettify, OrderedDict from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser import colander from deform import widget as dfwidget from pyramid import httpexceptions from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView log = logging.getLogger(__name__) class MobileItemStatusFilter(grids.filters.MobileFilter): value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all'] def filter_equal(self, query, value): # NOTE: this is only relevant for truck dump or "from scratch" if value == 'received': return query.filter(sa.or_( model.PurchaseBatchRow.cases_received != 0, model.PurchaseBatchRow.units_received != 0)) # TODO: is this accurate (enough) ? if value == 'incomplete': return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ .filter(~model.PurchaseBatchRow.status_code.in_(( model.PurchaseBatchRow.STATUS_OK, model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND))) if value == 'invalid': return query.filter(model.PurchaseBatchRow.status_code.in_(( model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, model.PurchaseBatchRow.STATUS_COST_NOT_FOUND, model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN, model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS, ))) if value == 'unexpected': return query.filter(sa.and_( sa.or_( model.PurchaseBatchRow.cases_ordered == None, model.PurchaseBatchRow.cases_ordered == 0), sa.or_( model.PurchaseBatchRow.units_ordered == None, model.PurchaseBatchRow.units_ordered == 0))) if value == 'damaged': return query.filter(sa.or_( model.PurchaseBatchRow.cases_damaged != 0, model.PurchaseBatchRow.units_damaged != 0)) if value == 'expired': return query.filter(sa.or_( model.PurchaseBatchRow.cases_expired != 0, model.PurchaseBatchRow.units_expired != 0)) return query def iter_choices(self): for value in self.value_choices: yield value, prettify(value) class ReceivingBatchView(PurchasingBatchView): """ Master view for receiving batches """ route_prefix = 'receiving' url_prefix = '/receiving' model_title = "Receiving Batch" model_title_plural = "Receiving Batches" index_title = "Receiving" downloadable = True rows_editable = True mobile_creatable = True mobile_rows_filterable = True mobile_rows_creatable = True mobile_rows_quickable = True mobile_rows_deletable = True allow_from_po = False allow_from_scratch = True allow_truck_dump = False default_uom_is_case = True purchase_order_fieldname = 'purchase' labels = { 'truck_dump_batch': "Truck Dump Parent", 'invoice_parser_key': "Invoice Parser", } grid_columns = [ 'id', 'vendor', 'truck_dump', 'department', 'buyer', 'date_ordered', 'created', 'created_by', 'rowcount', 'status_code', 'executed', ] form_fields = [ 'id', 'batch_type', 'description', 'store', 'vendor', 'truck_dump', 'truck_dump_children', 'truck_dump_batch', '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', 'notes', 'created', 'created_by', 'status_code', 'rowcount', 'order_quantities_known', 'complete', 'executed', 'executed_by', ] mobile_form_fields = [ 'vendor', 'truck_dump', 'department', ] row_grid_columns = [ 'sequence', 'upc', # 'item_id', 'brand_name', 'description', 'size', 'department_name', 'cases_ordered', 'units_ordered', 'cases_received', 'units_received', # 'po_total', 'invoice_total', 'credits', 'status_code', ] row_form_fields = [ 'upc', 'item_id', 'product', 'brand_name', 'description', 'size', 'case_quantity', 'cases_ordered', 'units_ordered', 'cases_received', 'units_received', 'cases_damaged', 'units_damaged', 'cases_expired', 'units_expired', 'cases_mispick', 'units_mispick', 'po_line_number', 'po_unit_cost', 'po_total', 'invoice_line_number', 'invoice_unit_cost', 'invoice_total', 'status_code', 'credits', ] @property def batch_mode(self): return self.enum.PURCHASE_BATCH_MODE_RECEIVING def row_editable(self, row): batch = row.batch if batch.truck_dump_batch: return False return True def row_deletable(self, row): batch = row.batch if batch.truck_dump: return True return False def get_instance_title(self, batch): title = super(ReceivingBatchView, self).get_instance_title(batch) if batch.truck_dump: title = "{} (TRUCK DUMP PARENT)".format(title) elif batch.truck_dump_batch: title = "{} (TRUCK DUMP CHILD)".format(title) return title def configure_form(self, f): super(ReceivingBatchView, self).configure_form(f) batch = f.model_instance # batch_type if self.creating: f.set_enum('batch_type', OrderedDict([ ('from_scratch', "New from Scratch"), ])) else: f.remove_field('batch_type') # truck_dump* if self.allow_truck_dump: # truck_dump if self.creating: f.remove_field('truck_dump') elif batch.truck_dump_batch: f.remove_field('truck_dump') else: f.set_readonly('truck_dump') # truck_dump_children if self.viewing: if batch.truck_dump: f.set_renderer('truck_dump_children', self.render_truck_dump_children) else: f.remove_field('truck_dump_children') else: f.remove_field('truck_dump_children') # truck_dump_batch if self.creating: f.replace('truck_dump_batch', 'truck_dump_batch_uuid') batches = self.Session.query(model.PurchaseBatch)\ .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ .filter(model.PurchaseBatch.truck_dump == True)\ .filter(model.PurchaseBatch.complete == True)\ .filter(model.PurchaseBatch.executed == None)\ .order_by(model.PurchaseBatch.id) batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor)) for b in batches] batch_values.insert(0, ('', "(please choose)")) f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values)) f.set_label('truck_dump_batch_uuid', "Truck Dump Parent") elif batch.truck_dump: f.remove_field('truck_dump_batch') elif batch.truck_dump_batch: f.set_readonly('truck_dump_batch') f.set_renderer('truck_dump_batch', self.render_truck_dump_batch) else: f.remove_field('truck_dump_batch') # truck_dump_vendor if self.creating: f.set_label('truck_dump_vendor', "Vendor") f.set_readonly('truck_dump_vendor') f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor) else: f.remove_fields('truck_dump', 'truck_dump_children', 'truck_dump_batch') # invoice_file if self.creating: f.set_type('invoice_file', 'file') 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)")) 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()) f.set_widget('store_uuid', forms.widgets.ReadonlyWidget()) f.set_default('store_uuid', store.uuid) f.set_hidden('store_uuid') # purchase if self.creating: f.remove_field('purchase') # department if self.creating: f.remove_field('department_uuid') # order_quantities_known if not self.editing: f.remove_field('order_quantities_known') def template_kwargs_create(self, **kwargs): kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs) if self.allow_truck_dump: vmap = {} batches = self.Session.query(model.PurchaseBatch)\ .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\ .filter(model.PurchaseBatch.truck_dump == True)\ .filter(model.PurchaseBatch.complete == True) for batch in batches: vmap[batch.uuid] = batch.vendor_uuid kwargs['batch_vendor_map'] = vmap return kwargs def get_batch_kwargs(self, batch, mobile=False): kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile) if not mobile: batch_type = self.request.POST['batch_type'] if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) elif batch_type.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store kwargs['vendor'] = truck_dump.vendor kwargs['truck_dump_batch'] = truck_dump else: raise NotImplementedError return kwargs def department_for_purchase(self, purchase): pass def delete_instance(self, batch): """ Delete all data (files etc.) for the batch. """ truck_dump = batch.truck_dump_batch if batch.truck_dump: for child in batch.truck_dump_children: self.delete_instance(child) super(ReceivingBatchView, self).delete_instance(batch) if truck_dump: self.handler.refresh(truck_dump) def render_truck_dump_batch(self, batch, field): truck_dump = batch.truck_dump_batch if not truck_dump: return "" text = six.text_type(truck_dump) url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) def render_truck_dump_vendor(self, batch, field): truck_dump = self.get_instance() vendor = truck_dump.vendor text = "({}) {}".format(vendor.id, vendor.name) url = self.request.route_url('vendors.view', uuid=vendor.uuid) return tags.link_to(text, url) def render_truck_dump_children(self, batch, field): contents = [] children = batch.truck_dump_children if children: items = [] for child in children: text = six.text_type(child) url = self.request.route_url('receiving.view', uuid=child.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) contents.append(HTML.tag('ul', c=items)) if batch.complete and not batch.executed: buttons = self.make_truck_dump_child_buttons(batch) if buttons: buttons = HTML.literal(' ').join(buttons) contents.append(HTML.tag('div', class_='buttons', c=[buttons])) if not contents: return "" return HTML.tag('div', c=contents) def make_truck_dump_child_buttons(self, batch): return [ tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'), ] def add_child_from_invoice(self): """ View for adding a child batch to a truck dump, from invoice file. """ batch = self.get_instance() if not batch.truck_dump: self.request.session.flash("Batch is not a truck dump: {}".format(batch)) return self.redirect(self.get_action_url('view', batch)) if batch.executed: self.request.session.flash("Batch has already been executed: {}".format(batch)) return self.redirect(self.get_action_url('view', batch)) if not batch.complete: self.request.session.flash("Batch is not marked as complete: {}".format(batch)) return self.redirect(self.get_action_url('view', batch)) self.creating = True form = self.make_child_from_invoice_form(self.get_model_class()) return self.create(form=form) def make_child_from_invoice_form(self, instance, **kwargs): """ Creates a new form for the given model class/instance """ kwargs['configure'] = self.configure_child_from_invoice_form return self.make_form(instance=instance, **kwargs) def configure_child_from_invoice_form(self, f): assert self.creating truck_dump = self.get_instance() self.configure_form(f) f.set_fields([ 'batch_type', 'truck_dump_parent', 'truck_dump_vendor', 'invoice_file', 'invoice_parser_key', 'invoice_number', 'description', 'notes', ]) # batch_type f.set_widget('batch_type', forms.widgets.ReadonlyWidget()) f.set_default('batch_type', 'truck_dump_child_from_invoice') # truck_dump_batch_uuid f.set_readonly('truck_dump_parent') f.set_renderer('truck_dump_parent', self.render_truck_dump_parent) def render_truck_dump_parent(self, batch, field): truck_dump = self.get_instance() text = six.text_type(truck_dump) url = self.request.route_url('receiving.view', uuid=truck_dump.uuid) return tags.link_to(text, url) def render_mobile_listitem(self, batch, i): title = "({}) {} for ${:0,.2f} - {}, {}".format( batch.id_str, batch.vendor, batch.po_total or 0, batch.department, batch.created_by) return title def make_mobile_row_filters(self): """ Returns a set of filters for the mobile row grid. """ batch = self.get_instance() filters = grids.filters.GridFilterSet() # visible filter options will depend on whether batch came from purchase if batch.order_quantities_known: value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all'] default_status = 'incomplete' else: value_choices = ['received', 'damaged', 'expired', 'invalid', 'all'] default_status = 'all' # remove 'expired' filter option if not relevant if 'expired' in value_choices and not self.handler.allow_expired_credits(): value_choices.remove('expired') filters['status'] = MobileItemStatusFilter('status', value_choices=value_choices, default_value=default_status) return filters def get_purchase(self, uuid): return self.Session.query(model.Purchase).get(uuid) def mobile_create(self): """ Mobile view for creating a new receiving batch """ mode = self.batch_mode data = {'mode': mode} phase = 1 schema = MobileNewReceivingBatch().bind(session=self.Session()) form = forms.Form(schema=schema, request=self.request) if form.validate(newstyle=True): phase = form.validated['phase'] if form.validated['workflow'] == 'from_scratch': if not self.allow_from_scratch: raise NotImplementedError("Requested workflow not supported: from_scratch") batch = self.model_class() batch.store = self.rattail_config.get_store(self.Session()) batch.mode = mode batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) batch.created_by = self.request.user batch.date_received = localtime(self.rattail_config).date() kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) return self.redirect(self.get_action_url('view', batch, mobile=True)) elif form.validated['workflow'] == 'truck_dump': if not self.allow_truck_dump: raise NotImplementedError("Requested workflow not supported: truck_dump") batch = self.model_class() batch.store = self.rattail_config.get_store(self.Session()) batch.mode = mode batch.truck_dump = True batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) batch.created_by = self.request.user batch.date_received = localtime(self.rattail_config).date() kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) return self.redirect(self.get_action_url('view', batch, mobile=True)) elif form.validated['workflow'] == 'from_po': if not self.allow_from_po: raise NotImplementedError("Requested workflow not supported: from_po") vendor = self.Session.query(model.Vendor).get(form.validated['vendor']) data['vendor'] = vendor schema = self.make_mobile_receiving_from_po_schema() po_form = forms.Form(schema=schema, request=self.request) if phase == 2: if po_form.validate(newstyle=True): batch = self.model_class() batch.store = self.rattail_config.get_store(self.Session()) batch.mode = mode batch.vendor = vendor batch.buyer = self.request.user.employee batch.created_by = self.request.user batch.date_received = localtime(self.rattail_config).date() self.assign_purchase_order(batch, po_form) kwargs = self.get_batch_kwargs(batch, mobile=True) batch = self.handler.make_batch(self.Session(), **kwargs) if self.handler.should_populate(batch): self.handler.populate(batch) return self.redirect(self.get_action_url('view', batch, mobile=True)) else: phase = 2 else: raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow'])) data['form'] = form data['dform'] = form.make_deform_form() data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize() data['phase'] = phase if phase == 2: purchases = self.eligible_purchases(vendor.uuid, mode=mode) data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']] data['purchase_order_fieldname'] = self.purchase_order_fieldname return self.render_to_response('create', data, mobile=True) def make_mobile_receiving_from_po_schema(self): schema = colander.MappingSchema() schema.add(colander.SchemaNode(colander.String(), name=self.purchase_order_fieldname, validator=self.validate_purchase)) return schema.bind(session=self.Session()) @staticmethod @colander.deferred def validate_purchase(node, kw): session = kw['session'] def validate(node, value): purchase = session.query(model.Purchase).get(value) if not purchase: raise colander.Invalid(node, "Purchase not found") return purchase.uuid return validate def assign_purchase_order(self, batch, po_form): """ Assign the original purchase order to the given batch. Default behavior assumes a Rattail Purchase object is what we're after. """ purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname]) if isinstance(purchase, model.Purchase): batch.purchase_uuid = purchase.uuid department = self.department_for_purchase(purchase) if department: batch.department_uuid = department.uuid def configure_mobile_form(self, f): super(ReceivingBatchView, self).configure_mobile_form(f) batch = f.model_instance # truck_dump if not self.creating: if not batch.truck_dump: f.remove_field('truck_dump') # department if not self.creating: if batch.truck_dump: f.remove_field('department') def configure_row_grid(self, g): super(ReceivingBatchView, self).configure_row_grid(g) g.set_label('department_name', "Department") def configure_row_form(self, f): super(ReceivingBatchView, self).configure_row_form(f) f.set_readonly('cases_ordered') f.set_readonly('units_ordered') f.set_readonly('po_unit_cost') f.set_readonly('po_total') f.set_readonly('invoice_total') def render_mobile_row_listitem(self, row, i): key = self.render_product_key_value(row) description = row.product.full_description if row.product else row.description return "({}) {}".format(key, description) def should_aggregate_products(self, batch): """ Must return a boolean indicating whether rows should be aggregated by product for the given batch. """ return True def quick_locate_rows(self, batch, entry): rows = [] # try to locate rows by product uuid match before other key product = self.Session.query(model.Product).get(entry) if product: rows = [row for row in batch.active_rows() if row.product_uuid == product.uuid] if rows: return rows key = self.rattail_config.product_key() if key == 'upc': # we prefer "exact" UPC matches, i.e. those which assumed the entry # already contained the check digit. provided = GPC(entry, calc_check_digit=False) rows = [row for row in batch.active_rows() if row.upc == provided] if rows: return rows # if no "exact" UPC matches, we'll settle for those (UPC matches) # which assume the entry lacked a check digit. checked = GPC(entry, calc_check_digit='upc') rows = [row for row in batch.active_rows() if row.upc == checked] return rows elif key == 'item_id': rows = [row for row in batch.active_rows() if row.item_id == entry] return rows def save_quick_row_form(self, form): batch = self.get_instance() entry = form.validated['quick_row_entry'] # maybe try to locate existing row first rows = self.quick_locate_rows(batch, entry) if rows: # if aggregating, just re-use matching row prefer_existing = self.should_aggregate_products(batch) if prefer_existing: if len(rows) > 1: log.warning("found multiple row matches for '%s' in batch %s: %s", entry, batch.id_str, batch) return rows[0] else: # borrow product from matching row, but make new row other_row = rows[0] row = model.PurchaseBatchRow() row.product = other_row.product self.handler.add_row(batch, row) self.Session.flush() return row # try to locate product by uuid before other, more specific key product = self.Session.query(model.Product).get(entry) if product and not product.deleted: row = model.PurchaseBatchRow() row.product = product self.handler.add_row(batch, row) self.Session.flush() return row key = self.rattail_config.product_key() if key == 'upc': # try to locate product by upc provided = GPC(entry, calc_check_digit=False) checked = GPC(entry, calc_check_digit='upc') product = api.get_product_by_upc(self.Session(), provided) if not product: product = api.get_product_by_upc(self.Session(), checked) if product: row = model.PurchaseBatchRow() row.product = product self.handler.add_row(batch, row) self.Session.flush() return row # check for "bad" upc if len(entry) > 14: return # product not in system, but presumably sane upc, so add to batch anyway row = model.PurchaseBatchRow() row.upc = provided # TODO: why not checked? how to know? row.item_id = entry row.description = "(unknown product)" self.handler.add_row(batch, row) self.Session.flush() return row elif key == 'item_id': # try to locate product by item_id product = api.get_product_by_item_id(self.Session(), entry) if product: row = model.PurchaseBatchRow() row.product = product self.handler.add_row(batch, row) self.Session.flush() return row # check for "too long" item_id if len(entry) > maxlen(model.PurchaseBatchRow.item_id): return # product not in system, but presumably sane item_id, so add to batch anyway row = model.PurchaseBatchRow() row.item_id = entry row.description = "(unknown product)" self.handler.add_row(batch, row) self.Session.flush() return row else: raise NotImplementedError("don't know how to handle product key: {}".format(key)) def redirect_after_quick_row(self, row, mobile=False): if mobile: return self.redirect(self.get_row_action_url('view', row, mobile=mobile)) return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile) def mobile_view_row(self): """ Mobile view for receiving batch row items. Note that this also handles updating a row. """ self.viewing = True row = self.get_row_instance() batch = row.batch permission_prefix = self.get_permission_prefix() form = self.make_mobile_row_form(row) context = { 'row': row, 'batch': batch, 'parent_instance': batch, 'instance': row, 'instance_title': self.get_row_instance_title(row), 'parent_model_title': self.get_model_title(), 'product_image_url': pod.get_image_url(self.rattail_config, row.upc), 'form': form, 'allow_expired': self.handler.allow_expired_credits(), 'allow_cases': self.handler.allow_cases(), } if self.request.has_perm('{}.create_row'.format(permission_prefix)): schema = MobileReceivingForm().bind(session=self.Session()) update_form = forms.Form(schema=schema, request=self.request) if update_form.validate(newstyle=True): row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row']) mode = update_form.validated['mode'] cases = update_form.validated['cases'] units = update_form.validated['units'] # add values as-is to existing case/unit amounts. note # that this can sometimes give us negative values! e.g. if # user scans 1 CS and then subtracts 2 EA, then we would # have 1 / -2 for our counts. but we consider that to be # expected, and other logic must allow for the possibility if cases: setattr(row, 'cases_{}'.format(mode), (getattr(row, 'cases_{}'.format(mode)) or 0) + cases) if units: setattr(row, 'units_{}'.format(mode), (getattr(row, 'units_{}'.format(mode)) or 0) + units) # if mode in ('damaged', 'expired', 'mispick'): if mode in ('damaged', 'expired'): self.attach_credit(row, mode, cases, units, expiration_date=update_form.validated['expiration_date'], # discarded=update_form.data['trash'], # mispick_product=shipped_product) ) # first undo any totals previously in effect for the row, then refresh if row.invoice_total: batch.invoice_total -= row.invoice_total self.handler.refresh_row(row) # keep track of last-used uom, although we just track # whether or not it was 'CS' since the unit_uom can vary sticky_case = None if not update_form.validated['quick_receive']: if cases and not units: sticky_case = True elif units and not cases: sticky_case = False if sticky_case is not None: self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case return self.redirect(self.get_action_url('view', batch, mobile=True)) # unit_uom can vary by product context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA' # effective uom can vary in a few ways...the basic default is 'CS' if # self.default_uom_is_case is true, otherwise whatever unit_uom is. sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case') if sticky_case is None: context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom'] elif sticky_case: context['uom'] = 'CS' else: context['uom'] = context['unit_uom'] if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered: context['uom'] = context['unit_uom'] if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') return self.render_to_response('view_row', context, mobile=True) def attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None): batch = row.batch credit = model.PurchaseBatchCredit() credit.credit_type = credit_type credit.store = batch.store credit.vendor = batch.vendor credit.date_ordered = batch.date_ordered credit.date_shipped = batch.date_shipped credit.date_received = batch.date_received credit.invoice_number = batch.invoice_number credit.invoice_date = batch.invoice_date credit.product = row.product credit.upc = row.upc credit.vendor_item_code = row.vendor_code credit.brand_name = row.brand_name credit.description = row.description credit.size = row.size credit.department_number = row.department_number credit.department_name = row.department_name credit.case_quantity = row.case_quantity credit.cases_shorted = cases credit.units_shorted = units credit.invoice_line_number = row.invoice_line_number credit.invoice_case_cost = row.invoice_case_cost credit.invoice_unit_cost = row.invoice_unit_cost credit.invoice_total = row.invoice_total # calculate credit total # TODO: should this leverage case cost if present? credit_units = self.handler.get_units(credit.cases_shorted, credit.units_shorted, credit.case_quantity) credit.credit_total = credit_units * (credit.invoice_unit_cost or 0) credit.product_discarded = discarded if credit_type == 'expired': credit.expiration_date = expiration_date elif credit_type == 'mispick' and mispick_product: credit.mispick_product = mispick_product credit.mispick_upc = mispick_product.upc if mispick_product.brand: credit.mispick_brand_name = mispick_product.brand.name credit.mispick_description = mispick_product.description credit.mispick_size = mispick_product.size row.credits.append(credit) return credit @classmethod def _receiving_defaults(cls, config): route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() model_key = cls.get_model_key() permission_prefix = cls.get_permission_prefix() if cls.allow_truck_dump: config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key)) config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix), permission='{}.create'.format(permission_prefix)) @classmethod def defaults(cls, config): cls._receiving_defaults(config) cls._purchasing_defaults(config) cls._batch_defaults(config) cls._defaults(config) # TODO: this is a stopgap measure to fix an obvious bug, which exists when the # session is not provided by the view at runtime (i.e. when it was instead # being provided by the type instance, which was created upon app startup). @colander.deferred def valid_vendor(node, kw): session = kw['session'] def validate(node, value): vendor = session.query(model.Vendor).get(value) if not vendor: raise colander.Invalid(node, "Vendor not found") return vendor.uuid return validate class MobileNewReceivingBatch(colander.MappingSchema): vendor = colander.SchemaNode(colander.String(), validator=valid_vendor) workflow = colander.SchemaNode(colander.String(), validator=colander.OneOf([ 'from_po', 'from_scratch', 'truck_dump', ])) phase = colander.SchemaNode(colander.Int()) class MobileNewReceivingFromPO(colander.MappingSchema): purchase = colander.SchemaNode(colander.String()) # TODO: this is a stopgap measure to fix an obvious bug, which exists when the # session is not provided by the view at runtime (i.e. when it was instead # being provided by the type instance, which was created upon app startup). @colander.deferred def valid_purchase_batch_row(node, kw): session = kw['session'] def validate(node, value): row = session.query(model.PurchaseBatchRow).get(value) if not row: raise colander.Invalid(node, "Batch row not found") if row.batch.executed: raise colander.Invalid(node, "Batch has already been executed") return row.uuid return validate class MobileReceivingForm(colander.MappingSchema): row = colander.SchemaNode(colander.String(), validator=valid_purchase_batch_row) mode = colander.SchemaNode(colander.String(), validator=colander.OneOf([ 'received', 'damaged', 'expired', # 'mispick', ])) cases = colander.SchemaNode(colander.Decimal(), missing=None) units = colander.SchemaNode(colander.Decimal(), missing=None) expiration_date = colander.SchemaNode(colander.Date(), widget=dfwidget.TextInputWidget(), missing=colander.null) quick_receive = colander.SchemaNode(colander.Boolean()) def includeme(config): ReceivingBatchView.defaults(config)