source: tailbone/tailbone/views/purchasing/receiving.py @ 145e7f5

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

Allow vendor field to be dropdown, for mobile ordering/receiving

based on config. useful for apps which have very few vendors

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