diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py index fb2a60d9..2ecdbb5c 100644 --- a/tailbone/forms/core.py +++ b/tailbone/forms/core.py @@ -565,7 +565,7 @@ class Form(object): node = colander.SchemaNode(nodeinfo, **kwargs) self.nodes[key] = node - def set_type(self, key, type_): + def set_type(self, key, type_, **kwargs): if type_ == 'datetime': self.set_renderer(key, self.render_datetime) elif type_ == 'datetime_local': @@ -599,9 +599,11 @@ class Form(object): self.set_widget(key, dfwidget.TextAreaWidget(cols=80, rows=8)) elif type_ == 'file': tmpstore = SessionFileUploadTempStore(self.request) - self.set_node(key, colander.SchemaNode(deform.FileData(), - widget=dfwidget.FileUploadWidget(tmpstore), - title=self.get_label(key))) + kw = {'widget': dfwidget.FileUploadWidget(tmpstore), + 'title': self.get_label(key)} + if 'required' in kwargs and not kwargs['required']: + kw['missing'] = colander.null + self.set_node(key, colander.SchemaNode(deform.FileData(), **kw)) else: raise ValueError("unknown type for '{}' field: {}".format(key, type_)) @@ -619,6 +621,12 @@ class Form(object): def get_enum(self, key): return self.enums.get(key) + # TODO: i don't think this is actually being used anywhere..? + def set_enum_value(self, key, enum_key, enum_value): + enum = self.enums.get(key) + if enum: + enum[enum_key] = enum_value + def set_renderer(self, key, renderer): if renderer is None: if key in self.renderers: diff --git a/tailbone/templates/receiving/create.mako b/tailbone/templates/receiving/create.mako index d05634b9..a8055188 100644 --- a/tailbone/templates/receiving/create.mako +++ b/tailbone/templates/receiving/create.mako @@ -48,13 +48,24 @@ $('.field-wrapper.invoice_date').show(); $('.field-wrapper.invoice_number').show(); - } else if (batch_type == 'truck_dump') { - $('.field-wrapper.truck_dump_batch_uuid').show(); - $('.field-wrapper.invoice_file').show(); - $('.field-wrapper.invoice_parser_key').show(); - $('.field-wrapper.vendor_uuid').hide(); + } else if (batch_type == 'truck_dump_children_first') { + $('.field-wrapper.truck_dump_batch_uuid').hide(); + $('.field-wrapper.invoice_file').hide(); + $('.field-wrapper.invoice_parser_key').hide(); + $('.field-wrapper.vendor_uuid').show(); $('.field-wrapper.date_ordered').hide(); - $('.field-wrapper.date_received').hide(); + $('.field-wrapper.date_received').show(); + $('.field-wrapper.po_number').hide(); + $('.field-wrapper.invoice_date').hide(); + $('.field-wrapper.invoice_number').hide(); + + } else if (batch_type == 'truck_dump_children_last') { + $('.field-wrapper.truck_dump_batch_uuid').hide(); + $('.field-wrapper.invoice_file').hide(); + $('.field-wrapper.invoice_parser_key').hide(); + $('.field-wrapper.vendor_uuid').show(); + $('.field-wrapper.date_ordered').hide(); + $('.field-wrapper.date_received').show(); $('.field-wrapper.po_number').hide(); $('.field-wrapper.invoice_date').hide(); $('.field-wrapper.invoice_number').hide(); diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako index 2204cd77..62c0d6cc 100644 --- a/tailbone/templates/receiving/view.mako +++ b/tailbone/templates/receiving/view.mako @@ -54,6 +54,22 @@ % endif +<%def name="object_helpers()"> + ${parent.object_helpers()} + % if not request.rattail_config.production() and not batch.executed and not batch.complete and request.has_perm('admin') and batch.is_truck_dump_parent() and batch.truck_dump_children_first: +
+

Development Tools

+
+ ${h.form(url('{}.auto_receive'.format(route_prefix), uuid=batch.uuid), class_='autodisable')} + ${h.csrf_token(request)} + ${h.submit('submit', "Auto-Receive All Items")} + ${h.end_form()} +
+
+ % endif + + + ${parent.body()} % if master.allow_truck_dump and request.has_perm('{}.edit_row'.format(permission_prefix)): diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py index 5c2df5f7..0e8d5344 100644 --- a/tailbone/views/batch/core.py +++ b/tailbone/views/batch/core.py @@ -674,9 +674,8 @@ class BatchMasterView(MasterView): def make_row_grid_tools(self, batch): return (self.make_default_row_grid_tools(batch) or '') + (self.make_batch_row_grid_tools(batch) or '') - def get_mobile_row_data(self, batch): - return super(BatchMasterView, self).get_mobile_row_data(batch)\ - .order_by(self.model_row_class.sequence) + def sort_mobile_row_data(self, query): + return query.order_by(self.model_row_class.sequence) def redirect_after_edit(self, batch): """ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index c524da7a..b8ae34e4 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -1325,7 +1325,11 @@ class MasterView(View): return form.validate(newstyle=True) def get_mobile_row_data(self, parent): - return self.get_row_data(parent) + query = self.get_row_data(parent) + return self.sort_mobile_row_data(query) + + def sort_mobile_row_data(self, query): + return query def mobile_row_route_url(self, route_name, **kwargs): route_name = 'mobile.{}.{}_row'.format(self.get_route_prefix(), route_name) diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py index e2b9e379..92c787b8 100644 --- a/tailbone/views/purchasing/batch.py +++ b/tailbone/views/purchasing/batch.py @@ -591,10 +591,6 @@ class PurchasingBatchView(BatchMasterView): # query = super(PurchasingBatchView, self).get_row_data(batch) # return query.options(orm.joinedload(model.PurchaseBatchRow.credits)) - def get_mobile_row_data(self, parent): - query = self.get_row_data(parent) - return self.sort_mobile_row_data(query) - def sort_mobile_row_data(self, query): return query.order_by(model.PurchaseBatchRow.modified.desc()) diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py index 8c935587..2c7969bc 100644 --- a/tailbone/views/purchasing/receiving.py +++ b/tailbone/views/purchasing/receiving.py @@ -33,12 +33,13 @@ import six import sqlalchemy as sa from rattail import pod -from rattail.db import model, api +from rattail.db import model, api, Session as RattailSession from rattail.db.util import maxlen from rattail.gpc import GPC from rattail.time import localtime from rattail.util import pretty_quantity, prettify, OrderedDict from rattail.vendors.invoices import iter_invoice_parsers, require_invoice_parser +from rattail.threads import Thread import colander from deform import widget as dfwidget @@ -47,6 +48,7 @@ from webhelpers2.html import tags, HTML from tailbone import forms, grids from tailbone.views.purchasing import PurchasingBatchView +from tailbone.progress import SessionProgress log = logging.getLogger(__name__) @@ -64,13 +66,15 @@ class MobileItemStatusFilter(grids.filters.MobileFilter): model.PurchaseBatchRow.cases_received != 0, model.PurchaseBatchRow.units_received != 0)) - # TODO: is this accurate (enough) ? if value == 'incomplete': + # looking for any rows with "ordered" quantity, but where the + # status does *not* signify a "settled" row so to speak return query.filter(sa.or_(model.PurchaseBatchRow.cases_ordered != 0, model.PurchaseBatchRow.units_ordered != 0))\ .filter(~model.PurchaseBatchRow.status_code.in_(( model.PurchaseBatchRow.STATUS_OK, - model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND))) + model.PurchaseBatchRow.STATUS_PRODUCT_NOT_FOUND, + model.PurchaseBatchRow.STATUS_TRUCKDUMP_CLAIMED))) if value == 'invalid': return query.filter(model.PurchaseBatchRow.status_code.in_(( @@ -81,13 +85,22 @@ class MobileItemStatusFilter(grids.filters.MobileFilter): ))) if value == 'unexpected': + # looking for any rows which have "received" quantity but which + # do *not* have any "ordered" quantity return query.filter(sa.and_( sa.or_( model.PurchaseBatchRow.cases_ordered == None, model.PurchaseBatchRow.cases_ordered == 0), sa.or_( model.PurchaseBatchRow.units_ordered == None, - model.PurchaseBatchRow.units_ordered == 0))) + model.PurchaseBatchRow.units_ordered == 0), + sa.or_( + model.PurchaseBatchRow.cases_received != 0, + model.PurchaseBatchRow.units_received != 0, + model.PurchaseBatchRow.cases_damaged != 0, + model.PurchaseBatchRow.units_damaged != 0, + model.PurchaseBatchRow.cases_expired != 0, + model.PurchaseBatchRow.units_expired != 0))) if value == 'damaged': return query.filter(sa.or_( @@ -154,11 +167,13 @@ class ReceivingBatchView(PurchasingBatchView): form_fields = [ 'id', 'batch_type', - 'description', 'store', 'vendor', + 'description', 'truck_dump', + 'truck_dump_children_first', 'truck_dump_children', + 'truck_dump_ready', 'truck_dump_batch', 'invoice_file', 'invoice_parser_key', @@ -288,9 +303,15 @@ class ReceivingBatchView(PurchasingBatchView): # batch_type if self.creating: - f.set_enum('batch_type', OrderedDict([ - ('from_scratch', "New from Scratch"), - ])) + batch_types = OrderedDict() + if self.allow_from_scratch: + batch_types['from_scratch'] = "From Scratch" + if self.allow_from_po: + batch_types['from_po'] = "From PO" + if self.allow_truck_dump: + batch_types['truck_dump_children_first'] = "Truck Dump (children FIRST)" + batch_types['truck_dump_children_last'] = "Truck Dump (children LAST)" + f.set_enum('batch_type', batch_types) else: f.remove_field('batch_type') @@ -305,6 +326,10 @@ class ReceivingBatchView(PurchasingBatchView): else: f.set_readonly('truck_dump') + # truck_dump_children_first + if self.creating or not batch.truck_dump: + f.remove_field('truck_dump_children_first') + # truck_dump_children if self.viewing: if batch.truck_dump: @@ -314,6 +339,10 @@ class ReceivingBatchView(PurchasingBatchView): else: f.remove_field('truck_dump_children') + # truck_dump_ready + if self.creating or not batch.truck_dump: + f.remove_field('truck_dump_ready') + # truck_dump_batch if self.creating: f.replace('truck_dump_batch', 'truck_dump_batch_uuid') @@ -344,12 +373,14 @@ class ReceivingBatchView(PurchasingBatchView): else: f.remove_fields('truck_dump', + 'truck_dump_children_first', 'truck_dump_children', + 'truck_dump_ready', 'truck_dump_batch') # invoice_file if self.creating: - f.set_type('invoice_file', 'file') + f.set_type('invoice_file', 'file', required=False) else: f.set_readonly('invoice_file') f.set_renderer('invoice_file', self.render_downloadable_file) @@ -366,9 +397,10 @@ class ReceivingBatchView(PurchasingBatchView): # store if self.creating: store = self.rattail_config.get_store(self.Session()) - f.set_widget('store_uuid', forms.widgets.ReadonlyWidget()) f.set_default('store_uuid', store.uuid) + # TODO: seems like set_hidden() should also set HiddenWidget f.set_hidden('store_uuid') + f.set_widget('store_uuid', dfwidget.HiddenWidget()) # purchase if self.creating: @@ -402,6 +434,19 @@ class ReceivingBatchView(PurchasingBatchView): if batch_type == 'from_scratch': kwargs.pop('truck_dump_batch', None) kwargs.pop('truck_dump_batch_uuid', None) + elif batch_type == 'truck_dump_children_first': + kwargs['truck_dump'] = True + kwargs['truck_dump_children_first'] = True + kwargs['order_quantities_known'] = True + # TODO: this makes sense in some cases, but all? + # (should just omit that field when not relevant) + kwargs['date_ordered'] = None + elif batch_type == 'truck_dump_children_last': + kwargs['truck_dump'] = True + kwargs['truck_dump_ready'] = True + # TODO: this makes sense in some cases, but all? + # (should just omit that field when not relevant) + kwargs['date_ordered'] = None elif batch_type.startswith('truck_dump_child'): truck_dump = self.get_instance() kwargs['store'] = truck_dump.store @@ -451,7 +496,7 @@ class ReceivingBatchView(PurchasingBatchView): url = self.request.route_url('receiving.view', uuid=child.uuid) items.append(HTML.tag('li', c=[tags.link_to(text, url)])) contents.append(HTML.tag('ul', c=items)) - if batch.complete and not batch.executed: + if not batch.executed and (batch.complete or batch.truck_dump_children_first): buttons = self.make_truck_dump_child_buttons(batch) if buttons: buttons = HTML.literal(' ').join(buttons) @@ -476,7 +521,7 @@ class ReceivingBatchView(PurchasingBatchView): if batch.executed: self.request.session.flash("Batch has already been executed: {}".format(batch)) return self.redirect(self.get_action_url('view', batch)) - if not batch.complete: + if not batch.complete and not batch.truck_dump_children_first: self.request.session.flash("Batch is not marked as complete: {}".format(batch)) return self.redirect(self.get_action_url('view', batch)) self.creating = True @@ -687,10 +732,10 @@ class ReceivingBatchView(PurchasingBatchView): super(ReceivingBatchView, self).configure_row_grid(g) g.set_label('department_name', "Department") - # hide 'ordered' columns for truck dump parent, since that batch type - # is only concerned with receiving + # hide 'ordered' columns for truck dump parent, if its "children first" + # flag is set, since that batch type is only concerned with receiving batch = self.get_instance() - if batch.is_truck_dump_parent(): + if batch.is_truck_dump_parent() and not batch.truck_dump_children_first: g.hide_column('cases_ordered') g.hide_column('units_ordered') @@ -1213,6 +1258,15 @@ class ReceivingBatchView(PurchasingBatchView): if self.rattail_config.getbool('rattail.batch', 'purchase.mobile_images', default=True): return pod.get_image_url(self.rattail_config, row.upc) + def get_mobile_data(self, session=None): + query = super(ReceivingBatchView, self).get_mobile_data(session=session) + + # do not expose truck dump child batches on mobile + # TODO: is there any case where we *would* want to? + query = query.filter(model.PurchaseBatch.truck_dump_batch == None) + + return query + def mobile_view_row(self): """ Mobile view for receiving batch row items. Note that this also handles @@ -1279,6 +1333,13 @@ class ReceivingBatchView(PurchasingBatchView): batch.invoice_total -= row.invoice_total self.handler.refresh_row(row) + # if current batch is a truck dump parent with "children last" + # then we now must let handler "make claims" between them + if (batch.is_truck_dump_parent() + and batch.truck_dump_children_first + and row.product): + self.handler.make_truck_dump_claims_for_parent_row(row) + # keep track of last-used uom, although we just track # whether or not it was 'CS' since the unit_uom can vary sticky_case = None @@ -1334,9 +1395,72 @@ class ReceivingBatchView(PurchasingBatchView): context['uom'] = context['unit_uom'] if batch.order_quantities_known and not row.cases_ordered and not row.units_ordered: - self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') + warn = True + if batch.is_truck_dump_parent() and row.product: + uuids = [child.uuid for child in batch.truck_dump_children] + if uuids: + count = self.Session.query(model.PurchaseBatchRow)\ + .filter(model.PurchaseBatchRow.batch_uuid.in_(uuids))\ + .filter(model.PurchaseBatchRow.product == row.product)\ + .count() + if count: + warn = False + if warn: + self.request.session.flash("This item was NOT on the original purchase order.", 'receiving-warning') return self.render_to_response('view_row', context, mobile=True) + def auto_receive(self): + """ + View which can "auto-receive" all items in the batch. Meant only as a + convenience for developers. + """ + batch = self.get_instance() + key = '{}.receive_all'.format(self.get_grid_key()) + progress = SessionProgress(self.request, key) + kwargs = {'progress': progress} + thread = Thread(target=self.auto_receive_thread, args=(batch.uuid, self.request.user.uuid), kwargs=kwargs) + thread.start() + + return self.render_progress(progress, { + 'instance': batch, + 'cancel_url': self.get_action_url('view', batch), + 'cancel_msg': "Auto-receive was canceled", + }) + + def auto_receive_thread(self, uuid, user_uuid, progress=None): + """ + Thread target for receiving all items on the given batch. + """ + session = RattailSession() + batch = session.query(model.PurchaseBatch).get(uuid) + user = session.query(model.User).get(user_uuid) + try: + self.handler.auto_receive_all_items(batch, progress=progress) + + # if anything goes wrong, rollback and log the error etc. + except Exception as error: + session.rollback() + log.exception("auto-receive failed for: %s".format(batch)) + session.close() + if progress: + progress.session.load() + progress.session['error'] = True + progress.session['error_msg'] = "Auto-receive failed: {}: {}".format( + type(error).__name__, error) + progress.session.save() + + # if no error, check result flag (false means user canceled) + else: + session.commit() + session.refresh(batch) + success_url = self.get_action_url('view', batch) + session.close() + if progress: + progress.session.load() + progress.session['complete'] = True + progress.session['success_url'] = success_url + progress.session.save() + def attach_credit(self, row, credit_type, cases, units, expiration_date=None, discarded=None, mispick_product=None): batch = row.batch credit = model.PurchaseBatchCredit() @@ -1386,6 +1510,7 @@ class ReceivingBatchView(PurchasingBatchView): @classmethod def _receiving_defaults(cls, config): + rattail_config = config.registry.settings.get('rattail_config') route_prefix = cls.get_route_prefix() url_prefix = cls.get_url_prefix() model_key = cls.get_model_key() @@ -1403,6 +1528,14 @@ class ReceivingBatchView(PurchasingBatchView): config.add_view(cls, attr='transform_unit_row', route_name='{}.transform_unit_row'.format(route_prefix), permission='{}.edit_row'.format(permission_prefix), renderer='json') + # auto-receive all items + if not rattail_config.production(): + config.add_route('{}.auto_receive'.format(route_prefix), '{}/{{{}}}/auto-receive'.format(url_prefix, model_key), + request_method='POST') + config.add_view(cls, attr='auto_receive', route_name='{}.auto_receive'.format(route_prefix), + permission='admin') + + @classmethod def defaults(cls, config): cls._receiving_defaults(config)