# -*- coding: utf-8; -*-
################################################################################
#
#  Rattail -- Retail Software Framework
#  Copyright © 2010-2023 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 'receiving' (purchasing) batches
"""

from __future__ import unicode_literals, absolute_import

import os
import re
import decimal
import logging

import six
import humanize
import sqlalchemy as sa

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.threads import Thread

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.util import get_form_data
from tailbone.views.purchasing import PurchasingBatchView


log = logging.getLogger(__name__)

POSSIBLE_RECEIVING_MODES = [
    'received',
    'damaged',
    'expired',
    # 'mispick',
    'missing',
]

POSSIBLE_CREDIT_TYPES = [
    'damaged',
    'expired',
    # 'mispick',
    'missing',
]


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
    bulk_deletable = True
    configurable = True
    config_title = "Receiving"
    default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/receiving/index.html'

    rows_editable = False
    rows_editable_but_not_directly = True
    rows_deletable = True

    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',
        'description',
        'department',
        'date_ordered',
        'created',
        'created_by',
        'rowcount',
        'invoice_total_calculated',
        'status_code',
        'executed',
    ]

    form_fields = [
        'id',
        'batch_type',           # TODO: ideally would get rid of this one
        'store',
        'vendor',
        'description',
        'receiving_workflow',
        'truck_dump',
        'truck_dump_children_first',
        'truck_dump_children',
        'truck_dump_ready',
        'truck_dump_batch',
        'invoice_file',
        'invoice_parser_key',
        'department',
        'purchase',
        'params',
        'vendor_email',
        'vendor_fax',
        'vendor_contact',
        'vendor_phone',
        'date_ordered',
        'po_number',
        'po_total',
        'date_received',
        'invoice_date',
        'invoice_number',
        'invoice_total',
        'invoice_total_calculated',
        'notes',
        'created',
        'created_by',
        'status_code',
        'truck_dump_status',
        'rowcount',
        'order_quantities_known',
        'receiving_complete',
        'complete',
        'executed',
        'executed_by',
    ]

    row_grid_columns = [
        'sequence',
        '_product_key_',
        'vendor_code',
        'brand_name',
        'description',
        'size',
        'department_name',
        'cases_ordered',
        'units_ordered',
        'cases_shipped',
        'units_shipped',
        'cases_received',
        'units_received',
        'catalog_unit_cost',
        'po_unit_cost',
        'invoice_unit_cost',
        'invoice_total_calculated',
        'credits',
        'status_code',
        'truck_dump_status',
    ]

    row_form_fields = [
        'sequence',
        'item_entry',
        '_product_key_',
        'vendor_code',
        'product',
        'brand_name',
        'description',
        'size',
        'case_quantity',
        'ordered',
        'cases_ordered',
        'units_ordered',
        'shipped',
        'cases_shipped',
        'units_shipped',
        'received',
        'cases_received',
        'units_received',
        'damaged',
        'cases_damaged',
        'units_damaged',
        'expired',
        'cases_expired',
        'units_expired',
        'mispick',
        'cases_mispick',
        'units_mispick',
        'missing',
        'cases_missing',
        'units_missing',
        'catalog_unit_cost',
        'po_line_number',
        'po_unit_cost',
        'po_case_size',
        'po_total',
        'invoice_line_number',
        'invoice_unit_cost',
        'invoice_cost_confirmed',
        'invoice_case_size',
        'invoice_total',
        'invoice_total_calculated',
        'status_code',
        'truck_dump_status',
        'claims',
        'credits',
    ]

    # convenience list of all quantity attributes involved for a truck dump claim
    claim_keys = [
        'cases_received',
        'units_received',
        'cases_damaged',
        'units_damaged',
        'cases_expired',
        'units_expired',
    ]

    @property
    def batch_mode(self):
        return self.enum.PURCHASE_BATCH_MODE_RECEIVING

    def configure_grid(self, g):
        super(ReceivingBatchView, self).configure_grid(g)

        if not self.handler.allow_truck_dump_receiving():
            g.remove('truck_dump')

    def create(self, form=None, **kwargs):
        """
        Custom view for creating a new receiving batch.  We split the process
        into two steps, 1) choose and 2) create.  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.costing:CostingBatchView.create()`
        which uses similar logic.
        """
        route_prefix = self.get_route_prefix()
        workflows = self.handler.supported_receiving_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'):

            redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))

            # 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 redirect

            # also, we require vendor to be correctly identified.  if
            # someone e.g. navigates to a URL by accident etc. we want
            # to gracefully handle and redirect
            uuid = self.request.matchdict['vendor_uuid']
            vendor = self.Session.query(model.Vendor).get(uuid)
            if not vendor:
                self.request.session.flash("Invalid vendor selection.  "
                                           "Please choose an existing vendor.",
                                           'warning')
                raise redirect

            # okay now do the normal thing, per workflow
            return super(ReceivingBatchView, self).create(**kwargs)

        # on the other hand, if caller provided a form, that means we are in
        # the middle of some other custom workflow, e.g. "add child to truck
        # dump parent" or some such.  in which case we also defer to the normal
        # logic, so as to not interfere with that.
        if form:
            return super(ReceivingBatchView, self).create(form=form, **kwargs)

        # okay, at this point we need the user to select a vendor and workflow
        self.creating = True
        use_buefy = self.get_use_buefy()
        context = {}

        # form to accept user choice of vendor/workflow
        schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
        form = forms.Form(schema=schema, request=self.request,
                          use_buefy=use_buefy)

        # configure vendor field
        app = self.get_rattail_app()
        vendor_handler = app.get_vendor_handler()
        if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
            # only show vendors for which we have dedicated invoice parsers
            vendors = {}
            for parser in self.batch_handler.get_supported_invoice_parsers():
                if parser.vendor_key:
                    vendor = vendor_handler.get_vendor(self.Session(),
                                                       parser.vendor_key)
                    if vendor:
                        vendors[vendor.uuid] = vendor
            vendors = sorted(vendors.values(), key=lambda v: v.name)
            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
                             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))
        else:
            # user may choose *any* available vendor
            use_dropdown = vendor_handler.choice_uses_dropdown()
            if use_dropdown:
                vendors = self.Session.query(model.Vendor)\
                                      .order_by(model.Vendor.id)\
                                      .all()
                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))
                if len(vendors) == 1:
                    form.set_default('vendor', vendors[0].uuid)
            else:
                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))
        form.set_validator('vendor', self.valid_vendor_uuid)

        # 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))
        if len(workflows) == 1:
            form.set_default('workflow', workflows[0]['workflow_key'])

        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 row_deletable(self, row):

        # first run it through the normal logic, if that doesn't like
        # it then we won't either
        if not super(ReceivingBatchView, self).row_deletable(row):
            return False

        batch = row.batch

        # can always delete rows from truck dump parent
        if batch.is_truck_dump_parent():
            return True

        # can always delete rows from truck dump child
        elif batch.is_truck_dump_child():
            return True

        else: # okay, normal batch
            if batch.order_quantities_known:
                return False
            else: # allow delete if receiving rom scratch
                return True

        # cannot delete row by default
        return False

    def get_instance_title(self, batch):
        title = super(ReceivingBatchView, self).get_instance_title(batch)
        if batch.is_truck_dump_parent():
            title = "{} (TRUCK DUMP PARENT)".format(title)
        elif batch.is_truck_dump_child():
            title = "{} (TRUCK DUMP CHILD)".format(title)
        return title

    def configure_form(self, f):
        super(ReceivingBatchView, self).configure_form(f)
        model = self.model
        batch = f.model_instance
        allow_truck_dump = self.batch_handler.allow_truck_dump_receiving()
        workflow = self.request.matchdict.get('workflow_key')
        route_prefix = self.get_route_prefix()
        use_buefy = self.get_use_buefy()

        # 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_readonly('vendor_uuid')
            f.set_default('vendor_uuid', six.text_type(vendor))

            # cancel should take us back to choosing a workflow
            f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))

        # receiving_workflow
        if self.creating and workflow:
            f.set_readonly('receiving_workflow')
            f.set_renderer('receiving_workflow', self.render_receiving_workflow)
        else:
            f.remove('receiving_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')

        # truck_dump*
        if allow_truck_dump:

            # truck_dump
            if self.creating or not batch.is_truck_dump_parent():
                f.remove_field('truck_dump')
            else:
                f.set_readonly('truck_dump')

            # truck_dump_children_first
            if self.creating or not batch.is_truck_dump_parent():
                f.remove_field('truck_dump_children_first')

            # truck_dump_children
            if self.viewing and batch.is_truck_dump_parent():
                f.set_renderer('truck_dump_children', self.render_truck_dump_children)
            else:
                f.remove_field('truck_dump_children')

            # truck_dump_ready
            if self.creating or not batch.is_truck_dump_parent():
                f.remove_field('truck_dump_ready')

            # truck_dump_status
            if self.creating or not batch.is_truck_dump_parent():
                f.remove_field('truck_dump_status')
            else:
                f.set_readonly('truck_dump_status')
                f.set_enum('truck_dump_status', model.PurchaseBatch.STATUS)

            # 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.is_truck_dump_child():
                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_first',
                            'truck_dump_children',
                            'truck_dump_ready',
                            'truck_dump_status',
                            'truck_dump_batch')

        # store
        if self.creating:
            store = self.rattail_config.get_store(self.Session())
            f.set_default('store_uuid', store.uuid)
            # TODO: seems like set_hidden() should also set HiddenWidget
            f.set_hidden('store_uuid')
            f.set_widget('store_uuid', dfwidget.HiddenWidget())

        # purchase
        if (self.creating and workflow in ('from_po', 'from_po_with_invoice')
            and self.purchase_order_fieldname == 'purchase'):
            if use_buefy:
                f.replace('purchase', 'purchase_uuid')
                purchases = self.batch_handler.get_eligible_purchases(
                    vendor, self.enum.PURCHASE_BATCH_MODE_RECEIVING)
                values = [(p.uuid, self.batch_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')
        elif self.creating or not batch.purchase:
            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')

        # multiple invoice files (if applicable)
        if (not self.creating
            and batch.get_param('receiving_workflow') == 'from_multi_invoice'):

            if 'invoice_files' not in f:
                f.insert_before('invoice_file', 'invoice_files')
            f.set_renderer('invoice_files', self.render_invoice_files)
            f.set_readonly('invoice_files', True)

        # invoice totals
        f.set_label('invoice_total', "Invoice Total (Orig.)")
        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
        if self.creating:
            f.remove('invoice_total_calculated')

        # hide all invoice fields if batch does not have invoice file
        if not self.creating and not self.batch_handler.has_invoice_file(batch):
            f.remove('invoice_file',
                     'invoice_date',
                     'invoice_number',
                     'invoice_total')

        # receiving_complete
        if self.creating:
            f.remove('receiving_complete')

        # now that all fields are setup, some final tweaks based on workflow
        if self.creating and workflow:

            if workflow == 'from_scratch':
                f.remove('truck_dump_batch_uuid',
                         'invoice_file',
                         'invoice_parser_key')

            elif workflow == 'from_invoice':
                f.set_required('invoice_file')
                f.set_required('invoice_parser_key')
                f.remove('truck_dump_batch_uuid',
                         'po_number',
                         'invoice_date',
                         'invoice_number')

            elif workflow == 'from_multi_invoice':
                if 'invoice_files' not in f:
                    f.insert_before('invoice_file', 'invoice_files')
                f.set_type('invoice_files', 'multi_file')
                f.set_required('invoice_parser_key')
                f.remove('truck_dump_batch_uuid',
                         'po_number',
                         'invoice_file',
                         'invoice_date',
                         'invoice_number')

            elif workflow == 'from_po':
                f.remove('truck_dump_batch_uuid',
                         'date_ordered',
                         'po_number',
                         'invoice_file',
                         'invoice_parser_key',
                         'invoice_date',
                         'invoice_number')

            elif workflow == 'from_po_with_invoice':
                f.set_required('invoice_file')
                f.set_required('invoice_parser_key')
                f.remove('truck_dump_batch_uuid',
                         'date_ordered',
                         'po_number',
                         'invoice_date',
                         'invoice_number')

            elif workflow == 'truck_dump_children_first':
                f.remove('truck_dump_batch_uuid',
                         'invoice_file',
                         'invoice_parser_key',
                         'date_ordered',
                         'po_number',
                         'invoice_date',
                         'invoice_number')

            elif workflow == 'truck_dump_children_last':
                f.remove('truck_dump_batch_uuid',
                         'invoice_file',
                         'invoice_parser_key',
                         'date_ordered',
                         'po_number',
                         'invoice_date',
                         'invoice_number')

    def render_invoice_files(self, batch, field):
        datadir = self.batch_handler.datadir(batch)
        items = []
        for filename in batch.get_param('invoice_files', []):
            path = os.path.join(datadir, filename)
            url = self.get_action_url('download', batch,
                                      _query={'filename': filename})
            link = self.render_file_field(path, url)
            items.append(HTML.tag('li', c=[link]))
        return HTML.tag('ul', c=items)

    def render_receiving_workflow(self, batch, field):
        key = self.request.matchdict['workflow_key']
        info = self.handler.receiving_workflow_info(key)
        if info:
            return info['display']

    def get_visible_params(self, batch):
        params = super(ReceivingBatchView, self).get_visible_params(batch)

        # remove this since we show it separately
        params.pop('invoice_files', None)

        return params

    def template_kwargs_create(self, **kwargs):
        kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
        if self.handler.allow_truck_dump_receiving():
            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, **kwargs):
        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, **kwargs)
        batch_type = self.request.POST['batch_type']

        # must pull vendor from URL if it was not in form data
        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
            if 'vendor_uuid' in self.request.matchdict:
                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']

        # TODO: ugh should just have workflow and no batch_type
        kwargs['receiving_workflow'] = batch_type
        if batch_type == 'from_scratch':
            kwargs.pop('truck_dump_batch', None)
            kwargs.pop('truck_dump_batch_uuid', None)
        elif batch_type == 'from_invoice':
            pass
        elif batch_type == 'from_multi_invoice':
            pass
        elif batch_type == 'from_po':
            # TODO: how to best handle this field?  this doesn't seem flexible
            kwargs['purchase_key'] = batch.purchase_uuid
        elif batch_type == 'from_po_with_invoice':
            # TODO: how to best handle this field?  this doesn't seem flexible
            kwargs['purchase_key'] = batch.purchase_uuid
        elif batch_type == 'truck_dump_children_first':
            kwargs['truck_dump'] = True
            kwargs['truck_dump_children_first'] = True
            kwargs['order_quantities_known'] = True
            # TODO: this makes sense in some cases, but all?
            # (should just omit that field when not relevant)
            kwargs['date_ordered'] = None
        elif batch_type == 'truck_dump_children_last':
            kwargs['truck_dump'] = True
            kwargs['truck_dump_ready'] = True
            # TODO: this makes sense in some cases, but all?
            # (should just omit that field when not relevant)
            kwargs['date_ordered'] = 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 make_po_vs_invoice_breakdown(self, batch):
        """
        Returns a simple breakdown as list of 2-tuples, each of which
        has the display title as first member, and number of rows as
        second member.
        """
        grouped = {}
        labels = OrderedDict([
            ('both', "Found in both PO and Invoice"),
            ('po_not_invoice', "Found in PO but not Invoice"),
            ('invoice_not_po', "Found in Invoice but not PO"),
            ('neither', "Not found in PO nor Invoice"),
        ])

        for row in batch.active_rows():
            if row.po_line_number and not row.invoice_line_number:
                grouped.setdefault('po_not_invoice', []).append(row)
            elif row.invoice_line_number and not row.po_line_number:
                grouped.setdefault('invoice_not_po', []).append(row)
            elif row.po_line_number and row.invoice_line_number:
                grouped.setdefault('both', []).append(row)
            else:
                grouped.setdefault('neither', []).append(row)

        breakdown = []

        for key, label in labels.items():
            if key in grouped:
                breakdown.append({
                    'key': key,
                    'title': label,
                    'count': len(grouped[key]),
                })

        return breakdown

    def allow_edit_catalog_unit_cost(self, batch):
        return (not batch.executed
                and self.has_perm('edit_row')
                and self.batch_handler.allow_receiving_edit_catalog_unit_cost())

    def allow_edit_invoice_unit_cost(self, batch):
        return (not batch.executed
                and self.has_perm('edit_row')
                and self.batch_handler.allow_receiving_edit_invoice_unit_cost())

    def template_kwargs_view(self, **kwargs):
        kwargs = super(ReceivingBatchView, self).template_kwargs_view(**kwargs)
        batch = kwargs['instance']
        use_buefy = self.get_use_buefy()

        if self.handler.has_purchase_order(batch) and self.handler.has_invoice_file(batch):
            breakdown = self.make_po_vs_invoice_breakdown(batch)
            factory = self.get_grid_factory()
            if use_buefy:

                g = factory('batch_po_vs_invoice_breakdown', [],
                    columns=['title', 'count'])
                g.set_click_handler('title', "autoFilterPoVsInvoice(props.row)")
                kwargs['po_vs_invoice_breakdown_data'] = breakdown
                kwargs['po_vs_invoice_breakdown_grid'] = HTML.literal(
                    g.render_buefy_table_element(data_prop='poVsInvoiceBreakdownData',
                                                 empty_labels=True))

            else:
                kwargs['po_vs_invoice_breakdown_grid'] = factory(
                    'batch_po_vs_invoice_breakdown',
                    data=breakdown,
                    columns=['title', 'count'])

        kwargs['allow_edit_catalog_unit_cost'] = self.allow_edit_catalog_unit_cost(batch)
        kwargs['allow_edit_invoice_unit_cost'] = self.allow_edit_invoice_unit_cost(batch)

        return kwargs

    def get_context_credits(self, row):
        app = self.get_rattail_app()
        credits_data = []
        for credit in row.credits:
            credits_data.append({
                'uuid': credit.uuid,
                'credit_type': credit.credit_type,
                'expiration_date': six.text_type(credit.expiration_date) if credit.expiration_date else None,
                'cases_shorted': app.render_quantity(credit.cases_shorted),
                'units_shorted': app.render_quantity(credit.units_shorted),
                'shorted': app.render_cases_units(credit.cases_shorted,
                                                  credit.units_shorted),
                'credit_total': app.render_currency(credit.credit_total),
                'mispick_upc': '-',
                'mispick_brand_name': '-',
                'mispick_description': '-',
                'mispick_size': '-',
            })
        return credits_data

    def template_kwargs_view_row(self, **kwargs):
        kwargs = super(ReceivingBatchView, self).template_kwargs_view_row(**kwargs)
        use_buefy = self.get_use_buefy()
        app = self.get_rattail_app()
        products_handler = app.get_products_handler()
        row = kwargs['instance']

        kwargs['allow_cases'] = self.batch_handler.allow_cases()

        if row.product:
            kwargs['image_url'] = products_handler.get_image_url(row.product)
        elif row.upc:
            kwargs['image_url'] = products_handler.get_image_url(upc=row.upc)

        if use_buefy:
            kwargs['row_context'] = self.get_context_row(row)

            modes = list(POSSIBLE_RECEIVING_MODES)
            types = list(POSSIBLE_CREDIT_TYPES)
            if not self.batch_handler.allow_expired_credits():
                if 'expired' in modes:
                    modes.remove('expired')
                if 'expired' in types:
                    types.remove('expired')
            kwargs['possible_receiving_modes'] = modes
            kwargs['possible_credit_types'] = types

        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.is_truck_dump_parent():
            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 = "({}) {}".format(truck_dump.id_str, truck_dump.description or '')
        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 = "({}) {}".format(child.id_str, child.description or '')
                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 not batch.executed and (batch.complete or batch.truck_dump_children_first):
            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.is_truck_dump_parent():
            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 and not batch.truck_dump_children_first:
            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)

        # cancel should go back to truck dump parent
        f.cancel_url = self.get_action_url('view', truck_dump)

        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')
        f.set_hidden('batch_type', False)

        # truck_dump_batch_uuid
        f.set_readonly('truck_dump_parent')
        f.set_renderer('truck_dump_parent', self.render_truck_dump_parent)

        # invoice_parser_key
        f.set_required('invoice_parser_key')

    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)

    @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.handler.assign_purchase_order(
            batch, po_form.validated[self.purchase_order_fieldname],
            session=self.Session())

        department = self.department_for_purchase(purchase)
        if department:
            batch.department_uuid = department.uuid

    def configure_row_grid(self, g):
        super(ReceivingBatchView, self).configure_row_grid(g)
        use_buefy = self.get_use_buefy()
        batch = self.get_instance()

        # vendor_code
        g.filters['vendor_code'].default_active = True
        g.filters['vendor_code'].default_verb = 'contains'

        # catalog_unit_cost
        if (self.handler.has_purchase_order(batch)
            or self.handler.has_invoice_file(batch)):
            g.remove('catalog_unit_cost')
        elif use_buefy and self.allow_edit_catalog_unit_cost(batch):
            g.set_raw_renderer('catalog_unit_cost', self.render_catalog_unit_cost)
            g.set_click_handler('catalog_unit_cost',
                                'catalogUnitCostClicked(props.row)')

        # invoice_unit_cost
        if use_buefy and self.allow_edit_invoice_unit_cost(batch):
            g.set_raw_renderer('invoice_unit_cost', self.render_invoice_unit_cost)
            g.set_click_handler('invoice_unit_cost',
                                'invoiceUnitCostClicked(props.row)')

        # nb. only show PO *or* invoice cost; prefer the latter unless
        # we have a PO and no invoice
        if (self.batch_handler.has_purchase_order(batch)
            and not self.batch_handler.has_invoice_file(batch)):
            g.remove('invoice_unit_cost')
        else:
            g.remove('po_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
        Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid,
                                     sa.func.count().label('credit_count'))\
                              .group_by(model.PurchaseBatchCredit.row_uuid)\
                              .subquery()
        g.set_joiner('credits', lambda q: q.outerjoin(Credits))
        g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)())

        show_ordered = self.rattail_config.getbool(
            'rattail.batch', 'purchase.receiving.show_ordered_column_in_grid',
            default=False)
        if not show_ordered:
            g.remove('cases_ordered',
                     'units_ordered')

        show_shipped = self.rattail_config.getbool(
            'rattail.batch', 'purchase.receiving.show_shipped_column_in_grid',
            default=False)
        if not show_shipped:
            g.remove('cases_shipped',
                     'units_shipped')

        # hide 'ordered' columns for truck dump parent, if its "children first"
        # flag is set, since that batch type is only concerned with receiving
        if batch.is_truck_dump_parent() and not batch.truck_dump_children_first:
            g.remove('cases_ordered',
                     'units_ordered')

        # add "Transform to Unit" action, if appropriate
        if batch.is_truck_dump_parent():
            permission_prefix = self.get_permission_prefix()
            if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
                transform = grids.GridAction('transform',
                                             icon='shuffle',
                                             label="Transform to Unit",
                                             url=self.transform_unit_url)
                g.more_actions.append(transform)
                if g.main_actions and g.main_actions[-1].key == 'delete':
                    delete = g.main_actions.pop()
                    g.more_actions.append(delete)

        # truck_dump_status
        if not batch.is_truck_dump_parent():
            g.remove('truck_dump_status')
        else:
            g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)

    def render_catalog_unit_cost(self):
        return HTML.tag('receiving-cost-editor', **{
            'field': 'catalog_unit_cost',
            'v-model': 'props.row.catalog_unit_cost',
            ':ref': "'catalogUnitCost_' + props.row.uuid",
            ':row': 'props.row',
            '@input': 'catalogCostConfirmed',
        })

    def render_invoice_unit_cost(self):
        return HTML.tag('receiving-cost-editor', **{
            'field': 'invoice_unit_cost',
            'v-model': 'props.row.invoice_unit_cost',
            ':ref': "'invoiceUnitCost_' + props.row.uuid",
            ':row': 'props.row',
            '@input': 'invoiceCostConfirmed',
        })

    def row_grid_extra_class(self, row, i):
        css_class = super(ReceivingBatchView, self).row_grid_extra_class(row, i)

        if row.catalog_cost_confirmed:
            css_class = '{} catalog_cost_confirmed'.format(css_class or '')

        if row.invoice_cost_confirmed:
            css_class = '{} invoice_cost_confirmed'.format(css_class or '')

        return css_class

    def get_row_instance_title(self, row):
        if row.product:
            return six.text_type(row.product)
        if row.upc:
            return row.upc.pretty()
        return super(ReceivingBatchView, self).get_row_instance_title(row)

    def transform_unit_url(self, row, i):
        # grid action is shown only when we return a URL here
        if self.row_editable(row):
            if row.batch.is_truck_dump_parent():
                if row.product and row.product.is_pack_item():
                    return self.get_row_action_url('transform_unit', row)

    def make_row_credits_grid(self, row):

        # first make grid like normal
        g = super(ReceivingBatchView, self).make_row_credits_grid(row)

        if (self.get_use_buefy()
            and self.has_perm('edit_row')
            and self.row_editable(row)):

            # add the Un-Declare action
            g.main_actions.append(self.make_action(
                'remove', label="Un-Declare",
                url='#', icon='trash',
                link_class='has-text-danger',
                click_handler='removeCreditInit(props.row)'))

        return g

    def vuejs_convert_quantity(self, cstruct):
        result = dict(cstruct)
        if result['cases'] is colander.null:
            result['cases'] = None
        elif isinstance(result['cases'], decimal.Decimal):
            result['cases'] = float(result['cases'])
        if result['units'] is colander.null:
            result['units'] = None
        elif isinstance(result['units'], decimal.Decimal):
            result['units'] = float(result['units'])
        return result

    def receive_row(self, **kwargs):
        """
        Primary desktop view for row-level receiving.
        """
        # TODO: this code was largely copied from mobile_receive_row() but it
        # tries to pave the way for shared logic, i.e. where the latter would
        # simply invoke this method and return the result.  however we're not
        # there yet...for now it's only tested for desktop
        self.viewing = True
        use_buefy = self.get_use_buefy()
        row = self.get_row_instance()

        # things are a bit different now w/ buefy support..
        if use_buefy:

            # don't even bother showing this page if that's all the
            # request was about
            if self.request.method == 'GET':
                return self.redirect(self.get_row_action_url('view', row))

            # make sure edit is allowed
            if not (self.has_perm('edit_row') and self.row_editable(row)):
                raise self.forbidden()

            # check for JSON POST, which is submitted via AJAX from
            # the "view row" page
            if self.request.method == 'POST' and not self.request.POST:
                data = self.request.json_body
                kwargs = dict(data)

                # TODO: for some reason quantities can come through as strings?
                cases = kwargs['quantity']['cases']
                if cases is not None:
                    if cases == '':
                        cases = None
                    else:
                        cases = decimal.Decimal(cases)
                kwargs['cases'] = cases
                units = kwargs['quantity']['units']
                if units is not None:
                    if units == '':
                        units = None
                    else:
                        units = decimal.Decimal(units)
                kwargs['units'] = units
                del kwargs['quantity']

                # handler takes care of the receiving logic for us
                try:
                    self.batch_handler.receive_row(row, **kwargs)

                except Exception as error:
                    return self.json_response({'error': six.text_type(error)})

                self.Session.flush()
                self.Session.refresh(row)
                return self.json_response({
                    'ok': True,
                    'row': self.get_context_row(row)})

        batch = row.batch
        permission_prefix = self.get_permission_prefix()
        possible_modes = [
            'received',
            'damaged',
            'expired',
        ]
        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': self.get_row_image_url(row),
            'allow_expired': self.handler.allow_expired_credits(),
            'allow_cases': self.handler.allow_cases(),
            'quick_receive': False,
            'quick_receive_all': False,
        }

        schema = ReceiveRowForm().bind(session=self.Session())
        form = forms.Form(schema=schema, request=self.request, use_buefy=use_buefy)
        form.cancel_url = self.get_row_action_url('view', row)

        # mode
        mode_values = [(mode, mode) for mode in possible_modes]
        if use_buefy:
            mode_widget = dfwidget.SelectWidget(values=mode_values)
        else:
            mode_widget = forms.widgets.JQuerySelectWidget(values=mode_values)
        form.set_widget('mode', mode_widget)

        # quantity
        form.set_widget('quantity', forms.widgets.CasesUnitsWidget(amount_required=True,
                                                                   one_amount_only=True))
        form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity)

        # expiration_date
        form.set_type('expiration_date', 'date_jquery')

        # TODO: what is this one about again?
        form.remove_field('quick_receive')

        if form.validate(newstyle=True):

            # handler takes care of the row receiving logic for us
            kwargs = dict(form.validated)
            kwargs['cases'] = kwargs['quantity']['cases']
            kwargs['units'] = kwargs['quantity']['units']
            del kwargs['quantity']
            self.handler.receive_row(row, **kwargs)

            # keep track of last-used uom, although we just track
            # whether or not it was 'CS' since the unit_uom can vary
            # TODO: should this be done for desktop too somehow?
            sticky_case = None
            # if mobile and not form.validated['quick_receive']:
            #     cases = form.validated['cases']
            #     units = form.validated['units']
            #     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_row_action_url('view', row))

        # unit_uom can vary by product
        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'

        if context['quick_receive'] and context['quick_receive_all']:
            if context['allow_cases']:
                context['quick_receive_uom'] = 'CS'
                raise NotImplementedError("TODO: add CS support for quick_receive_all")
            else:
                context['quick_receive_uom'] = context['unit_uom']
                accounted_for = self.handler.get_units_accounted_for(row)
                remainder = self.handler.get_units_ordered(row) - accounted_for

                if accounted_for:
                    # some product accounted for; button should receive "remainder" only
                    if remainder:
                        remainder = pretty_quantity(remainder)
                        context['quick_receive_quantity'] = remainder
                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
                    else:
                        # unless there is no remainder, in which case disable it
                        context['quick_receive'] = False

                else: # nothing yet accounted for, button should receive "all"
                    if not remainder:
                        raise ValueError("why is remainder empty?")
                    remainder = pretty_quantity(remainder)
                    context['quick_receive_quantity'] = remainder
                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])

        # 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 = None
        # if mobile:
        #     # TODO: should do this for desktop also, but rename the session variable
        #     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']

        # # TODO: should do this for desktop in addition to mobile?
        # if mobile and batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
        #     warn = True
        #     if batch.is_truck_dump_parent() and row.product:
        #         uuids = [child.uuid for child in batch.truck_dump_children]
        #         if uuids:
        #             count = self.Session.query(model.PurchaseBatchRow)\
        #                                 .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
        #                                 .filter(model.PurchaseBatchRow.product == row.product)\
        #                                 .count()
        #             if count:
        #                 warn = False
        #     if warn:
        #         self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')

        # # TODO: should do this for desktop in addition to mobile?
        # if mobile:
        #     # maybe alert user if they've already received some of this product
        #     alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
        #                                                  default=False)
        #     if alert_received:
        #         if self.handler.get_units_confirmed(row):
        #             msg = "You have already received some of this product; last update was {}.".format(
        #                 humanize.naturaltime(make_utc() - row.modified))
        #             self.request.session.flash(msg, 'receiving-warning')

        context['form'] = form
        context['dform'] = form.make_deform_form()
        context['parent_url'] = self.get_action_url('view', batch)
        context['parent_title'] = self.get_instance_title(batch)
        return self.render_to_response('receive_row', context)

    def declare_credit(self):
        """
        View for declaring a credit, i.e. converting some "received" or similar
        quantity, to a credit of some sort.
        """
        use_buefy = self.get_use_buefy()
        row = self.get_row_instance()

        # things are a bit different now w/ buefy support..
        if use_buefy:

            # don't even bother showing this page if that's all the
            # request was about
            if self.request.method == 'GET':
                return self.redirect(self.get_row_action_url('view', row))

            # make sure edit is allowed
            if not (self.has_perm('edit_row') and self.row_editable(row)):
                raise self.forbidden()

            # check for JSON POST, which is submitted via AJAX from
            # the "view row" page
            if self.request.method == 'POST' and not self.request.POST:
                data = self.request.json_body
                kwargs = dict(data)

                # TODO: for some reason quantities can come through as strings?
                if kwargs['cases'] is not None:
                    if kwargs['cases'] == '':
                        kwargs['cases'] = None
                    else:
                        kwargs['cases'] = decimal.Decimal(kwargs['cases'])
                if kwargs['units'] is not None:
                    if kwargs['units'] == '':
                        kwargs['units'] = None
                    else:
                        kwargs['units'] = decimal.Decimal(kwargs['units'])

                try:
                    result = self.handler.can_declare_credit(row, **kwargs)

                except Exception as error:
                    return self.json_response({'error': six.text_type(error)})

                else:
                    if result:
                        self.handler.declare_credit(row, **kwargs)

                    else:
                        return self.json_response({
                            'error': "Handler says you can't declare that credit; "
                            "not sure why"})

                self.Session.flush()
                self.Session.refresh(row)
                return self.json_response({
                    'ok': True,
                    'row': self.get_context_row(row)})

        batch = row.batch
        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': self.get_row_image_url(row),
            'allow_expired': self.handler.allow_expired_credits(),
            'allow_cases': self.handler.allow_cases(),
        }

        schema = DeclareCreditForm()
        form = forms.Form(schema=schema, request=self.request,
                          use_buefy=use_buefy)
        form.cancel_url = self.get_row_action_url('view', row)

        # credit_type
        values = [(m, m) for m in POSSIBLE_CREDIT_TYPES]
        if use_buefy:
            widget = dfwidget.SelectWidget(values=values)
        else:
            widget = forms.widgets.JQuerySelectWidget(values=values)
        form.set_widget('credit_type', widget)

        # quantity
        form.set_widget('quantity', forms.widgets.CasesUnitsWidget(
            amount_required=True, one_amount_only=True))
        form.set_vuejs_field_converter('quantity', self.vuejs_convert_quantity)

        # expiration_date
        form.set_type('expiration_date', 'date_jquery')

        if form.validate(newstyle=True):

            # handler takes care of the row receiving logic for us
            kwargs = dict(form.validated)
            kwargs['cases'] = kwargs['quantity']['cases']
            kwargs['units'] = kwargs['quantity']['units']
            del kwargs['quantity']
            try:
                result = self.handler.can_declare_credit(row, **kwargs)
            except Exception as error:
                self.request.session.flash("Handler says you can't declare that credit: {}".format(error), 'error')
            else:
                if result:
                    self.handler.declare_credit(row, **kwargs)
                    return self.redirect(self.get_row_action_url('view', row))

                self.request.session.flash("Handler says you can't declare that credit; not sure why", 'error')

        context['form'] = form
        context['dform'] = form.make_deform_form()
        context['parent_url'] = self.get_action_url('view', batch)
        context['parent_title'] = self.get_instance_title(batch)
        return self.render_to_response('declare_credit', context)

    def undeclare_credit(self):
        """
        View for un-declaring a credit, i.e. moving the credit amounts
        back into the "received" tally.
        """
        model = self.model
        row = self.get_row_instance()
        data = self.request.json_body

        # make sure edit is allowed
        if not (self.has_perm('edit_row') and self.row_editable(row)):
            raise self.forbidden()

        # figure out which credit to un-declare
        credit = None
        uuid = data.get('uuid')
        if uuid:
            credit = self.Session.query(model.PurchaseBatchCredit).get(uuid)
        if not credit:
            return {'error': "Credit not found"}

        # un-declare it
        self.batch_handler.undeclare_credit(row, credit)
        self.Session.flush()
        self.Session.refresh(row)

        return {'ok': True,
                'row': self.get_context_row(row)}

    def get_context_row(self, row):
        app = self.get_rattail_app()
        return {
            'sequence': row.sequence,
            'case_quantity': float(row.case_quantity) if row.case_quantity is not None else None,
            'ordered': self.render_row_quantity(row, 'ordered'),
            'shipped': self.render_row_quantity(row, 'shipped'),
            'received': self.render_row_quantity(row, 'received'),
            'cases_received': float(row.cases_received) if row.cases_received is not None else None,
            'units_received': float(row.units_received) if row.units_received is not None else None,
            'damaged': self.render_row_quantity(row, 'damaged'),
            'expired': self.render_row_quantity(row, 'expired'),
            'mispick': self.render_row_quantity(row, 'mispick'),
            'missing': self.render_row_quantity(row, 'missing'),
            'credits': self.get_context_credits(row),
            'invoice_total_calculated': app.render_currency(row.invoice_total_calculated),
            'status': row.STATUS[row.status_code],
        }

    def transform_unit_row(self):
        """
        View which transforms the given row, which is assumed to associate with
        a "pack" item, such that it instead associates with the "unit" item,
        with quantities adjusted accordingly.
        """
        batch = self.get_instance()

        row_uuid = self.request.params.get('row_uuid')
        row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
        if row and row.batch is batch and not row.removed:
            pass # we're good
        else:
            if self.request.method == 'POST':
                raise self.notfound()
            return {'error': "Row not found."}

        def normalize(product):
            data = {
                'upc': product.upc,
                'item_id': product.item_id,
                'description': product.description,
                'size': product.size,
                'case_quantity': None,
                'cases_received': row.cases_received,
            }
            cost = product.cost_for_vendor(batch.vendor)
            if cost:
                data['case_quantity'] = cost.case_size
            return data

        if self.request.method == 'POST':
            self.handler.transform_pack_to_unit(row)
            self.request.session.flash("Transformed pack to unit item for: {}".format(row.product))
            return self.redirect(self.get_action_url('view', batch))

        pack_data = normalize(row.product)
        pack_data['units_received'] = row.units_received
        unit_data = normalize(row.product.unit)
        unit_data['units_received'] = None
        if row.units_received:
            unit_data['units_received'] = row.units_received * row.product.pack_size
        diff = self.make_diff(pack_data, unit_data, monospace=True)
        return self.render_to_response('transform_unit_row', {
            'batch': batch,
            'row': row,
            'diff': diff,
        })

    def configure_row_form(self, f):
        super(ReceivingBatchView, self).configure_row_form(f)
        batch = self.get_instance()

        # when viewing a row which has no product reference, enable
        # the 'upc' field to help with troubleshooting
        # TODO: this maybe should be optional..?
        if self.viewing and 'upc' not in f:
            row = self.get_row_instance()
            if not row.product:
                f.append('upc')

        # allow input for certain fields only; all others are readonly
        mutable = [
            'invoice_unit_cost',
        ]
        for name in f.fields:
            if name not in mutable:
                f.set_readonly(name)

        # invoice totals
        f.set_label('invoice_total', "Invoice Total (Orig.)")
        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")

        # claims
        f.set_readonly('claims')
        if batch.is_truck_dump_parent():
            f.set_renderer('claims', self.render_parent_row_claims)
            f.set_helptext('claims', "Parent row is claimed by these child rows.")
        elif batch.is_truck_dump_child():
            f.set_renderer('claims', self.render_child_row_claims)
            f.set_helptext('claims', "Child row makes claims against these parent rows.")
        else:
            f.remove_field('claims')

        # truck_dump_status
        if self.creating or not batch.is_truck_dump_parent():
            f.remove_field('truck_dump_status')
        else:
            f.set_readonly('truck_dump_status')
            f.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)

        # misc. labels
        f.set_label('vendor_code', "Vendor Item Code")

    def render_parent_row_claims(self, row, field):
        items = []
        for claim in row.claims:
            child_row = claim.claiming_row
            child_batch = child_row.batch
            text = child_batch.id_str
            if child_batch.description:
                text = "{} ({})".format(text, child_batch.description)
            text = "{}, row {}".format(text, child_row.sequence)
            url = self.get_row_action_url('view', child_row)
            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
        return HTML.tag('ul', c=items)

    def render_child_row_claims(self, row, field):
        items = []
        for claim in row.truck_dump_claims:
            parent_row = claim.claimed_row
            parent_batch = parent_row.batch
            text = parent_batch.id_str
            if parent_batch.description:
                text = "{} ({})".format(text, parent_batch.description)
            text = "{}, row {}".format(text, parent_row.sequence)
            url = self.get_row_action_url('view', parent_row)
            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
        return HTML.tag('ul', c=items)

    def validate_row_form(self, form):

        # if normal validation fails, stop there
        if not super(ReceivingBatchView, self).validate_row_form(form):
            return False

        # if user is editing row from truck dump child, then we must further
        # validate the form to ensure whatever new amounts they've requested
        # would in fact fall within the bounds of what is available from the
        # truck dump parent batch...
        if self.editing:
            batch = self.get_instance()
            if batch.is_truck_dump_child():
                old_row = self.get_row_instance()
                case_quantity = old_row.case_quantity

                # get all "existing" (old) claim amounts
                old_claims = {}
                for claim in old_row.truck_dump_claims:
                    for key in self.claim_keys:
                        amount = getattr(claim, key)
                        if amount is not None:
                            old_claims[key] = old_claims.get(key, 0) + amount

                # get all "proposed" (new) claim amounts
                new_claims = {}
                for key in self.claim_keys:
                    amount = form.validated[key]
                    if amount is not colander.null and amount is not None:
                        # do not allow user to request a negative claim amount
                        if amount < 0:
                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
                            return False
                        new_claims[key] = amount

                # figure out what changes are actually being requested
                claim_diff = {}
                for key in new_claims:
                    if key not in old_claims:
                        claim_diff[key] = new_claims[key]
                    elif new_claims[key] != old_claims[key]:
                        claim_diff[key] = new_claims[key] - old_claims[key]
                        # do not allow user to request a negative claim amount
                        if claim_diff[key] < (0 - old_claims[key]):
                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
                            return False
                for key in old_claims:
                    if key not in new_claims:
                        claim_diff[key] = 0 - old_claims[key]

                # find all rows from truck dump parent which "may" pertain to child row
                # TODO: perhaps would need to do a more "loose" match on UPC also?
                if not old_row.product_uuid:
                    raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
                parent_rows = [row for row in batch.truck_dump_batch.active_rows()
                               if row.product_uuid == old_row.product_uuid]

                # NOTE: "confirmed" are the proper amounts which exist in the
                # parent batch.  "claimed" are the amounts claimed by this row.

                # get existing "confirmed" and "claimed" amounts for all
                # (possibly related) truck dump parent rows
                confirmed = {}
                claimed = {}
                for parent_row in parent_rows:
                    for key in self.claim_keys:
                        amount = getattr(parent_row, key)
                        if amount is not None:
                            confirmed[key] = confirmed.get(key, 0) + amount
                    for claim in parent_row.claims:
                        for key in self.claim_keys:
                            amount = getattr(claim, key)
                            if amount is not None:
                                claimed[key] = claimed.get(key, 0) + amount

                # now to see if user's request is possible, given what is
                # available...

                # first we must (pretend to) "relinquish" any claims which are
                # to be reduced or eliminated, according to our diff
                for key, amount in claim_diff.items():
                    if amount < 0:
                        amount = abs(amount) # make positive, for more readable math
                        if key not in claimed or claimed[key] < amount:
                            self.request.session.flash("Cannot relinquish more claims than the "
                                                       "parent batch has to offer.", 'error')
                            return False
                        claimed[key] -= amount

                # next we must determine if any "new" requests would increase
                # the claim(s) beyond what is available
                for key, amount in claim_diff.items():
                    if amount > 0:
                        claimed[key] = claimed.get(key, 0) + amount
                        if key not in confirmed or confirmed[key] < claimed[key]:
                            self.request.session.flash("Cannot request to claim more product than "
                                                       "is available in Truck Dump Parent batch", 'error')
                            return False

                # looks like the claim diff is all good, so let's attach that
                # to the form now and then pick this up again in save()
                form._claim_diff = claim_diff

        # all validation went ok
        return True

    def save_edit_row_form(self, form):
        batch = self.get_instance()
        row = self.objectify(form)

        # editing a row for truck dump child batch can be complicated...
        if batch.is_truck_dump_child():

            # grab the claim diff which we attached to the form during validation
            claim_diff = form._claim_diff

            # first we must "relinquish" any claims which are to be reduced or
            # eliminated, according to our diff
            for key, amount in claim_diff.items():
                if amount < 0:
                    amount = abs(amount) # make positive, for more readable math

                    # we'd prefer to find an exact match, i.e. there was a 1CS
                    # claim and our diff said to reduce by 1CS
                    matches = [claim for claim in row.truck_dump_claims
                               if getattr(claim, key) == amount]
                    if matches:
                        claim = matches[0]
                        setattr(claim, key, None)

                    else:
                        # but if no exact match(es) then we'll just whittle
                        # away at whatever (smallest) claims we do find
                        possible = [claim for claim in row.truck_dump_claims
                                    if getattr(claim, key) is not None]
                        for claim in sorted(possible, key=lambda claim: getattr(claim, key)):
                            previous = getattr(claim, key)
                            if previous:
                                if previous >= amount:
                                    if (previous - amount):
                                        setattr(claim, key, previous - amount)
                                    else:
                                        setattr(claim, key, None)
                                    amount = 0
                                    break
                                else:
                                    setattr(claim, key, None)
                                    amount -= previous

                        if amount:
                            raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)")

            # next we must stake all new claim(s) as requested, per our diff
            for key, amount in claim_diff.items():
                if amount > 0:

                    # if possible, we'd prefer to add to an existing claim
                    # which already has an amount for this key
                    existing = [claim for claim in row.truck_dump_claims
                                if getattr(claim, key) is not None]
                    if existing:
                        claim = existing[0]
                        setattr(claim, key, getattr(claim, key) + amount)

                    # next we'd prefer to add to an existing claim, of any kind
                    elif row.truck_dump_claims:
                        claim = row.truck_dump_claims[0]
                        setattr(claim, key, (getattr(claim, key) or 0) + amount)

                    else:
                        # otherwise we must create a new claim...

                        # find all rows from truck dump parent which "may" pertain to child row
                        # TODO: perhaps would need to do a more "loose" match on UPC also?
                        if not row.product_uuid:
                            raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
                        parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
                                       if parent_row.product_uuid == row.product_uuid]

                        # remove any parent rows which are fully claimed
                        # TODO: should perhaps leverage actual amounts for this, instead
                        parent_rows = [parent_row for parent_row in parent_rows
                                       if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED]

                        # try to find a parent row which is exact match on claim amount
                        matches = [parent_row for parent_row in parent_rows
                                   if getattr(parent_row, key) == amount]
                        if matches:

                            # make the claim against first matching parent row
                            claim = model.PurchaseBatchRowClaim()
                            claim.claimed_row = parent_rows[0]
                            setattr(claim, key, amount)
                            row.truck_dump_claims.append(claim)

                        else:
                            # but if no exact match(es) then we'll just whittle
                            # away at whatever (smallest) parent rows we do find
                            for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)):

                                available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims])
                                if available:
                                    if available >= amount:
                                        # make claim against this parent row, making it fully claimed
                                        claim = model.PurchaseBatchRowClaim()
                                        claim.claimed_row = parent_row
                                        setattr(claim, key, amount)
                                        row.truck_dump_claims.append(claim)
                                        amount = 0
                                        break
                                    else:
                                        # make partial claim against this parent row
                                        claim = model.PurchaseBatchRowClaim()
                                        claim.claimed_row = parent_row
                                        setattr(claim, key, available)
                                        row.truck_dump_claims.append(claim)
                                        amount -= available

                            if amount:
                                raise NotImplementedError("Had leftover amount when \"staking\" claim(s)")

            # now we must be sure to refresh all truck dump parent batch rows
            # which were affected.  but along with that we also should purge
            # any empty claims, i.e. those which were fully relinquished
            pending_refresh = set()
            for claim in list(row.truck_dump_claims):
                parent_row = claim.claimed_row
                if claim.is_empty():
                    row.truck_dump_claims.remove(claim)
                    self.Session.flush()
                pending_refresh.add(parent_row)
            for parent_row in pending_refresh:
                self.handler.refresh_row(parent_row)
            self.handler.refresh_batch_status(batch.truck_dump_batch)

        self.after_edit_row(row)
        self.Session.flush()
        return row

    def redirect_after_edit_row(self, row, **kwargs):
        return self.redirect(self.get_row_action_url('view', row))

    def update_row_cost(self):
        """
        AJAX view for updating various cost fields in a data row.
        """
        batch = self.get_instance()
        data = dict(get_form_data(self.request))

        # validate row
        uuid = data.get('row_uuid')
        row = self.Session.query(model.PurchaseBatchRow).get(uuid) if uuid else None
        if not row or row.batch is not batch:
            return {'error': "Row not found"}

        # validate/normalize cost value(s)
        for field in ('catalog_unit_cost', 'invoice_unit_cost'):
            if field in data:
                cost = data[field]
                if cost == '':
                    return {'error': "You must specify a cost"}
                try:
                    cost = decimal.Decimal(six.text_type(cost))
                except decimal.InvalidOperation:
                    return {'error': "Cost is not valid!"}
                else:
                    data[field] = cost

        # okay, update our row
        self.handler.update_row_cost(row, **data)

        return {
            'row': {
                'catalog_unit_cost': '{:0.3f}'.format(row.catalog_unit_cost),
                'catalog_cost_confirmed': row.catalog_cost_confirmed,
                'invoice_unit_cost': '{:0.3f}'.format(row.invoice_unit_cost),
                'invoice_cost_confirmed': row.invoice_cost_confirmed,
                'invoice_total_calculated': '{:0.2f}'.format(row.invoice_total_calculated),
            },
            'batch': {
                'invoice_total_calculated': '{:0.2f}'.format(batch.invoice_total_calculated),
            },
        }

    def save_quick_row_form(self, form):
        batch = self.get_instance()
        entry = form.validated['quick_entry']
        row = self.handler.quick_entry(self.Session(), batch, entry)
        return row

    def get_row_image_url(self, row):
        if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
            return pod.get_image_url(self.rattail_config, row.upc)

    def can_auto_receive(self, batch):
        return self.handler.can_auto_receive(batch)

    def auto_receive(self):
        """
        View which can "auto-receive" all items in the batch.  Meant only as a
        convenience for developers.
        """
        batch = self.get_instance()
        key = '{}.receive_all'.format(self.get_grid_key())
        progress = self.make_progress(key)
        kwargs = {'progress': progress}
        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
        thread.start()

        return self.render_progress(progress, {
            'instance': batch,
            'cancel_url': self.get_action_url('view', batch),
            'cancel_msg': "Auto-receive was canceled",
        })

    def auto_receive_thread(self, uuid, user_uuid, progress=None):
        """
        Thread target for receiving all items on the given batch.
        """
        session = RattailSession()
        batch = session.query(model.PurchaseBatch).get(uuid)
        # user = session.query(model.User).get(user_uuid)
        try:
            self.handler.auto_receive_all_items(batch, progress=progress)

        # if anything goes wrong, rollback and log the error etc.
        except Exception as error:
            session.rollback()
            log.exception("auto-receive failed for: %s".format(batch))
            session.close()
            if progress:
                progress.session.load()
                progress.session['error'] = True
                progress.session['error_msg'] = "Auto-receive failed: {}".format(
                    simple_error(error))
                progress.session.save()

        # if no error, check result flag (false means user canceled)
        else:
            session.commit()
            session.refresh(batch)
            success_url = self.get_action_url('view', batch)
            session.close()
            if progress:
                progress.session.load()
                progress.session['complete'] = True
                progress.session['success_url'] = success_url
                progress.session.save()

    def configure_get_simple_settings(self):
        config = self.rattail_config
        return [

            # workflows
            {'section': 'rattail.batch',
             'option': 'purchase.allow_receiving_from_scratch',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_receiving_from_invoice',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_receiving_from_multi_invoice',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_receiving_from_purchase_order',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_receiving_from_purchase_order_with_invoice',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_truck_dump_receiving',
             'type': bool},

            # vendors
            {'section': 'rattail.batch',
             'option': 'purchase.supported_vendors_only',
             'type': bool},

            # display
            {'section': 'rattail.batch',
             'option': 'purchase.receiving.show_ordered_column_in_grid',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.receiving.show_shipped_column_in_grid',
             'type': bool},

            # product handling
            {'section': 'rattail.batch',
             'option': 'purchase.allow_cases',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.allow_expired_credits',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.receiving.should_autofix_invoice_case_vs_unit',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.receiving.allow_edit_catalog_unit_cost',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.receiving.allow_edit_invoice_unit_cost',
             'type': bool},

            # mobile interface
            {'section': 'rattail.batch',
             'option': 'purchase.mobile_images',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.mobile_quick_receive',
             'type': bool},
            {'section': 'rattail.batch',
             'option': 'purchase.mobile_quick_receive_all',
             'type': bool},
        ]

    @classmethod
    def defaults(cls, config):
        cls._receiving_defaults(config)
        cls._purchasing_defaults(config)
        cls._batch_defaults(config)
        cls._defaults(config)

    @classmethod
    def _receiving_defaults(cls, config):
        rattail_config = config.registry.settings.get('rattail_config')
        route_prefix = cls.get_route_prefix()
        url_prefix = cls.get_url_prefix()
        instance_url_prefix = cls.get_instance_url_prefix()
        model_key = cls.get_model_key()
        model_title = cls.get_model_title()
        permission_prefix = cls.get_permission_prefix()

        # new receiving 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))

        # row-level receiving
        config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
        config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
                        permission='{}.edit_row'.format(permission_prefix))

        # declare credit for row
        config.add_route('{}.declare_credit'.format(route_prefix), '{}/rows/{{row_uuid}}/declare-credit'.format(instance_url_prefix))
        config.add_view(cls, attr='declare_credit', route_name='{}.declare_credit'.format(route_prefix),
                        permission='{}.edit_row'.format(permission_prefix))

        # un-declare credit
        config.add_route('{}.undeclare_credit'.format(route_prefix),
                         '{}/rows/{{row_uuid}}/undeclare-credit'.format(instance_url_prefix))
        config.add_view(cls, attr='undeclare_credit',
                        route_name='{}.undeclare_credit'.format(route_prefix),
                        permission='{}.edit_row'.format(permission_prefix),
                        renderer='json')

        # update row cost
        config.add_route('{}.update_row_cost'.format(route_prefix), '{}/update-row-cost'.format(instance_url_prefix))
        config.add_view(cls, attr='update_row_cost', route_name='{}.update_row_cost'.format(route_prefix),
                        permission='{}.edit_row'.format(permission_prefix),
                        renderer='json')

        # add TD child batch, from invoice file
        config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/add-child-from-invoice'.format(instance_url_prefix))
        config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
                        permission='{}.create'.format(permission_prefix))

        # transform TD parent row from "pack" to "unit" item
        config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/transform-unit'.format(instance_url_prefix))
        config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
                        permission='{}.edit_row'.format(permission_prefix), renderer='json')

        # auto-receive all items
        config.add_tailbone_permission(permission_prefix,
                                       '{}.auto_receive'.format(permission_prefix),
                                       "Auto-receive all items for a {}".format(model_title))
        config.add_route('{}.auto_receive'.format(route_prefix), '{}/auto-receive'.format(instance_url_prefix),
                         request_method='POST')
        config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
                        permission='{}.auto_receive'.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 NewReceivingBatch(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)


class ReceiveRowForm(colander.MappingSchema):

    mode = colander.SchemaNode(colander.String(),
                               validator=colander.OneOf(
                                   POSSIBLE_RECEIVING_MODES))

    quantity = forms.types.ProductQuantity()

    expiration_date = colander.SchemaNode(colander.Date(),
                                          widget=dfwidget.TextInputWidget(),
                                          missing=colander.null)

    quick_receive = colander.SchemaNode(colander.Boolean())

    def deserialize(self, *args):
        result = super(ReceiveRowForm, self).deserialize(*args)

        if result['mode'] == 'expired' and not result['expiration_date']:
            msg = "Expiration date is required for items with 'expired' mode."
            self.raise_invalid(msg, node=self.get('expiration_date'))

        return result


class DeclareCreditForm(colander.MappingSchema):

    credit_type = colander.SchemaNode(colander.String(),
                                      validator=colander.OneOf(
                                          POSSIBLE_CREDIT_TYPES))

    quantity = forms.types.ProductQuantity()

    expiration_date = colander.SchemaNode(colander.Date(),
                                          widget=dfwidget.TextInputWidget(),
                                          missing=colander.null)


def defaults(config, **kwargs):
    base = globals()

    ReceivingBatchView = kwargs.get('ReceivingBatchView', base['ReceivingBatchView'])
    ReceivingBatchView.defaults(config)


def includeme(config):
    defaults(config)
