source: tailbone/tailbone/views/purchasing/receiving.py @ 4312238

Last change on this file since 4312238 was 4312238, checked in by Lance Edgar <ledgar@…>, 9 months ago

Add mobile alert when receiving product for 2nd time

optional per config. idea is to alert user so they don't accidentally
double-receive a given item

  • Property mode set to 100644
File size: 72.7 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2019 Lance Edgar
6#
7#  This file is part of Rattail.
8#
9#  Rattail is free software: you can redistribute it and/or modify it under the
10#  terms of the GNU General Public License as published by the Free Software
11#  Foundation, either version 3 of the License, or (at your option) any later
12#  version.
13#
14#  Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
15#  WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16#  FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
17#  details.
18#
19#  You should have received a copy of the GNU General Public License along with
20#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Views for 'receiving' (purchasing) batches
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import re
30import logging
31
32import six
33import sqlalchemy as sa
34
35from rattail import pod
36from rattail.db import model, api, Session as RattailSession
37from rattail.db.util import maxlen
38from rattail.gpc import GPC
39from rattail.time import localtime
40from rattail.util import pretty_quantity, prettify, OrderedDict
41from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser
42from rattail.threads import Thread
43
44import colander
45from deform import widget as dfwidget
46from pyramid import httpexceptions
47from webhelpers2.html import tags, HTML
48
49from tailbone import forms, grids
50from tailbone.views.purchasing import PurchasingBatchView
51from tailbone.progress import SessionProgress
52
53
54log = logging.getLogger(__name__)
55
56
57class MobileItemStatusFilter(grids.filters.MobileFilter):
58
59    value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'all']
60
61    def filter_equal(self, query, value):
62
63        # NOTE: this is only relevant for truck dump or "from scratch"
64        if value == 'received':
65            return query.filter(sa.or_(
66                model.PurchaseBatchRow.cases_received != 0,
67                model.PurchaseBatchRow.units_received != 0))
68
69        if value == 'incomplete':
70            # looking for any rows with "ordered" quantity, but where the
71            # status does *not* signify a "settled" row so to speak
72            # TODO: would be nice if we had a simple flag to leverage?
73            return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0,
74                                       model.PurchaseBatchRow.units_ordered != 0))\
75                        .filter(~model.PurchaseBatchRow.status_code.in_((
76                            model.PurchaseBatchRow.STATUS_OK,
77                            model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
78                            model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS)))
79
80        if value == 'invalid':
81            return query.filter(model.PurchaseBatchRow.status_code.in_((
82                model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND,
83                model.PurchaseBatchRow.STATUS_COST_NOT_FOUND,
84                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_UNKNOWN,
85                model.PurchaseBatchRow.STATUS_CASE_QUANTITY_DIFFERS,
86            )))
87
88        if value == 'unexpected':
89            # looking for any rows which have "received" quantity but which
90            # do *not* have any "ordered" quantity
91            return query.filter(sa.and_(
92                sa.or_(
93                    model.PurchaseBatchRow.cases_ordered == None,
94                    model.PurchaseBatchRow.cases_ordered == 0),
95                sa.or_(
96                    model.PurchaseBatchRow.units_ordered == None,
97                    model.PurchaseBatchRow.units_ordered == 0),
98                sa.or_(
99                    model.PurchaseBatchRow.cases_received != 0,
100                    model.PurchaseBatchRow.units_received != 0,
101                    model.PurchaseBatchRow.cases_damaged != 0,
102                    model.PurchaseBatchRow.units_damaged != 0,
103                    model.PurchaseBatchRow.cases_expired != 0,
104                    model.PurchaseBatchRow.units_expired != 0)))
105
106        if value == 'damaged':
107            return query.filter(sa.or_(
108                model.PurchaseBatchRow.cases_damaged != 0,
109                model.PurchaseBatchRow.units_damaged != 0))
110
111        if value == 'expired':
112            return query.filter(sa.or_(
113                model.PurchaseBatchRow.cases_expired != 0,
114                model.PurchaseBatchRow.units_expired != 0))
115
116        return query
117
118    def iter_choices(self):
119        for value in self.value_choices:
120            yield value, prettify(value)
121
122
123class ReceivingBatchView(PurchasingBatchView):
124    """
125    Master view for receiving batches
126    """
127    route_prefix = 'receiving'
128    url_prefix = '/receiving'
129    model_title = "Receiving Batch"
130    model_title_plural = "Receiving Batches"
131    index_title = "Receiving"
132    downloadable = True
133    rows_editable = True
134    mobile_creatable = True
135    mobile_rows_filterable = True
136    mobile_rows_creatable = True
137    mobile_rows_quickable = True
138    mobile_rows_deletable = True
139
140    allow_from_po = False
141    allow_from_scratch = True
142    allow_truck_dump = False
143
144    default_uom_is_case = True
145
146    purchase_order_fieldname = 'purchase'
147
148    labels = {
149        'truck_dump_batch': "Truck Dump Parent",
150        'invoice_parser_key': "Invoice Parser",
151    }
152
153    grid_columns = [
154        'id',
155        'vendor',
156        'truck_dump',
157        'description',
158        'department',
159        'buyer',
160        'date_ordered',
161        'created',
162        'created_by',
163        'rowcount',
164        'invoice_total_calculated',
165        'status_code',
166        'executed',
167    ]
168
169    form_fields = [
170        'id',
171        'batch_type',
172        'store',
173        'vendor',
174        'description',
175        'truck_dump',
176        'truck_dump_children_first',
177        'truck_dump_children',
178        'truck_dump_ready',
179        'truck_dump_batch',
180        'invoice_file',
181        'invoice_parser_key',
182        'department',
183        'purchase',
184        'vendor_email',
185        'vendor_fax',
186        'vendor_contact',
187        'vendor_phone',
188        'date_ordered',
189        'date_received',
190        'po_number',
191        'po_total',
192        'invoice_date',
193        'invoice_number',
194        'invoice_total',
195        'invoice_total_calculated',
196        'notes',
197        'created',
198        'created_by',
199        'status_code',
200        'truck_dump_status',
201        'rowcount',
202        'order_quantities_known',
203        'complete',
204        'executed',
205        'executed_by',
206    ]
207
208    mobile_form_fields = [
209        'vendor',
210        'department',
211    ]
212
213    row_grid_columns = [
214        'sequence',
215        'upc',
216        # 'item_id',
217        'brand_name',
218        'description',
219        'size',
220        'department_name',
221        'cases_ordered',
222        'units_ordered',
223        'cases_received',
224        'units_received',
225        # 'po_total',
226        'invoice_total_calculated',
227        'credits',
228        'status_code',
229        'truck_dump_status',
230    ]
231
232    row_form_fields = [
233        'item_entry',
234        'upc',
235        'item_id',
236        'product',
237        'brand_name',
238        'description',
239        'size',
240        'case_quantity',
241        'cases_ordered',
242        'units_ordered',
243        'cases_received',
244        'units_received',
245        'cases_damaged',
246        'units_damaged',
247        'cases_expired',
248        'units_expired',
249        'cases_mispick',
250        'units_mispick',
251        'po_line_number',
252        'po_unit_cost',
253        'po_total',
254        'invoice_line_number',
255        'invoice_unit_cost',
256        'invoice_total',
257        'invoice_total_calculated',
258        'status_code',
259        'truck_dump_status',
260        'claims',
261        'credits',
262    ]
263
264    # convenience list of all quantity attributes involved for a truck dump claim
265    claim_keys = [
266        'cases_received',
267        'units_received',
268        'cases_damaged',
269        'units_damaged',
270        'cases_expired',
271        'units_expired',
272    ]
273
274    @property
275    def batch_mode(self):
276        return self.enum.PURCHASE_BATCH_MODE_RECEIVING
277
278    def row_deletable(self, row):
279        batch = row.batch
280
281        # can always delete rows from truck dump parent
282        if batch.is_truck_dump_parent():
283            return True
284
285        # can always delete rows from truck dump child
286        elif batch.is_truck_dump_child():
287            return True
288
289        else: # okay, normal batch
290            if batch.order_quantities_known:
291                return False
292            else: # allow delete if receiving rom scratch
293                return True
294
295        # cannot delete row by default
296        return False
297
298    def get_instance_title(self, batch):
299        title = super(ReceivingBatchView, self).get_instance_title(batch)
300        if batch.is_truck_dump_parent():
301            title = "{} (TRUCK DUMP PARENT)".format(title)
302        elif batch.is_truck_dump_child():
303            title = "{} (TRUCK DUMP CHILD)".format(title)
304        return title
305
306    def configure_form(self, f):
307        super(ReceivingBatchView, self).configure_form(f)
308        batch = f.model_instance
309
310        # batch_type
311        if self.creating:
312            batch_types = OrderedDict()
313            if self.allow_from_scratch:
314                batch_types['from_scratch'] = "From Scratch"
315            if self.allow_from_po:
316                batch_types['from_po'] = "From PO"
317            if self.allow_truck_dump:
318                batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)"
319                batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)"
320            f.set_enum('batch_type', batch_types)
321        else:
322            f.remove_field('batch_type')
323
324        # truck_dump*
325        if self.allow_truck_dump:
326
327            # truck_dump
328            if self.creating or not batch.is_truck_dump_parent():
329                f.remove_field('truck_dump')
330            else:
331                f.set_readonly('truck_dump')
332
333            # truck_dump_children_first
334            if self.creating or not batch.is_truck_dump_parent():
335                f.remove_field('truck_dump_children_first')
336
337            # truck_dump_children
338            if self.viewing and batch.is_truck_dump_parent():
339                f.set_renderer('truck_dump_children', self.render_truck_dump_children)
340            else:
341                f.remove_field('truck_dump_children')
342
343            # truck_dump_ready
344            if self.creating or not batch.is_truck_dump_parent():
345                f.remove_field('truck_dump_ready')
346
347            # truck_dump_status
348            if self.creating or not batch.is_truck_dump_parent():
349                f.remove_field('truck_dump_status')
350            else:
351                f.set_readonly('truck_dump_status')
352                f.set_enum('truck_dump_status', model.PurchaseBatch.STATUS)
353
354            # truck_dump_batch
355            if self.creating:
356                f.replace('truck_dump_batch', 'truck_dump_batch_uuid')
357                batches = self.Session.query(model.PurchaseBatch)\
358                                      .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
359                                      .filter(model.PurchaseBatch.truck_dump == True)\
360                                      .filter(model.PurchaseBatch.complete == True)\
361                                      .filter(model.PurchaseBatch.executed == None)\
362                                      .order_by(model.PurchaseBatch.id)
363                batch_values = [(b.uuid, "({}) {}, {}".format(b.id_str, b.date_received, b.vendor))
364                                for b in batches]
365                batch_values.insert(0, ('', "(please choose)"))
366                f.set_widget('truck_dump_batch_uuid', forms.widgets.JQuerySelectWidget(values=batch_values))
367                f.set_label('truck_dump_batch_uuid', "Truck Dump Parent")
368            elif batch.is_truck_dump_child():
369                f.set_readonly('truck_dump_batch')
370                f.set_renderer('truck_dump_batch', self.render_truck_dump_batch)
371            else:
372                f.remove_field('truck_dump_batch')
373
374            # truck_dump_vendor
375            if self.creating:
376                f.set_label('truck_dump_vendor', "Vendor")
377                f.set_readonly('truck_dump_vendor')
378                f.set_renderer('truck_dump_vendor', self.render_truck_dump_vendor)
379
380        else:
381            f.remove_fields('truck_dump',
382                            'truck_dump_children_first',
383                            'truck_dump_children',
384                            'truck_dump_ready',
385                            'truck_dump_status',
386                            'truck_dump_batch')
387
388        # invoice_file
389        if self.creating:
390            f.set_type('invoice_file', 'file', required=False)
391        else:
392            f.set_readonly('invoice_file')
393            f.set_renderer('invoice_file', self.render_downloadable_file)
394
395        # invoice_parser_key
396        if self.creating:
397            parsers = sorted(iter_invoice_parsers(), key=lambda p: p.display)
398            parser_values = [(p.key, p.display) for p in parsers]
399            parser_values.insert(0, ('', "(please choose)"))
400            f.set_widget('invoice_parser_key', forms.widgets.JQuerySelectWidget(values=parser_values))
401        else:
402            f.remove_field('invoice_parser_key')
403
404        # store
405        if self.creating:
406            store = self.rattail_config.get_store(self.Session())
407            f.set_default('store_uuid', store.uuid)
408            # TODO: seems like set_hidden() should also set HiddenWidget
409            f.set_hidden('store_uuid')
410            f.set_widget('store_uuid', dfwidget.HiddenWidget())
411
412        # purchase
413        if self.creating:
414            f.remove_field('purchase')
415
416        # department
417        if self.creating:
418            f.remove_field('department_uuid')
419
420        # order_quantities_known
421        if not self.editing:
422            f.remove_field('order_quantities_known')
423
424        # invoice totals
425        f.set_label('invoice_total', "Invoice Total (Orig.)")
426        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
427
428    def template_kwargs_create(self, **kwargs):
429        kwargs = super(ReceivingBatchView, self).template_kwargs_create(**kwargs)
430        if self.allow_truck_dump:
431            vmap = {}
432            batches = self.Session.query(model.PurchaseBatch)\
433                                  .filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)\
434                                  .filter(model.PurchaseBatch.truck_dump == True)\
435                                  .filter(model.PurchaseBatch.complete == True)
436            for batch in batches:
437                vmap[batch.uuid] = batch.vendor_uuid
438            kwargs['batch_vendor_map'] = vmap
439        return kwargs
440
441    def get_batch_kwargs(self, batch, mobile=False):
442        kwargs = super(ReceivingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
443        if not mobile:
444            batch_type = self.request.POST['batch_type']
445            if batch_type == 'from_scratch':
446                kwargs.pop('truck_dump_batch', None)
447                kwargs.pop('truck_dump_batch_uuid', None)
448            elif batch_type == 'truck_dump_children_first':
449                kwargs['truck_dump'] = True
450                kwargs['truck_dump_children_first'] = True
451                kwargs['order_quantities_known'] = True
452                # TODO: this makes sense in some cases, but all?
453                # (should just omit that field when not relevant)
454                kwargs['date_ordered'] = None
455            elif batch_type == 'truck_dump_children_last':
456                kwargs['truck_dump'] = True
457                kwargs['truck_dump_ready'] = True
458                # TODO: this makes sense in some cases, but all?
459                # (should just omit that field when not relevant)
460                kwargs['date_ordered'] = None
461            elif batch_type.startswith('truck_dump_child'):
462                truck_dump = self.get_instance()
463                kwargs['store'] = truck_dump.store
464                kwargs['vendor'] = truck_dump.vendor
465                kwargs['truck_dump_batch'] = truck_dump
466            else:
467                raise NotImplementedError
468        return kwargs
469
470    def department_for_purchase(self, purchase):
471        pass
472
473    def delete_instance(self, batch):
474        """
475        Delete all data (files etc.) for the batch.
476        """
477        truck_dump = batch.truck_dump_batch
478        if batch.is_truck_dump_parent():
479            for child in batch.truck_dump_children:
480                self.delete_instance(child)
481        super(ReceivingBatchView, self).delete_instance(batch)
482        if truck_dump:
483            self.handler.refresh(truck_dump)
484
485    def render_truck_dump_batch(self, batch, field):
486        truck_dump = batch.truck_dump_batch
487        if not truck_dump:
488            return ""
489        text = "({}) {}".format(truck_dump.id_str, truck_dump.description or '')
490        url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
491        return tags.link_to(text, url)
492
493    def render_truck_dump_vendor(self, batch, field):
494        truck_dump = self.get_instance()
495        vendor = truck_dump.vendor
496        text = "({}) {}".format(vendor.id, vendor.name)
497        url = self.request.route_url('vendors.view', uuid=vendor.uuid)
498        return tags.link_to(text, url)
499
500    def render_truck_dump_children(self, batch, field):
501        contents = []
502        children = batch.truck_dump_children
503        if children:
504            items = []
505            for child in children:
506                text = "({}) {}".format(child.id_str, child.description or '')
507                url = self.request.route_url('receiving.view', uuid=child.uuid)
508                items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
509            contents.append(HTML.tag('ul', c=items))
510        if not batch.executed and (batch.complete or batch.truck_dump_children_first):
511            buttons = self.make_truck_dump_child_buttons(batch)
512            if buttons:
513                buttons = HTML.literal(' ').join(buttons)
514                contents.append(HTML.tag('div', class_='buttons', c=[buttons]))
515        if not contents:
516            return ""
517        return HTML.tag('div', c=contents)
518
519    def make_truck_dump_child_buttons(self, batch):
520        return [
521            tags.link_to("Add from Invoice File", self.get_action_url('add_child_from_invoice', batch), class_='button autodisable'),
522        ]
523
524    def add_child_from_invoice(self):
525        """
526        View for adding a child batch to a truck dump, from invoice file.
527        """
528        batch = self.get_instance()
529        if not batch.is_truck_dump_parent():
530            self.request.session.flash("Batch is not a truck dump: {}".format(batch))
531            return self.redirect(self.get_action_url('view', batch))
532        if batch.executed:
533            self.request.session.flash("Batch has already been executed: {}".format(batch))
534            return self.redirect(self.get_action_url('view', batch))
535        if not batch.complete and not batch.truck_dump_children_first:
536            self.request.session.flash("Batch is not marked as complete: {}".format(batch))
537            return self.redirect(self.get_action_url('view', batch))
538        self.creating = True
539        form = self.make_child_from_invoice_form(self.get_model_class())
540        return self.create(form=form)
541
542    def make_child_from_invoice_form(self, instance, **kwargs):
543        """
544        Creates a new form for the given model class/instance
545        """
546        kwargs['configure'] = self.configure_child_from_invoice_form
547        return self.make_form(instance=instance, **kwargs)
548
549    def configure_child_from_invoice_form(self, f):
550        assert self.creating
551        truck_dump = self.get_instance()
552
553        self.configure_form(f)
554
555        f.set_fields([
556            'batch_type',
557            'truck_dump_parent',
558            'truck_dump_vendor',
559            'invoice_file',
560            'invoice_parser_key',
561            'invoice_number',
562            'description',
563            'notes',
564        ])
565
566        # batch_type
567        f.set_widget('batch_type', forms.widgets.ReadonlyWidget())
568        f.set_default('batch_type', 'truck_dump_child_from_invoice')
569
570        # truck_dump_batch_uuid
571        f.set_readonly('truck_dump_parent')
572        f.set_renderer('truck_dump_parent', self.render_truck_dump_parent)
573
574    def render_truck_dump_parent(self, batch, field):
575        truck_dump = self.get_instance()
576        text = six.text_type(truck_dump)
577        url = self.request.route_url('receiving.view', uuid=truck_dump.uuid)
578        return tags.link_to(text, url)
579
580    def render_mobile_listitem(self, batch, i):
581        title = "({}) {} for ${:0,.2f} - {}, {}".format(
582            batch.id_str,
583            batch.vendor,
584            batch.invoice_total or batch.po_total or 0,
585            batch.department,
586            batch.created_by)
587        return title
588
589    def make_mobile_row_filters(self):
590        """
591        Returns a set of filters for the mobile row grid.
592        """
593        batch = self.get_instance()
594        filters = grids.filters.GridFilterSet()
595
596        # visible filter options will depend on whether batch came from purchase
597        if batch.order_quantities_known:
598            value_choices = ['incomplete', 'unexpected', 'damaged', 'expired', 'invalid', 'all']
599            default_status = 'incomplete'
600        else:
601            value_choices = ['received', 'damaged', 'expired', 'invalid', 'all']
602            default_status = 'all'
603
604        # remove 'expired' filter option if not relevant
605        if 'expired' in value_choices and not self.handler.allow_expired_credits():
606            value_choices.remove('expired')
607
608        filters['status'] = MobileItemStatusFilter('status',
609                                                   value_choices=value_choices,
610                                                   default_value=default_status)
611        return filters
612
613    def get_purchase(self, uuid):
614        return self.Session.query(model.Purchase).get(uuid)
615
616    def mobile_create(self):
617        """
618        Mobile view for creating a new receiving batch
619        """
620        mode = self.batch_mode
621        data = {'mode': mode}
622        phase = 1
623
624        schema = MobileNewReceivingBatch().bind(session=self.Session())
625        form = forms.Form(schema=schema, request=self.request)
626        if form.validate(newstyle=True):
627            phase = form.validated['phase']
628
629            if form.validated['workflow'] == 'from_scratch':
630                if not self.allow_from_scratch:
631                    raise NotImplementedError("Requested workflow not supported: from_scratch")
632                batch = self.model_class()
633                batch.store = self.rattail_config.get_store(self.Session())
634                batch.mode = mode
635                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
636                batch.created_by = self.request.user
637                batch.date_received = localtime(self.rattail_config).date()
638                kwargs = self.get_batch_kwargs(batch, mobile=True)
639                batch = self.handler.make_batch(self.Session(), **kwargs)
640                return self.redirect(self.get_action_url('view', batch, mobile=True))
641
642            elif form.validated['workflow'] == 'truck_dump':
643                if not self.allow_truck_dump:
644                    raise NotImplementedError("Requested workflow not supported: truck_dump")
645                batch = self.model_class()
646                batch.store = self.rattail_config.get_store(self.Session())
647                batch.mode = mode
648                batch.truck_dump = True
649                batch.vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
650                batch.created_by = self.request.user
651                batch.date_received = localtime(self.rattail_config).date()
652                kwargs = self.get_batch_kwargs(batch, mobile=True)
653                batch = self.handler.make_batch(self.Session(), **kwargs)
654                return self.redirect(self.get_action_url('view', batch, mobile=True))
655
656            elif form.validated['workflow'] == 'from_po':
657                if not self.allow_from_po:
658                    raise NotImplementedError("Requested workflow not supported: from_po")
659
660                vendor = self.Session.query(model.Vendor).get(form.validated['vendor'])
661                data['vendor'] = vendor
662
663                schema = self.make_mobile_receiving_from_po_schema()
664                po_form = forms.Form(schema=schema, request=self.request)
665                if phase == 2:
666                    if po_form.validate(newstyle=True):
667                        batch = self.model_class()
668                        batch.store = self.rattail_config.get_store(self.Session())
669                        batch.mode = mode
670                        batch.vendor = vendor
671                        batch.buyer = self.request.user.employee
672                        batch.created_by = self.request.user
673                        batch.date_received = localtime(self.rattail_config).date()
674                        self.assign_purchase_order(batch, po_form)
675                        kwargs = self.get_batch_kwargs(batch, mobile=True)
676                        batch = self.handler.make_batch(self.Session(), **kwargs)
677                        if self.handler.should_populate(batch):
678                            self.handler.populate(batch)
679                        return self.redirect(self.get_action_url('view', batch, mobile=True))
680
681                else:
682                    phase = 2
683
684            else:
685                raise NotImplementedError("Requested workflow not supported: {}".format(form.validated['workflow']))
686
687        data['form'] = form
688        data['dform'] = form.make_deform_form()
689        data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
690        data['phase'] = phase
691
692        if phase == 1:
693            data['vendor_use_autocomplete'] = self.rattail_config.getbool(
694                'rattail', 'vendor.use_autocomplete', default=True)
695            if not data['vendor_use_autocomplete']:
696                vendors = self.Session.query(model.Vendor)\
697                                      .order_by(model.Vendor.name)
698                options = [(tags.Option(vendor.name, vendor.uuid))
699                           for vendor in vendors]
700                options.insert(0, tags.Option("(please choose)", ''))
701                data['vendor_options'] = options
702
703        elif phase == 2:
704            purchases = self.eligible_purchases(vendor.uuid, mode=mode)
705            data['purchases'] = [(p['key'], p['display']) for p in purchases['purchases']]
706            data['purchase_order_fieldname'] = self.purchase_order_fieldname
707
708        return self.render_to_response('create', data, mobile=True)
709
710    def make_mobile_receiving_from_po_schema(self):
711        schema = colander.MappingSchema()
712        schema.add(colander.SchemaNode(colander.String(),
713                                       name=self.purchase_order_fieldname,
714                                       validator=self.validate_purchase))
715        return schema.bind(session=self.Session())
716
717    @staticmethod
718    @colander.deferred
719    def validate_purchase(node, kw):
720        session = kw['session']
721        def validate(node, value):
722            purchase = session.query(model.Purchase).get(value)
723            if not purchase:
724                raise colander.Invalid(node, "Purchase not found")
725            return purchase.uuid
726        return validate
727
728    def assign_purchase_order(self, batch, po_form):
729        """
730        Assign the original purchase order to the given batch.  Default
731        behavior assumes a Rattail Purchase object is what we're after.
732        """
733        purchase = self.get_purchase(po_form.validated[self.purchase_order_fieldname])
734        if isinstance(purchase, model.Purchase):
735            batch.purchase_uuid = purchase.uuid
736
737        department = self.department_for_purchase(purchase)
738        if department:
739            batch.department_uuid = department.uuid
740
741    def configure_mobile_form(self, f):
742        super(ReceivingBatchView, self).configure_mobile_form(f)
743        batch = f.model_instance
744
745        # truck_dump
746        if not self.creating:
747            if not batch.is_truck_dump_parent():
748                f.remove_field('truck_dump')
749
750        # department
751        if not self.creating:
752            if batch.is_truck_dump_parent():
753                f.remove_field('department')
754
755    def configure_row_grid(self, g):
756        super(ReceivingBatchView, self).configure_row_grid(g)
757        g.set_label('department_name', "Department")
758
759        # credits
760        # note that sorting by credits involves a subquery with group by clause.
761        # seems likely there may be a better way? but this seems to work fine
762        Credits = self.Session.query(model.PurchaseBatchCredit.row_uuid,
763                                     sa.func.count().label('credit_count'))\
764                              .group_by(model.PurchaseBatchCredit.row_uuid)\
765                              .subquery()
766        g.set_joiner('credits', lambda q: q.outerjoin(Credits))
767        g.sorters['credits'] = lambda q, d: q.order_by(getattr(Credits.c.credit_count, d)())
768
769        # hide 'ordered' columns for truck dump parent, if its "children first"
770        # flag is set, since that batch type is only concerned with receiving
771        batch = self.get_instance()
772        if batch.is_truck_dump_parent() and not batch.truck_dump_children_first:
773            g.hide_column('cases_ordered')
774            g.hide_column('units_ordered')
775
776        # add "Transform to Unit" action, if appropriate
777        if batch.is_truck_dump_parent():
778            permission_prefix = self.get_permission_prefix()
779            if self.request.has_perm('{}.edit_row'.format(permission_prefix)):
780                transform = grids.GridAction('transform',
781                                             icon='shuffle',
782                                             label="Transform to Unit",
783                                             url=self.transform_unit_url)
784                g.more_actions.append(transform)
785                if g.main_actions and g.main_actions[-1].key == 'delete':
786                    delete = g.main_actions.pop()
787                    g.more_actions.append(delete)
788
789        # truck_dump_status
790        if not batch.is_truck_dump_parent():
791            g.hide_column('truck_dump_status')
792        else:
793            g.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
794
795    def transform_unit_url(self, row, i):
796        # grid action is shown only when we return a URL here
797        if self.row_editable(row):
798            if row.batch.is_truck_dump_parent():
799                if row.product and row.product.is_pack_item():
800                    return self.get_row_action_url('transform_unit', row)
801
802    def transform_unit_row(self):
803        """
804        View which transforms the given row, which is assumed to associate with
805        a "pack" item, such that it instead associates with the "unit" item,
806        with quantities adjusted accordingly.
807        """
808        batch = self.get_instance()
809
810        row_uuid = self.request.params.get('row_uuid')
811        row = self.Session.query(model.PurchaseBatchRow).get(row_uuid) if row_uuid else None
812        if row and row.batch is batch and not row.removed:
813            pass # we're good
814        else:
815            if self.request.method == 'POST':
816                raise self.notfound()
817            return {'error': "Row not found."}
818
819        def normalize(product):
820            data = {
821                'upc': product.upc,
822                'item_id': product.item_id,
823                'description': product.description,
824                'size': product.size,
825                'case_quantity': None,
826                'cases_received': row.cases_received,
827            }
828            cost = product.cost_for_vendor(batch.vendor)
829            if cost:
830                data['case_quantity'] = cost.case_size
831            return data
832
833        if self.request.method == 'POST':
834            self.handler.transform_pack_to_unit(row)
835            self.request.session.flash("Transformed pack to unit item for: {}".format(row.product))
836            return self.redirect(self.get_action_url('view', batch))
837
838        pack_data = normalize(row.product)
839        pack_data['units_received'] = row.units_received
840        unit_data = normalize(row.product.unit)
841        unit_data['units_received'] = None
842        if row.units_received:
843            unit_data['units_received'] = row.units_received * row.product.pack_size
844        diff = self.make_diff(pack_data, unit_data, monospace=True)
845        return self.render_to_response('transform_unit_row', {
846            'batch': batch,
847            'row': row,
848            'diff': diff,
849        })
850
851    def configure_row_form(self, f):
852        super(ReceivingBatchView, self).configure_row_form(f)
853        batch = self.get_instance()
854
855        f.set_readonly('cases_ordered')
856        f.set_readonly('units_ordered')
857        f.set_readonly('po_unit_cost')
858        f.set_readonly('po_total')
859
860        # invoice totals
861        f.set_readonly('invoice_total')
862        f.set_label('invoice_total', "Invoice Total (Orig.)")
863        f.set_readonly('invoice_total_calculated')
864        f.set_label('invoice_total_calculated', "Invoice Total (Calc.)")
865
866        # claims
867        f.set_readonly('claims')
868        if batch.is_truck_dump_parent():
869            f.set_renderer('claims', self.render_parent_row_claims)
870            f.set_helptext('claims', "Parent row is claimed by these child rows.")
871        elif batch.is_truck_dump_child():
872            f.set_renderer('claims', self.render_child_row_claims)
873            f.set_helptext('claims', "Child row makes claims against these parent rows.")
874        else:
875            f.remove_field('claims')
876
877        # truck_dump_status
878        if self.creating or not batch.is_truck_dump_parent():
879            f.remove_field('truck_dump_status')
880        else:
881            f.set_readonly('truck_dump_status')
882            f.set_enum('truck_dump_status', model.PurchaseBatchRow.STATUS)
883
884    def render_parent_row_claims(self, row, field):
885        items = []
886        for claim in row.claims:
887            child_row = claim.claiming_row
888            child_batch = child_row.batch
889            text = child_batch.id_str
890            if child_batch.description:
891                text = "{} ({})".format(text, child_batch.description)
892            text = "{}, row {}".format(text, child_row.sequence)
893            url = self.get_row_action_url('view', child_row)
894            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
895        return HTML.tag('ul', c=items)
896
897    def render_child_row_claims(self, row, field):
898        items = []
899        for claim in row.truck_dump_claims:
900            parent_row = claim.claimed_row
901            parent_batch = parent_row.batch
902            text = parent_batch.id_str
903            if parent_batch.description:
904                text = "{} ({})".format(text, parent_batch.description)
905            text = "{}, row {}".format(text, parent_row.sequence)
906            url = self.get_row_action_url('view', parent_row)
907            items.append(HTML.tag('li', c=[tags.link_to(text, url)]))
908        return HTML.tag('ul', c=items)
909
910    def validate_row_form(self, form):
911
912        # if normal validation fails, stop there
913        if not super(ReceivingBatchView, self).validate_row_form(form):
914            return False
915
916        # if user is editing row from truck dump child, then we must further
917        # validate the form to ensure whatever new amounts they've requested
918        # would in fact fall within the bounds of what is available from the
919        # truck dump parent batch...
920        if self.editing:
921            batch = self.get_instance()
922            if batch.is_truck_dump_child():
923                old_row = self.get_row_instance()
924                case_quantity = old_row.case_quantity
925
926                # get all "existing" (old) claim amounts
927                old_claims = {}
928                for claim in old_row.truck_dump_claims:
929                    for key in self.claim_keys:
930                        amount = getattr(claim, key)
931                        if amount is not None:
932                            old_claims[key] = old_claims.get(key, 0) + amount
933
934                # get all "proposed" (new) claim amounts
935                new_claims = {}
936                for key in self.claim_keys:
937                    amount = form.validated[key]
938                    if amount is not colander.null and amount is not None:
939                        # do not allow user to request a negative claim amount
940                        if amount < 0:
941                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
942                            return False
943                        new_claims[key] = amount
944
945                # figure out what changes are actually being requested
946                claim_diff = {}
947                for key in new_claims:
948                    if key not in old_claims:
949                        claim_diff[key] = new_claims[key]
950                    elif new_claims[key] != old_claims[key]:
951                        claim_diff[key] = new_claims[key] - old_claims[key]
952                        # do not allow user to request a negative claim amount
953                        if claim_diff[key] < (0 - old_claims[key]):
954                            self.request.session.flash("Cannot claim a negative amount for: {}".format(key), 'error')
955                            return False
956                for key in old_claims:
957                    if key not in new_claims:
958                        claim_diff[key] = 0 - old_claims[key]
959
960                # find all rows from truck dump parent which "may" pertain to child row
961                # TODO: perhaps would need to do a more "loose" match on UPC also?
962                if not old_row.product_uuid:
963                    raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
964                parent_rows = [row for row in batch.truck_dump_batch.active_rows()
965                               if row.product_uuid == old_row.product_uuid]
966
967                # NOTE: "confirmed" are the proper amounts which exist in the
968                # parent batch.  "claimed" are the amounts claimed by this row.
969
970                # get existing "confirmed" and "claimed" amounts for all
971                # (possibly related) truck dump parent rows
972                confirmed = {}
973                claimed = {}
974                for parent_row in parent_rows:
975                    for key in self.claim_keys:
976                        amount = getattr(parent_row, key)
977                        if amount is not None:
978                            confirmed[key] = confirmed.get(key, 0) + amount
979                    for claim in parent_row.claims:
980                        for key in self.claim_keys:
981                            amount = getattr(claim, key)
982                            if amount is not None:
983                                claimed[key] = claimed.get(key, 0) + amount
984
985                # now to see if user's request is possible, given what is
986                # available...
987
988                # first we must (pretend to) "relinquish" any claims which are
989                # to be reduced or eliminated, according to our diff
990                for key, amount in claim_diff.items():
991                    if amount < 0:
992                        amount = abs(amount) # make positive, for more readable math
993                        if key not in claimed or claimed[key] < amount:
994                            self.request.session.flash("Cannot relinquish more claims than the "
995                                                       "parent batch has to offer.", 'error')
996                            return False
997                        claimed[key] -= amount
998
999                # next we must determine if any "new" requests would increase
1000                # the claim(s) beyond what is available
1001                for key, amount in claim_diff.items():
1002                    if amount > 0:
1003                        claimed[key] = claimed.get(key, 0) + amount
1004                        if key not in confirmed or confirmed[key] < claimed[key]:
1005                            self.request.session.flash("Cannot request to claim more product than "
1006                                                       "is available in Truck Dump Parent batch", 'error')
1007                            return False
1008
1009                # looks like the claim diff is all good, so let's attach that
1010                # to the form now and then pick this up again in save()
1011                form._claim_diff = claim_diff
1012
1013        # all validation went ok
1014        return True
1015
1016    def save_edit_row_form(self, form):
1017        batch = self.get_instance()
1018        row = self.objectify(form)
1019
1020        # editing a row for truck dump child batch can be complicated...
1021        if batch.is_truck_dump_child():
1022
1023            # grab the claim diff which we attached to the form during validation
1024            claim_diff = form._claim_diff
1025
1026            # first we must "relinquish" any claims which are to be reduced or
1027            # eliminated, according to our diff
1028            for key, amount in claim_diff.items():
1029                if amount < 0:
1030                    amount = abs(amount) # make positive, for more readable math
1031
1032                    # we'd prefer to find an exact match, i.e. there was a 1CS
1033                    # claim and our diff said to reduce by 1CS
1034                    matches = [claim for claim in row.truck_dump_claims
1035                               if getattr(claim, key) == amount]
1036                    if matches:
1037                        claim = matches[0]
1038                        setattr(claim, key, None)
1039
1040                    else:
1041                        # but if no exact match(es) then we'll just whittle
1042                        # away at whatever (smallest) claims we do find
1043                        possible = [claim for claim in row.truck_dump_claims
1044                                    if getattr(claim, key) is not None]
1045                        for claim in sorted(possible, key=lambda claim: getattr(claim, key)):
1046                            previous = getattr(claim, key)
1047                            if previous:
1048                                if previous >= amount:
1049                                    if (previous - amount):
1050                                        setattr(claim, key, previous - amount)
1051                                    else:
1052                                        setattr(claim, key, None)
1053                                    amount = 0
1054                                    break
1055                                else:
1056                                    setattr(claim, key, None)
1057                                    amount -= previous
1058
1059                        if amount:
1060                            raise NotImplementedError("Had leftover amount when \"relinquishing\" claim(s)")
1061
1062            # next we must stake all new claim(s) as requested, per our diff
1063            for key, amount in claim_diff.items():
1064                if amount > 0:
1065
1066                    # if possible, we'd prefer to add to an existing claim
1067                    # which already has an amount for this key
1068                    existing = [claim for claim in row.truck_dump_claims
1069                                if getattr(claim, key) is not None]
1070                    if existing:
1071                        claim = existing[0]
1072                        setattr(claim, key, getattr(claim, key) + amount)
1073
1074                    # next we'd prefer to add to an existing claim, of any kind
1075                    elif row.truck_dump_claims:
1076                        claim = row.truck_dump_claims[0]
1077                        setattr(claim, key, (getattr(claim, key) or 0) + amount)
1078
1079                    else:
1080                        # otherwise we must create a new claim...
1081
1082                        # find all rows from truck dump parent which "may" pertain to child row
1083                        # TODO: perhaps would need to do a more "loose" match on UPC also?
1084                        if not row.product_uuid:
1085                            raise NotImplementedError("Don't (yet) know how to handle edit for row with no product")
1086                        parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
1087                                       if parent_row.product_uuid == row.product_uuid]
1088
1089                        # remove any parent rows which are fully claimed
1090                        # TODO: should perhaps leverage actual amounts for this, instead
1091                        parent_rows = [parent_row for parent_row in parent_rows
1092                                       if parent_row.status_code != parent_row.STATUS_TRUCKDUMP_CLAIMED]
1093
1094                        # try to find a parent row which is exact match on claim amount
1095                        matches = [parent_row for parent_row in parent_rows
1096                                   if getattr(parent_row, key) == amount]
1097                        if matches:
1098
1099                            # make the claim against first matching parent row
1100                            claim = model.PurchaseBatchRowClaim()
1101                            claim.claimed_row = parent_rows[0]
1102                            setattr(claim, key, amount)
1103                            row.truck_dump_claims.append(claim)
1104
1105                        else:
1106                            # but if no exact match(es) then we'll just whittle
1107                            # away at whatever (smallest) parent rows we do find
1108                            for parent_row in sorted(parent_rows, lambda prow: getattr(prow, key)):
1109
1110                                available = getattr(parent_row, key) - sum([getattr(claim, key) for claim in parent_row.claims])
1111                                if available:
1112                                    if available >= amount:
1113                                        # make claim against this parent row, making it fully claimed
1114                                        claim = model.PurchaseBatchRowClaim()
1115                                        claim.claimed_row = parent_row
1116                                        setattr(claim, key, amount)
1117                                        row.truck_dump_claims.append(claim)
1118                                        amount = 0
1119                                        break
1120                                    else:
1121                                        # make partial claim against this parent row
1122                                        claim = model.PurchaseBatchRowClaim()
1123                                        claim.claimed_row = parent_row
1124                                        setattr(claim, key, available)
1125                                        row.truck_dump_claims.append(claim)
1126                                        amount -= available
1127
1128                            if amount:
1129                                raise NotImplementedError("Had leftover amount when \"staking\" claim(s)")
1130
1131            # now we must be sure to refresh all truck dump parent batch rows
1132            # which were affected.  but along with that we also should purge
1133            # any empty claims, i.e. those which were fully relinquished
1134            pending_refresh = set()
1135            for claim in list(row.truck_dump_claims):
1136                parent_row = claim.claimed_row
1137                if claim.is_empty():
1138                    row.truck_dump_claims.remove(claim)
1139                    self.Session.flush()
1140                pending_refresh.add(parent_row)
1141            for parent_row in pending_refresh:
1142                self.handler.refresh_row(parent_row)
1143            self.handler.refresh_batch_status(batch.truck_dump_batch)
1144
1145        self.after_edit_row(row)
1146        self.Session.flush()
1147        return row
1148
1149    def redirect_after_edit_row(self, row, mobile=False):
1150        return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1151
1152    def render_mobile_row_listitem(self, row, i):
1153        key = self.render_product_key_value(row)
1154        description = row.product.full_description if row.product else row.description
1155        return "({}) {}".format(key, description)
1156
1157    def make_mobile_row_grid_kwargs(self, **kwargs):
1158        kwargs = super(ReceivingBatchView, self).make_mobile_row_grid_kwargs(**kwargs)
1159
1160        # use custom `receive_row` instead of `view_row`
1161        # TODO: should still use `view_row` in some cases? e.g. executed batch
1162        kwargs['url'] = lambda obj: self.get_row_action_url('receive', obj, mobile=True)
1163
1164        return kwargs
1165
1166    def should_aggregate_products(self, batch):
1167        """
1168        Must return a boolean indicating whether rows should be aggregated by
1169        product for the given batch.
1170        """
1171        return True
1172
1173    def quick_locate_rows(self, batch, entry, product):
1174        rows = []
1175
1176        # try to locate rows by product uuid match before other key
1177        if product:
1178            rows = [row for row in batch.active_rows()
1179                    if row.product_uuid == product.uuid]
1180            if rows:
1181                return rows
1182
1183        key = self.rattail_config.product_key()
1184        if key == 'upc':
1185
1186            if entry.isdigit():
1187
1188                # we prefer "exact" UPC matches, i.e. those which assumed the entry
1189                # already contained the check digit.
1190                provided = GPC(entry, calc_check_digit=False)
1191                rows = [row for row in batch.active_rows()
1192                        if row.upc == provided]
1193                if rows:
1194                    return rows
1195
1196                # if no "exact" UPC matches, we'll settle for those (UPC matches)
1197                # which assume the entry lacked a check digit.
1198                checked = GPC(entry, calc_check_digit='upc')
1199                rows = [row for row in batch.active_rows()
1200                        if row.upc == checked]
1201                return rows
1202
1203        elif key == 'item_id':
1204            rows = [row for row in batch.active_rows()
1205                    if row.item_id == entry]
1206            return rows
1207
1208    def quick_locate_product(self, batch, entry):
1209
1210        # first let the handler attempt lookup on product key (only)
1211        product = self.handler.locate_product_for_entry(self.Session(), entry,
1212                                                        lookup_by_code=False)
1213        if product:
1214            return product
1215
1216        # now we'll attempt lookup by vendor item code
1217        product = api.get_product_by_vendor_code(self.Session(), entry, vendor=batch.vendor)
1218        if product:
1219            return product
1220
1221        # okay then, let's attempt lookup by "alternate" code
1222        product = api.get_product_by_code(self.Session(), entry)
1223        if product:
1224            return product
1225
1226    def save_quick_row_form(self, form):
1227        batch = self.get_instance()
1228        entry = form.validated['quick_entry']
1229
1230        # first try to locate the product based on quick entry
1231        product = self.quick_locate_product(batch, entry)
1232
1233        # then try to locate existing row(s) which match product/entry
1234        rows = self.quick_locate_rows(batch, entry, product)
1235        if rows:
1236
1237            # if aggregating, just re-use matching row
1238            prefer_existing = self.should_aggregate_products(batch)
1239            if prefer_existing:
1240                if len(rows) > 1:
1241                    log.warning("found multiple row matches for '%s' in batch %s: %s",
1242                                entry, batch.id_str, batch)
1243                return rows[0]
1244
1245            else: # borrow product from matching row, but make new row
1246                other_row = rows[0]
1247                row = model.PurchaseBatchRow()
1248                row.item_entry = entry
1249                row.product = other_row.product
1250                self.handler.add_row(batch, row)
1251                self.Session.flush()
1252                self.handler.refresh_batch_status(batch)
1253                return row
1254
1255        # matching row(s) not found; add new row if product was identified
1256        # TODO: probably should be smarter about how we handle deleted?
1257        if product and not product.deleted:
1258            row = model.PurchaseBatchRow()
1259            row.item_entry = entry
1260            row.product = product
1261            self.handler.add_row(batch, row)
1262            self.Session.flush()
1263            self.handler.refresh_batch_status(batch)
1264            return row
1265
1266        key = self.rattail_config.product_key()
1267        if key == 'upc':
1268
1269            # check for "bad" upc
1270            if len(entry) > 14:
1271                return
1272
1273            if not entry.isdigit():
1274                return
1275
1276            provided = GPC(entry, calc_check_digit=False)
1277            checked = GPC(entry, calc_check_digit='upc')
1278
1279            # product not in system, but presumably sane upc, so add to batch anyway
1280            row = model.PurchaseBatchRow()
1281            row.item_entry = entry
1282            add_check_digit = True # TODO: make this dynamic, of course
1283            if add_check_digit:
1284                row.upc = checked
1285            else:
1286                row.upc = provided
1287            row.item_id = entry
1288            row.description = "(unknown product)"
1289            self.handler.add_row(batch, row)
1290            self.Session.flush()
1291            self.handler.refresh_batch_status(batch)
1292            return row
1293
1294        elif key == 'item_id':
1295
1296            # check for "too long" item_id
1297            if len(entry) > maxlen(model.PurchaseBatchRow.item_id):
1298                return
1299
1300            # product not in system, but presumably sane item_id, so add to batch anyway
1301            row = model.PurchaseBatchRow()
1302            row.item_entry = entry
1303            row.item_id = entry
1304            row.description = "(unknown product)"
1305            self.handler.add_row(batch, row)
1306            self.Session.flush()
1307            self.handler.refresh_batch_status(batch)
1308            return row
1309
1310        else:
1311            raise NotImplementedError("don't know how to handle product key: {}".format(key))
1312
1313    def redirect_after_quick_row(self, row, mobile=False):
1314        if mobile:
1315            return self.redirect(self.get_row_action_url('view', row, mobile=mobile))
1316        return super(ReceivingBatchView, self).redirect_after_quick_row(row, mobile=mobile)
1317
1318    def get_row_image_url(self, row):
1319        if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True):
1320            return pod.get_image_url(self.rattail_config, row.upc)
1321
1322    def get_mobile_data(self, session=None):
1323        query = super(ReceivingBatchView, self).get_mobile_data(session=session)
1324
1325        # do not expose truck dump child batches on mobile
1326        # TODO: is there any case where we *would* want to?
1327        query = query.filter(model.PurchaseBatch.truck_dump_batch == None)
1328
1329        return query
1330
1331    def mobile_view_row(self):
1332        """
1333        Mobile view for receiving batch row items.  Note that this also handles
1334        updating a row.
1335        """
1336        self.mobile = True
1337        self.viewing = True
1338        row = self.get_row_instance()
1339        batch = row.batch
1340        permission_prefix = self.get_permission_prefix()
1341        form = self.make_mobile_row_form(row)
1342        context = {
1343            'row': row,
1344            'batch': batch,
1345            'parent_instance': batch,
1346            'instance': row,
1347            'instance_title': self.get_row_instance_title(row),
1348            'parent_model_title': self.get_model_title(),
1349            'product_image_url': self.get_row_image_url(row),
1350            'form': form,
1351            'allow_expired': self.handler.allow_expired_credits(),
1352            'allow_cases': self.handler.allow_cases(),
1353            'quick_receive': False,
1354            'quick_receive_all': False,
1355        }
1356
1357        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1358                                                               default=True)
1359        if batch.order_quantities_known:
1360            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1361                                                                       default=False)
1362
1363        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1364            schema = MobileReceivingForm().bind(session=self.Session())
1365            update_form = forms.Form(schema=schema, request=self.request)
1366            if update_form.validate(newstyle=True):
1367                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1368                mode = update_form.validated['mode']
1369                cases = update_form.validated['cases']
1370                units = update_form.validated['units']
1371
1372                # handler takes care of the row receiving logic for us
1373                kwargs = dict(update_form.validated)
1374                del kwargs['row']
1375                self.handler.receive_row(row, **kwargs)
1376
1377                # keep track of last-used uom, although we just track
1378                # whether or not it was 'CS' since the unit_uom can vary
1379                sticky_case = None
1380                if not update_form.validated['quick_receive']:
1381                    if cases and not units:
1382                        sticky_case = True
1383                    elif units and not cases:
1384                        sticky_case = False
1385                if sticky_case is not None:
1386                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1387
1388                return self.redirect(self.get_action_url('view', batch, mobile=True))
1389
1390        # unit_uom can vary by product
1391        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1392
1393        if context['quick_receive'] and context['quick_receive_all']:
1394            if context['allow_cases']:
1395                context['quick_receive_uom'] = 'CS'
1396                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1397            else:
1398                context['quick_receive_uom'] = context['unit_uom']
1399                accounted_for = self.handler.get_units_accounted_for(row)
1400                remainder = self.handler.get_units_ordered(row) - accounted_for
1401
1402                if accounted_for:
1403                    # some product accounted for; button should receive "remainder" only
1404                    if remainder:
1405                        remainder = pretty_quantity(remainder)
1406                        context['quick_receive_quantity'] = remainder
1407                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1408                    else:
1409                        # unless there is no remainder, in which case disable it
1410                        context['quick_receive'] = False
1411
1412                else: # nothing yet accounted for, button should receive "all"
1413                    if not remainder:
1414                        raise ValueError("why is remainder empty?")
1415                    remainder = pretty_quantity(remainder)
1416                    context['quick_receive_quantity'] = remainder
1417                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1418
1419        # effective uom can vary in a few ways...the basic default is 'CS' if
1420        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1421        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1422        if sticky_case is None:
1423            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1424        elif sticky_case:
1425            context['uom'] = 'CS'
1426        else:
1427            context['uom'] = context['unit_uom']
1428        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1429            context['uom'] = context['unit_uom']
1430
1431        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1432            warn = True
1433            if batch.is_truck_dump_parent() and row.product:
1434                uuids = [child.uuid for child in batch.truck_dump_children]
1435                if uuids:
1436                    count = self.Session.query(model.PurchaseBatchRow)\
1437                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1438                                        .filter(model.PurchaseBatchRow.product == row.product)\
1439                                        .count()
1440                    if count:
1441                        warn = False
1442            if warn:
1443                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1444        return self.render_to_response('view_row', context, mobile=True)
1445
1446    def mobile_receive_row(self):
1447        """
1448        Mobile view for row-level receiving.
1449        """
1450        self.mobile = True
1451        self.viewing = True
1452        row = self.get_row_instance()
1453        batch = row.batch
1454        permission_prefix = self.get_permission_prefix()
1455        form = self.make_mobile_row_form(row)
1456        context = {
1457            'row': row,
1458            'batch': batch,
1459            'parent_instance': batch,
1460            'instance': row,
1461            'instance_title': self.get_row_instance_title(row),
1462            'parent_model_title': self.get_model_title(),
1463            'product_image_url': self.get_row_image_url(row),
1464            'form': form,
1465            'allow_expired': self.handler.allow_expired_credits(),
1466            'allow_cases': self.handler.allow_cases(),
1467            'quick_receive': False,
1468            'quick_receive_all': False,
1469        }
1470
1471        context['quick_receive'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive',
1472                                                               default=True)
1473        if batch.order_quantities_known:
1474            context['quick_receive_all'] = self.rattail_config.getbool('rattail.batch', 'purchase.mobile_quick_receive_all',
1475                                                                       default=False)
1476
1477        if self.request.has_perm('{}.create_row'.format(permission_prefix)):
1478            schema = MobileReceivingForm().bind(session=self.Session())
1479            update_form = forms.Form(schema=schema, request=self.request)
1480            if update_form.validate(newstyle=True):
1481                row = self.Session.query(model.PurchaseBatchRow).get(update_form.validated['row'])
1482                mode = update_form.validated['mode']
1483                cases = update_form.validated['cases']
1484                units = update_form.validated['units']
1485
1486                # handler takes care of the row receiving logic for us
1487                kwargs = dict(update_form.validated)
1488                del kwargs['row']
1489                self.handler.receive_row(row, **kwargs)
1490
1491                # keep track of last-used uom, although we just track
1492                # whether or not it was 'CS' since the unit_uom can vary
1493                sticky_case = None
1494                if not update_form.validated['quick_receive']:
1495                    if cases and not units:
1496                        sticky_case = True
1497                    elif units and not cases:
1498                        sticky_case = False
1499                if sticky_case is not None:
1500                    self.request.session['tailbone.mobile.receiving.sticky_uom_is_case'] = sticky_case
1501
1502                return self.redirect(self.get_action_url('view', batch, mobile=True))
1503
1504        # unit_uom can vary by product
1505        context['unit_uom'] = 'LB' if row.product and row.product.weighed else 'EA'
1506
1507        if context['quick_receive'] and context['quick_receive_all']:
1508            if context['allow_cases']:
1509                context['quick_receive_uom'] = 'CS'
1510                raise NotImplementedError("TODO: add CS support for quick_receive_all")
1511            else:
1512                context['quick_receive_uom'] = context['unit_uom']
1513                accounted_for = self.handler.get_units_accounted_for(row)
1514                remainder = self.handler.get_units_ordered(row) - accounted_for
1515
1516                if accounted_for:
1517                    # some product accounted for; button should receive "remainder" only
1518                    if remainder:
1519                        remainder = pretty_quantity(remainder)
1520                        context['quick_receive_quantity'] = remainder
1521                        context['quick_receive_text'] = "Receive Remainder ({} {})".format(remainder, context['unit_uom'])
1522                    else:
1523                        # unless there is no remainder, in which case disable it
1524                        context['quick_receive'] = False
1525
1526                else: # nothing yet accounted for, button should receive "all"
1527                    if not remainder:
1528                        raise ValueError("why is remainder empty?")
1529                    remainder = pretty_quantity(remainder)
1530                    context['quick_receive_quantity'] = remainder
1531                    context['quick_receive_text'] = "Receive ALL ({} {})".format(remainder, context['unit_uom'])
1532
1533        # effective uom can vary in a few ways...the basic default is 'CS' if
1534        # self.default_uom_is_case is true, otherwise whatever unit_uom is.
1535        sticky_case = self.request.session.get('tailbone.mobile.receiving.sticky_uom_is_case')
1536        if sticky_case is None:
1537            context['uom'] = 'CS' if self.default_uom_is_case else context['unit_uom']
1538        elif sticky_case:
1539            context['uom'] = 'CS'
1540        else:
1541            context['uom'] = context['unit_uom']
1542        if context['uom'] == 'CS' and row.units_ordered and not row.cases_ordered:
1543            context['uom'] = context['unit_uom']
1544
1545        if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered:
1546            warn = True
1547            if batch.is_truck_dump_parent() and row.product:
1548                uuids = [child.uuid for child in batch.truck_dump_children]
1549                if uuids:
1550                    count = self.Session.query(model.PurchaseBatchRow)\
1551                                        .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\
1552                                        .filter(model.PurchaseBatchRow.product == row.product)\
1553                                        .count()
1554                    if count:
1555                        warn = False
1556            if warn:
1557                self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning')
1558
1559        # maybe alert user if they've already received some of this product
1560        alert_received = self.rattail_config.getbool('tailbone', 'receiving.alert_already_received',
1561                                                     default=False)
1562        if alert_received:
1563            if self.handler.get_units_confirmed(row):
1564                self.request.session.flash("You have already received some of this product.", 'receiving-warning')
1565
1566        return self.render_to_response('receive_row', context, mobile=True)
1567
1568    def auto_receive(self):
1569        """
1570        View which can "auto-receive" all items in the batch.  Meant only as a
1571        convenience for developers.
1572        """
1573        batch = self.get_instance()
1574        key = '{}.receive_all'.format(self.get_grid_key())
1575        progress = SessionProgress(self.request, key)
1576        kwargs = {'progress': progress}
1577        thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs)
1578        thread.start()
1579
1580        return self.render_progress(progress, {
1581            'instance': batch,
1582            'cancel_url': self.get_action_url('view', batch),
1583            'cancel_msg': "Auto-receive was canceled",
1584        })
1585
1586    def auto_receive_thread(self, uuid, user_uuid, progress=None):
1587        """
1588        Thread target for receiving all items on the given batch.
1589        """
1590        session = RattailSession()
1591        batch = session.query(model.PurchaseBatch).get(uuid)
1592        user = session.query(model.User).get(user_uuid)
1593        try:
1594            self.handler.auto_receive_all_items(batch, progress=progress)
1595
1596        # if anything goes wrong, rollback and log the error etc.
1597        except Exception as error:
1598            session.rollback()
1599            log.exception("auto-receive failed for: %s".format(batch))
1600            session.close()
1601            if progress:
1602                progress.session.load()
1603                progress.session['error'] = True
1604                progress.session['error_msg'] = "Auto-receive failed: {}: {}".format(
1605                    type(error).__name__, error)
1606                progress.session.save()
1607
1608        # if no error, check result flag (false means user canceled)
1609        else:
1610            session.commit()
1611            session.refresh(batch)
1612            success_url = self.get_action_url('view', batch)
1613            session.close()
1614            if progress:
1615                progress.session.load()
1616                progress.session['complete'] = True
1617                progress.session['success_url'] = success_url
1618                progress.session.save()
1619
1620    @classmethod
1621    def _receiving_defaults(cls, config):
1622        rattail_config = config.registry.settings.get('rattail_config')
1623        route_prefix = cls.get_route_prefix()
1624        url_prefix = cls.get_url_prefix()
1625        model_key = cls.get_model_key()
1626        permission_prefix = cls.get_permission_prefix()
1627
1628        # row-level receiving
1629        config.add_route('mobile.{}.receive_row'.format(route_prefix), '/mobile{}/{{uuid}}/rows/{{row_uuid}}/receive'.format(url_prefix))
1630        config.add_view(cls, attr='mobile_receive_row', route_name='mobile.{}.receive_row'.format(route_prefix),
1631                        permission='{}.edit_row'.format(permission_prefix))
1632
1633        if cls.allow_truck_dump:
1634
1635            # add TD child batch, from invoice file
1636            config.add_route('{}.add_child_from_invoice'.format(route_prefix), '{}/{{{}}}/add-child-from-invoice'.format(url_prefix, model_key))
1637            config.add_view(cls, attr='add_child_from_invoice', route_name='{}.add_child_from_invoice'.format(route_prefix),
1638                            permission='{}.create'.format(permission_prefix))
1639
1640            # transform TD parent row from "pack" to "unit" item
1641            config.add_route('{}.transform_unit_row'.format(route_prefix), '{}/{{{}}}/transform-unit'.format(url_prefix, model_key))
1642            config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix),
1643                            permission='{}.edit_row'.format(permission_prefix), renderer='json')
1644
1645            # auto-receive all items
1646            if not rattail_config.production():
1647                config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key),
1648                                 request_method='POST')
1649                config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix),
1650                                permission='admin')
1651
1652
1653    @classmethod
1654    def defaults(cls, config):
1655        cls._receiving_defaults(config)
1656        cls._purchasing_defaults(config)
1657        cls._batch_defaults(config)
1658        cls._defaults(config)
1659
1660
1661# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1662# session is not provided by the view at runtime (i.e. when it was instead
1663# being provided by the type instance, which was created upon app startup).
1664@colander.deferred
1665def valid_vendor(node, kw):
1666    session = kw['session']
1667    def validate(node, value):
1668        vendor = session.query(model.Vendor).get(value)
1669        if not vendor:
1670            raise colander.Invalid(node, "Vendor not found")
1671        return vendor.uuid
1672    return validate
1673
1674
1675class MobileNewReceivingBatch(colander.MappingSchema):
1676
1677    vendor = colander.SchemaNode(colander.String(),
1678                                 validator=valid_vendor)
1679
1680    workflow = colander.SchemaNode(colander.String(),
1681                                   validator=colander.OneOf([
1682                                       'from_po',
1683                                       'from_scratch',
1684                                       'truck_dump',
1685                                   ]))
1686
1687    phase = colander.SchemaNode(colander.Int())
1688
1689
1690class MobileNewReceivingFromPO(colander.MappingSchema):
1691
1692    purchase = colander.SchemaNode(colander.String())
1693
1694
1695# TODO: this is a stopgap measure to fix an obvious bug, which exists when the
1696# session is not provided by the view at runtime (i.e. when it was instead
1697# being provided by the type instance, which was created upon app startup).
1698@colander.deferred
1699def valid_purchase_batch_row(node, kw):
1700    session = kw['session']
1701    def validate(node, value):
1702        row = session.query(model.PurchaseBatchRow).get(value)
1703        if not row:
1704            raise colander.Invalid(node, "Batch row not found")
1705        if row.batch.executed:
1706            raise colander.Invalid(node, "Batch has already been executed")
1707        return row.uuid
1708    return validate
1709
1710
1711class MobileReceivingForm(colander.MappingSchema):
1712
1713    row = colander.SchemaNode(colander.String(),
1714                              validator=valid_purchase_batch_row)
1715
1716    mode = colander.SchemaNode(colander.String(),
1717                               validator=colander.OneOf([
1718                                   'received',
1719                                   'damaged',
1720                                   'expired',
1721                                   # 'mispick',
1722                               ]))
1723
1724    cases = colander.SchemaNode(colander.Decimal(), missing=None)
1725
1726    units = colander.SchemaNode(colander.Decimal(), missing=None)
1727
1728    expiration_date = colander.SchemaNode(colander.Date(),
1729                                          widget=dfwidget.TextInputWidget(),
1730                                          missing=colander.null)
1731
1732    quick_receive = colander.SchemaNode(colander.Boolean())
1733
1734
1735def includeme(config):
1736    ReceivingBatchView.defaults(config)
Note: See TracBrowser for help on using the repository browser.