source: rattail/rattail/batch/pricing.py @ 72d1b07

Last change on this file since 72d1b07 was 72d1b07, checked in by Lance Edgar <lance@…>, 8 months ago

Add basic start date support for "future" pricing batch

  • Property mode set to 100644
File size: 10.2 KB
Line 
1# -*- coding: utf-8; -*-
2################################################################################
3#
4#  Rattail -- Retail Software Framework
5#  Copyright © 2010-2022 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"""
24Handler for pricing batches
25"""
26
27from __future__ import unicode_literals, absolute_import
28
29import decimal
30
31import six
32from sqlalchemy import orm
33
34from rattail.db import model, api
35from rattail.gpc import GPC
36from rattail.batch import BatchHandler
37from rattail.excel import ExcelReader
38
39
40class PricingBatchHandler(BatchHandler):
41    """
42    Handler for pricing batches.
43    """
44    batch_model_class = model.PricingBatch
45
46    # cached decimal object used for rounding percentages, below
47    percent_decimal = decimal.Decimal('.001')
48
49    def allow_future(self):
50        """
51        Returns boolean indicating whether "future" price changes
52        should be allowed.
53
54        :returns: ``True`` if future price changes allowed; else ``False``.
55        """
56        return self.config.getbool('rattail.batch', 'pricing.allow_future',
57                                   default=False)
58
59    def should_populate(self, batch):
60        if batch.params and batch.params.get('auto_generate_from_srp_breach'):
61            return True
62        if batch.input_filename:
63            return True
64        if hasattr(batch, 'products'):
65            return True
66        return False
67
68    def populate(self, batch, progress=None):
69        """
70        Batch row data comes from product query.
71        """
72        # maybe populate with products which have an SRP breach
73        if batch.params and batch.params.get('auto_generate_from_srp_breach'):
74            self.populate_from_srp_breach(batch, progress=progress)
75            return
76
77        if batch.input_filename:
78            return self.populate_from_file(batch, progress=progress)
79
80        if hasattr(batch, 'product_batch') and batch.product_batch:
81            self.populate_from_product_batch(batch, progress=progress)
82            return
83
84        assert batch.products
85        session = orm.object_session(batch)
86
87        def append(item, i):
88            row = model.PricingBatchRow()
89            row.product = item
90            row.upc = row.product.upc
91            self.add_row(batch, row)
92            if i % 200 == 0:
93                session.flush()
94
95        self.progress_loop(append, batch.products, progress,
96                           message="Adding initial rows to batch")
97
98    def populate_from_file(self, batch, progress=None):
99        """
100        Batch row data comes from input data file.
101        """
102        path = batch.filepath(self.config, filename=batch.input_filename)
103        reader = ExcelReader(path)
104        excel_rows = reader.read_rows(progress=progress)
105        session = orm.object_session(batch)
106
107        def append(excel, i):
108            row = model.PricingBatchRow()
109
110            if 'upc' in excel:
111                item_entry = excel['upc']
112            else:
113                item_entry = excel.get('UPC')
114            if isinstance(item_entry, float):
115                item_entry = six.text_type(int(item_entry))
116            row.item_entry = item_entry
117
118            row.product = self.locate_product_for_entry(session, row.item_entry)
119            if row.product:
120                row.upc = row.product.upc
121            elif row.item_entry:
122                row.upc = GPC(row.item_entry, calc_check_digit='upc')
123
124            self.add_row(batch, row)
125            if i % 200 == 0:
126                session.flush()
127
128        self.progress_loop(append, excel_rows, progress,
129                           message="Adding initial rows to batch")
130
131    def populate_from_product_batch(self, batch, progress=None):
132        """
133        Populate pricing batch from product batch.
134        """
135        session = orm.object_session(batch)
136        product_batch = batch.product_batch
137
138        def add(prow, i):
139            row = model.PricingBatchRow()
140            row.item_entry = prow.item_entry
141            with session.no_autoflush:
142                row.product = prow.product
143            self.add_row(batch, row)
144            if i % 200 == 0:
145                session.flush()
146
147        self.progress_loop(add, product_batch.active_rows(), progress,
148                           message="Adding initial rows to batch")
149
150    def populate_from_srp_breach(self, batch, progress=None):
151        session = orm.object_session(batch)
152        products = self.find_products_with_srp_breach(session, progress=progress)
153
154        def append(product, i):
155            row = self.make_row()
156            row.product = product
157            self.add_row(batch, row)
158
159        self.progress_loop(append, products, progress,
160                           message="Adding rows to batch")
161
162    def find_products_with_srp_breach(self, session, progress=None):
163        """
164        Find and return a list of all products whose "regular price" is greater
165        than "suggested price" (SRP).
166        """
167        query = session.query(model.Product)\
168                       .options(orm.joinedload(model.Product.regular_price))\
169                       .options(orm.joinedload(model.Product.suggested_price))
170                       # TODO: should add these filters? make configurable?
171                       # .filter(model.Product.deleted == False)\
172                       # .filter(model.Product.discontinued == False)\
173        products = []
174
175        def collect(product, i):
176            regular = product.regular_price
177            suggested = product.suggested_price
178            if (regular and regular.price and suggested and suggested.price
179                and regular.price > suggested.price):
180                products.append(product)
181
182        self.progress_loop(collect, query.all(), progress,
183                           message="Collecting products with SRP breach")
184        return products
185
186    def refresh_row(self, row):
187        """
188        Inspect a row from the source data and populate additional attributes
189        for it, according to what we find in the database.
190        """
191        product = row.product
192        if not product:
193            row.status_code = row.STATUS_PRODUCT_NOT_FOUND
194            return
195
196        row.item_id = product.item_id
197        row.upc = product.upc
198        row.brand_name = six.text_type(product.brand or '')
199        row.description = product.description
200        row.size = product.size
201
202        department = product.department
203        row.department_number = department.number if department else None
204        row.department_name = department.name if department else None
205
206        subdept = product.subdepartment
207        row.subdepartment_number = subdept.number if subdept else None
208        row.subdepartment_name = subdept.name if subdept else None
209
210        family = product.family
211        row.family_code = family.code if family else None
212
213        report = product.report_code
214        row.report_code = report.code if report else None
215
216        row.alternate_code = product.code
217
218        cost = product.cost
219        row.vendor = cost.vendor if cost else None
220        row.vendor_item_code = cost.code if cost else None
221        row.regular_unit_cost = cost.unit_cost if cost else None
222
223        sugprice = product.suggested_price
224        row.suggested_price = sugprice.price if sugprice else None
225
226        curprice = product.current_price
227        if curprice:
228            row.current_price = curprice.price
229            row.current_price_type = curprice.type
230            row.current_price_starts = curprice.starts
231            row.current_price_ends = curprice.ends
232        else:
233            row.current_price = None
234            row.current_price_type = None
235            row.current_price_starts = None
236            row.current_price_ends = None
237
238        regprice = product.regular_price
239        row.old_price = regprice.price if regprice else None
240
241    def set_status_per_diff(self, row):
242        """
243        Set the row's status code according to its price diff
244        """
245        # manually priced items are "special" unless batch says to re-calc
246        if row.manually_priced and not row.batch.calculate_for_manual:
247            row.status_code = row.STATUS_PRODUCT_MANUALLY_PRICED
248            return
249
250        # prefer "% Diff" if batch defines that
251        threshold = row.batch.min_diff_percent
252        if threshold:
253            # force rounding of row's % diff, for comparison to threshold
254            # (this is just to avoid unexpected surprises for the user)
255            # (ideally we'd just flush() the session but this seems safer)
256            if isinstance(row.price_diff_percent, decimal.Decimal):
257                row.price_diff_percent = row.price_diff_percent.quantize(self.percent_decimal)
258            # TODO: why don't we use price_diff_percent here again?
259            minor = abs(row.margin_diff) < threshold
260
261        else: # or, use "$ Diff" as fallback
262            threshold = row.batch.min_diff_threshold
263            minor = bool(threshold) and abs(row.price_diff) < threshold
264
265        # unchanged?
266        if row.price_diff == 0:
267            row.status_code = row.STATUS_PRICE_UNCHANGED
268
269        # new price > SRP?
270        elif row.suggested_price and row.new_price > row.suggested_price:
271            row.status_code = row.STATUS_PRICE_BREACHES_SRP
272
273        # price increase?
274        elif row.price_diff > 0:
275            if minor:
276                row.status_code = row.STATUS_PRICE_INCREASE_MINOR
277            else:
278                row.status_code = row.STATUS_PRICE_INCREASE
279
280        # must be price decrease
281        else: # row.price_diff < 0
282            if minor:
283                row.status_code = row.STATUS_PRICE_DECREASE_MINOR
284            else:
285                row.status_code = row.STATUS_PRICE_DECREASE
Note: See TracBrowser for help on using the repository browser.