source: tailbone/tailbone/views/purchasing/ordering.py @ 4b9394f

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

Use empty string for "missing" department name, for ordering worksheet

that way the sorting works as expected (i.e. for python3)

  • Property mode set to 100644
File size: 14.5 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2017 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 'ordering' (purchasing) batches
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import os
30
31import six
32import openpyxl
33from sqlalchemy import orm
34
35from rattail.db import model, api
36from rattail.core import Object
37from rattail.time import localtime
38
39from tailbone.views.purchasing import PurchasingBatchView
40
41
42class OrderingBatchView(PurchasingBatchView):
43    """
44    Master view for purchase order batches.
45    """
46    route_prefix = 'ordering'
47    url_prefix = '/ordering'
48    model_title = "Ordering Batch"
49    model_title_plural = "Ordering Batches"
50    index_title = "Ordering"
51    mobile_creatable = True
52    rows_editable = True
53    mobile_rows_creatable = True
54    mobile_rows_quickable = True
55    mobile_rows_editable = True
56    mobile_rows_deletable = True
57    has_worksheet = True
58
59    mobile_form_fields = [
60        'vendor',
61        'department',
62        'date_ordered',
63        'po_number',
64        'po_total',
65        'created',
66        'created_by',
67        'notes',
68        'status_code',
69        'complete',
70        'executed',
71        'executed_by',
72    ]
73
74    row_grid_columns = [
75        'sequence',
76        'upc',
77        # 'item_id',
78        'brand_name',
79        'description',
80        'size',
81        'cases_ordered',
82        'units_ordered',
83        # 'cases_received',
84        # 'units_received',
85        'po_total',
86        # 'invoice_total',
87        # 'credits',
88        'status_code',
89    ]
90
91    order_form_header_columns = [
92        "UPC",
93        "Brand",
94        "Description",
95        "Case",
96        "Vend. Code",
97        "Pref.",
98        "Unit Cost",
99    ]
100
101    @property
102    def batch_mode(self):
103        return self.enum.PURCHASE_BATCH_MODE_ORDERING
104
105    def configure_form(self, f):
106        super(OrderingBatchView, self).configure_form(f)
107
108        # purchase
109        f.remove_field('purchase')
110
111    def get_batch_kwargs(self, batch, mobile=False):
112        kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, mobile=mobile)
113        kwargs['ship_method'] = batch.ship_method
114        kwargs['notes_to_vendor'] = batch.notes_to_vendor
115        return kwargs
116
117    def worksheet(self):
118        """
119        View for editing batch row data as an order form worksheet.
120        """
121        batch = self.get_instance()
122        if batch.executed:
123            return self.redirect(self.get_action_url('view', batch))
124
125        # organize existing batch rows by product
126        order_items = {}
127        for row in batch.active_rows():
128            order_items[row.product_uuid] = row
129
130        # organize vendor catalog costs by dept / subdept
131        departments = {}
132        costs = self.get_order_form_costs(batch.vendor)
133        costs = self.sort_order_form_costs(costs)
134        for cost in costs:
135
136            department = cost.product.department
137            if department:
138                departments.setdefault(department.uuid, department)
139            else:
140                if None not in departments:
141                    department = Object(name='', number=None)
142                    departments[None] = department
143                department = departments[None]
144           
145            subdepartments = getattr(department, '_order_subdepartments', None)
146            if subdepartments is None:
147                subdepartments = department._order_subdepartments = {}
148
149            subdepartment = cost.product.subdepartment
150            if subdepartment:
151                subdepartments.setdefault(subdepartment.uuid, subdepartment)
152            else:
153                if None not in subdepartments:
154                    subdepartment = Object(name=None, number=None)
155                    subdepartments[None] = subdepartment
156                subdepartment = subdepartments[None]
157
158            subdept_costs = getattr(subdepartment, '_order_costs', None)
159            if subdept_costs is None:
160                subdept_costs = subdepartment._order_costs = []
161            subdept_costs.append(cost)
162            cost._batchrow = order_items.get(cost.product_uuid)
163
164            # do anything else needed to satisfy template display requirements etc.
165            self.decorate_order_form_cost(cost)
166
167        # fetch recent purchase history, sort/pad for template convenience
168        history = self.get_order_form_history(batch, costs, 6)
169        for i in range(6 - len(history)):
170            history.append(None)
171        history = list(reversed(history))
172
173        title = self.get_instance_title(batch)
174        order_date = batch.date_ordered
175        if not order_date:
176            order_date = localtime(self.rattail_config).date()
177        return self.render_to_response('worksheet', {
178            'batch': batch,
179            'order_date': order_date,
180            'instance': batch,
181            'instance_title': title,
182            'instance_url': self.get_action_url('view', batch),
183            'vendor': batch.vendor,
184            'departments': departments,
185            'history': history,
186            'get_upc': lambda p: p.upc.pretty() if p.upc else '',
187            'header_columns': self.order_form_header_columns,
188            'ignore_cases': not self.handler.allow_cases(),
189        })
190
191    def get_order_form_history(self, batch, costs, count):
192
193        # fetch last 6 purchases for this vendor, organize line items by product
194        history = []
195        purchases = self.Session.query(model.Purchase)\
196                                .filter(model.Purchase.vendor == batch.vendor)\
197                                .filter(model.Purchase.status >= self.enum.PURCHASE_STATUS_ORDERED)\
198                                .order_by(model.Purchase.date_ordered.desc(), model.Purchase.created.desc())\
199                                .options(orm.joinedload(model.Purchase.items))
200        for purchase in purchases[:count]:
201            items = {}
202            for item in purchase.items:
203                items[item.product_uuid] = item
204            history.append({'purchase': purchase, 'items': items})
205       
206        return history
207
208    def get_order_form_costs(self, vendor):
209        return self.Session.query(model.ProductCost)\
210                           .join(model.Product)\
211                           .outerjoin(model.Brand)\
212                           .filter(model.ProductCost.vendor == vendor)\
213                           .options(orm.joinedload(model.ProductCost.product)\
214                                    .joinedload(model.Product.department))\
215                           .options(orm.joinedload(model.ProductCost.product)\
216                                    .joinedload(model.Product.subdepartment))
217
218    def sort_order_form_costs(self, costs):
219        return costs.order_by(model.Brand.name,
220                              model.Product.description,
221                              model.Product.size)
222
223    def decorate_order_form_cost(self, cost):
224        pass
225
226    def worksheet_update(self):
227        """
228        Handles AJAX requests to update current batch, from Order Form view.
229        """
230        batch = self.get_instance()
231
232        cases_ordered = self.request.POST.get('cases_ordered', '0')
233        if not cases_ordered or not cases_ordered.isdigit():
234            return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)}
235        cases_ordered = int(cases_ordered)
236        if cases_ordered >= 100000: # TODO: really this depends on underlying column
237            return {'error': "Invalid value for cases ordered: {}".format(cases_ordered)}
238
239        units_ordered = self.request.POST.get('units_ordered', '0')
240        if not units_ordered or not units_ordered.isdigit():
241            return {'error': "Invalid value for units ordered: {}".format(units_ordered)}
242        units_ordered = int(units_ordered)
243        if units_ordered >= 100000: # TODO: really this depends on underlying column
244            return {'error': "Invalid value for units ordered: {}".format(units_ordered)}
245
246        uuid = self.request.POST.get('product_uuid')
247        product = self.Session.query(model.Product).get(uuid) if uuid else None
248        if not product:
249            return {'error': "Product not found"}
250
251        row = None
252        rows = [r for r in batch.data_rows if r.product_uuid == uuid]
253        if rows:
254            assert len(rows) == 1
255            row = rows[0]
256            if row.po_total and not row.removed:
257                batch.po_total -= row.po_total
258            if cases_ordered or units_ordered:
259                row.cases_ordered = cases_ordered or None
260                row.units_ordered = units_ordered or None
261                if row.removed:
262                    row.removed = False
263                    batch.rowcount += 1
264                self.handler.refresh_row(row)
265            else:
266                row.removed = True
267
268        elif cases_ordered or units_ordered:
269            row = model.PurchaseBatchRow()
270            row.sequence = max([0] + [r.sequence for r in batch.data_rows]) + 1
271            row.product = product
272            batch.data_rows.append(row)
273            row.cases_ordered = cases_ordered or None
274            row.units_ordered = units_ordered or None
275            self.handler.refresh_row(row)
276            batch.rowcount = (batch.rowcount or 0) + 1
277
278        return {
279            'row_cases_ordered': '' if not row or row.removed else int(row.cases_ordered or 0),
280            'row_units_ordered': '' if not row or row.removed else int(row.units_ordered or 0),
281            'row_po_total': '' if not row or row.removed else '${:0,.2f}'.format(row.po_total or 0),
282            'batch_po_total': '${:0,.2f}'.format(batch.po_total or 0),
283        }
284
285    def render_mobile_listitem(self, batch, i):
286        return "({}) {} on {} for ${:0,.2f}".format(batch.id_str, batch.vendor,
287                                                    batch.date_ordered, batch.po_total or 0)
288
289    def mobile_create(self):
290        """
291        Mobile view for creating a new ordering batch
292        """
293        mode = self.batch_mode
294        data = {'mode': mode}
295
296        vendor = None
297        if self.request.method == 'POST' and self.request.POST.get('vendor'):
298            vendor = self.Session.query(model.Vendor).get(self.request.POST['vendor'])
299            if vendor:
300
301                # fetch first to avoid flush below
302                store = self.rattail_config.get_store(self.Session())
303
304                batch = self.model_class()
305                batch.mode = mode
306                batch.vendor = vendor
307                batch.store = store
308                batch.buyer = self.request.user.employee
309                batch.created_by = self.request.user
310                batch.po_total = 0
311                kwargs = self.get_batch_kwargs(batch, mobile=True)
312                batch = self.handler.make_batch(self.Session(), **kwargs)
313                if self.handler.should_populate(batch):
314                    self.handler.populate(batch)
315                return self.redirect(self.request.route_url('mobile.ordering.view', uuid=batch.uuid))
316
317        data['index_title'] = self.get_index_title()
318        data['index_url'] = self.get_index_url(mobile=True)
319        data['mode_title'] = self.enum.PURCHASE_BATCH_MODE[mode].capitalize()
320        return self.render_to_response('create', data, mobile=True)
321
322    def configure_mobile_row_form(self, f):
323        super(OrderingBatchView, self).configure_mobile_row_form(f)
324        if self.editing:
325            # TODO: probably should take `allow_cases` into account here...
326            f.focus_spec = '[name="units_ordered"]'
327
328    def download_excel(self):
329        """
330        Download ordering batch as Excel spreadsheet.
331        """
332        batch = self.get_instance()
333
334        # populate Excel worksheet
335        workbook = openpyxl.Workbook()
336        worksheet = workbook.active
337        worksheet.title = "Purchase Order"
338        worksheet.append(["Store", "Vendor", "Date ordered"])
339        worksheet.append([batch.store.name, batch.vendor.name, batch.date_ordered.strftime('%m/%d/%Y')])
340        worksheet.append([])
341        worksheet.append(['vendor_code', 'upc', 'brand_name', 'description', 'cases_ordered', 'units_ordered'])
342        for row in batch.active_rows():
343            worksheet.append([row.vendor_code, six.text_type(row.upc), row.brand_name,
344                              '{} {}'.format(row.description, row.size),
345                              row.cases_ordered, row.units_ordered])
346
347        # write Excel file to batch data dir
348        filedir = batch.filedir(self.rattail_config)
349        if not os.path.exists(filedir):
350            os.makedirs(filedir)
351        filename = 'PO.{}.xlsx'.format(batch.id_str)
352        path = batch.filepath(self.rattail_config, filename)
353        workbook.save(path)
354
355        return self.file_response(path)
356
357    @classmethod
358    def _ordering_defaults(cls, config):
359        route_prefix = cls.get_route_prefix()
360        url_prefix = cls.get_url_prefix()
361        permission_prefix = cls.get_permission_prefix()
362        model_title = cls.get_model_title()
363        model_title_plural = cls.get_model_title_plural()
364
365        # fix permission group label
366        config.add_tailbone_permission_group(permission_prefix, model_title_plural)
367
368        # download as Excel
369        config.add_route('{}.download_excel'.format(route_prefix), '{}/{{uuid}}/excel'.format(url_prefix))
370        config.add_view(cls, attr='download_excel', route_name='{}.download_excel'.format(route_prefix),
371                        permission='{}.download_excel'.format(permission_prefix))
372        config.add_tailbone_permission(permission_prefix, '{}.download_excel'.format(permission_prefix),
373                                       "Download {} as Excel".format(model_title))
374
375    @classmethod
376    def defaults(cls, config):
377        cls._ordering_defaults(config)
378        cls._purchasing_defaults(config)
379        cls._batch_defaults(config)
380        cls._defaults(config)
381
382
383def includeme(config):
384    OrderingBatchView.defaults(config)
Note: See TracBrowser for help on using the repository browser.