source: rattail/rattail/batch/purchase.py @ 6927d74

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

Fix logic for calculating "credit total"

also copy receiving date from truck dump parent to child, and fix output of
str(PurchaseCredit)

  • Property mode set to 100644
File size: 93.3 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"""
24Handler for purchase order batches
25"""
26
27from __future__ import unicode_literals, absolute_import, division
28
29import logging
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.time import localtime, make_utc
38from rattail.vendors.invoices import require_invoice_parser
39
40
41log = logging.getLogger(__name__)
42
43
44class PurchaseBatchHandler(BatchHandler):
45    """
46    Handler for purchase order batches.
47    """
48    batch_model_class = model.PurchaseBatch
49
50    def allow_cases(self):
51        """
52        Must return boolean indicating whether "cases" should be generally
53        allowed, for sake of quantity input etc.
54        """
55        return self.config.getbool('rattail.batch', 'purchase.allow_cases',
56                                   default=True)
57
58    def allow_expired_credits(self):
59        """
60        Must return boolean indicating whether "expired" credits should be
61        tracked.  In practice, this should either en- or dis-able various UI
62        elements which involves expired product.
63        """
64        return self.config.getbool('rattail.batch', 'purchase.allow_expired_credits',
65                                   default=True)
66
67    def should_populate(self, batch):
68        # TODO: this probably should change soon, for now this works..
69        return batch.purchase and batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
70                                                 self.enum.PURCHASE_BATCH_MODE_COSTING)
71
72    def populate(self, batch, progress=None):
73        assert batch.purchase and batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
74                                                 self.enum.PURCHASE_BATCH_MODE_COSTING)
75        batch.order_quantities_known = True
76
77        # maybe copy receiving date from parent
78        if batch.is_truck_dump_child() and not batch.date_received:
79            batch.date_received = batch.truck_dump_batch.date_received
80
81        def append(item, i):
82            row = model.PurchaseBatchRow()
83            product = item.product
84            row.item = item
85            row.product = product
86            if product:
87                row.upc = product.upc
88                row.item_id = product.item_id
89            else:
90                row.upc = item.upc
91                row.item_id = item.item_id
92            row.cases_ordered = item.cases_ordered
93            row.units_ordered = item.units_ordered
94            row.cases_received = item.cases_received
95            row.units_received = item.units_received
96            row.po_unit_cost = item.po_unit_cost
97            row.po_total = item.po_total
98            if batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
99                row.invoice_unit_cost = item.invoice_unit_cost
100                row.invoice_total = item.invoice_total
101            self.add_row(batch, row)
102
103        self.progress_loop(append, batch.purchase.items, progress,
104                           message="Adding initial rows to batch")
105
106        # TODO: should(n't) this be handled elsewhere?
107        session = orm.object_session(batch)
108        session.flush()
109        self.refresh_batch_status(batch)
110
111    def populate_from_truck_dump_invoice(self, batch, progress=None):
112        child_batch = batch
113        parent_batch = child_batch.truck_dump_batch
114        session = orm.object_session(child_batch)
115
116        parser = require_invoice_parser(child_batch.invoice_parser_key)
117        parser.session = session
118
119        parser.vendor = api.get_vendor(session, parser.vendor_key)
120        if parser.vendor is not child_batch.vendor:
121            raise RuntimeError("Parser is for vendor '{}' but batch is for: {}".format(
122                parser.vendor_key, child_batch.vendor))
123
124        path = child_batch.filepath(self.config, child_batch.invoice_file)
125        child_batch.invoice_date = parser.parse_invoice_date(path)
126        child_batch.order_quantities_known = True
127
128        def append(invoice_row, i):
129            row = self.make_row_from_invoice(child_batch, invoice_row)
130            self.add_row(child_batch, row)
131
132        self.progress_loop(append, list(parser.parse_rows(path)), progress,
133                           message="Adding initial rows to batch")
134
135        if parent_batch.truck_dump_children_first:
136            # children first, so should add rows to parent batch now
137            session.flush()
138
139            def append(child_row, i):
140                if not child_row.out_of_stock:
141
142                    # if row for this product already exists in parent, must aggregate
143                    parent_row = self.locate_parent_row_for_child(parent_batch, child_row)
144                    if parent_row:
145
146                        # confirm 'case_quantity' matches
147                        if parent_row.case_quantity != child_row.case_quantity:
148                            raise ValueError("differing 'case_quantity' for item {}: {}".format(
149                                child_row.item_entry, child_row.description))
150
151                        # confirm 'out_of_stock' matches
152                        if parent_row.out_of_stock != child_row.out_of_stock:
153                            raise ValueError("differing 'out_of_stock' for item {}: {}".format(
154                                cihld_row.item_entry, child_row.description))
155
156                        # confirm 'invoice_unit_cost' matches
157                        if parent_row.invoice_unit_cost != child_row.invoice_unit_cost:
158                            raise ValueError("differing 'invoice_unit_cost' for item {}: {}".format(
159                                cihld_row.item_entry, child_row.description))
160
161                        # confirm 'invoice_case_cost' matches
162                        if parent_row.invoice_case_cost != child_row.invoice_case_cost:
163                            raise ValueError("differing 'invoice_case_cost' for item {}: {}".format(
164                                cihld_row.item_entry, child_row.description))
165
166                        # add 'ordered' quantities
167                        if child_row.cases_ordered:
168                            parent_row.cases_ordered = (parent_row.cases_ordered or 0) + child_row.cases_ordered
169                        if child_row.units_ordered:
170                            parent_row.units_ordered = (parent_row.units_ordered or 0) + child_row.units_ordered
171
172                        # add 'shipped' quantities
173                        if child_row.cases_shipped:
174                            parent_row.cases_shipped = (parent_row.cases_shipped or 0) + child_row.cases_shipped
175                        if child_row.units_shipped:
176                            parent_row.units_shipped = (parent_row.units_shipped or 0) + child_row.units_shipped
177
178                        # add 'invoice_total' quantities
179                        if child_row.invoice_total:
180                            parent_row.invoice_total = (parent_row.invoice_total or 0) + child_row.invoice_total
181
182                    else: # new product; simply add new row to parent
183                        parent_row = self.make_parent_row_from_child(child_row)
184                        self.add_row(parent_batch, parent_row)
185
186            self.progress_loop(append, child_batch.active_rows(), progress,
187                               message="Adding rows to parent batch")
188
189        else: # children last, so should make parent claims now
190            self.make_truck_dump_claims_for_child_batch(child_batch, progress=progress)
191
192        self.refresh_batch_status(parent_batch)
193
194    def locate_parent_row_for_child(self, parent_batch, child_row):
195        """
196        Locate a row within parent batch, which "matches" given row from child
197        batch.  May return ``None`` if no match found.
198        """
199        if child_row.product_uuid:
200            rows = [row for row in parent_batch.active_rows()
201                    if row.product_uuid == child_row.product_uuid]
202            if rows:
203                return rows[0]
204
205        elif child_row.item_entry:
206            rows = [row for row in parent_batch.active_rows()
207                    if row.product_uuid is None
208                    and row.item_entry == child_row.item_entry]
209            if rows:
210                return rows[0]
211
212    def make_row_from_invoice(self, batch, invoice_row):
213        row = model.PurchaseBatchRow()
214        row.item_entry = invoice_row.item_entry
215        row.upc = invoice_row.upc
216        row.vendor_code = invoice_row.vendor_code
217        row.brand_name = invoice_row.brand_name
218        row.description = invoice_row.description
219        row.size = invoice_row.size
220        row.case_quantity = invoice_row.case_quantity
221        row.cases_ordered = invoice_row.ordered_cases
222        row.units_ordered = invoice_row.ordered_units
223        row.cases_shipped = invoice_row.shipped_cases
224        row.units_shipped = invoice_row.shipped_units
225        row.out_of_stock = invoice_row.out_of_stock
226        row.invoice_unit_cost = invoice_row.unit_cost
227        row.invoice_total = invoice_row.total_cost
228        row.invoice_case_cost = invoice_row.case_cost
229        return row
230
231    def make_parent_row_from_child(self, child_row):
232        row = model.PurchaseBatchRow()
233        row.item_entry = child_row.item_entry
234        row.upc = child_row.upc
235        row.vendor_code = child_row.vendor_code
236        row.brand_name = child_row.brand_name
237        row.description = child_row.description
238        row.size = child_row.size
239        row.case_quantity = child_row.case_quantity
240        row.cases_ordered = child_row.cases_ordered
241        row.units_ordered = child_row.units_ordered
242        row.cases_shipped = child_row.cases_shipped
243        row.units_shipped = child_row.units_shipped
244        row.out_of_stock = child_row.out_of_stock
245        row.invoice_unit_cost = child_row.invoice_unit_cost
246        row.invoice_total = child_row.invoice_total
247        row.invoice_case_cost = child_row.invoice_case_cost
248        return row
249
250    def make_truck_dump_claims_for_child_batch(self, batch, progress=None):
251        """
252        Make all "claims" against a truck dump, for the given child batch.
253        This assumes no claims exist for the child batch at time of calling,
254        and that the truck dump batch is complete and not yet executed.
255        """
256        session = orm.object_session(batch)
257        truck_dump_rows = batch.truck_dump_batch.active_rows()
258        child_rows = batch.active_rows()
259
260        # organize truck dump by product and UPC
261        truck_dump_by_product = {}
262        truck_dump_by_upc = {}
263
264        def organize_parent(row, i):
265            if row.product:
266                truck_dump_by_product.setdefault(row.product.uuid, []).append(row)
267            if row.upc:
268                truck_dump_by_upc.setdefault(row.upc, []).append(row)
269
270        self.progress_loop(organize_parent, truck_dump_rows, progress,
271                           message="Organizing truck dump parent rows")
272
273        # organize child batch by product and UPC
274        child_by_product = {}
275        child_by_upc = {}
276
277        def organize_child(row, i):
278            if row.product:
279                child_by_product.setdefault(row.product.uuid, []).append(row)
280            if row.upc:
281                child_by_upc.setdefault(row.upc, []).append(row)
282
283        self.progress_loop(organize_child, child_rows, progress,
284                           message="Organizing truck dump child rows")
285
286        # okay then, let's go through all our organized rows, and make claims
287
288        def make_claims(child_product, i):
289            uuid, child_product_rows = child_product
290            if uuid in truck_dump_by_product:
291                truck_dump_product_rows = truck_dump_by_product[uuid]
292                for truck_dump_row in truck_dump_product_rows:
293                    self.make_truck_dump_claims(truck_dump_row, child_product_rows)
294
295        self.progress_loop(make_claims, child_by_product.items(), progress,
296                           count=len(child_by_product),
297                           message="Claiming parent rows for child") # (pass #1)
298
299    def make_truck_dump_claims(self, truck_dump_row, child_rows):
300
301        # first we go through the truck dump parent row, and calculate all
302        # "present", and "claimed" vs. "pending" product quantities
303
304        # cases_received
305        cases_received = truck_dump_row.cases_received or 0
306        cases_received_claimed = sum([claim.cases_received or 0
307                                      for claim in truck_dump_row.claims])
308        cases_received_pending = cases_received - cases_received_claimed
309
310        # units_received
311        units_received = truck_dump_row.units_received or 0
312        units_received_claimed = sum([claim.units_received or 0
313                                      for claim in truck_dump_row.claims])
314        units_received_pending = units_received - units_received_claimed
315
316        # cases_damaged
317        cases_damaged = truck_dump_row.cases_damaged or 0
318        cases_damaged_claimed = sum([claim.cases_damaged or 0
319                                     for claim in truck_dump_row.claims])
320        cases_damaged_pending = cases_damaged - cases_damaged_claimed
321
322        # units_damaged
323        units_damaged = truck_dump_row.units_damaged or 0
324        units_damaged_claimed = sum([claim.units_damaged or 0
325                                     for claim in truck_dump_row.claims])
326        units_damaged_pending = units_damaged - units_damaged_claimed
327
328        # cases_expired
329        cases_expired = truck_dump_row.cases_expired or 0
330        cases_expired_claimed = sum([claim.cases_expired or 0
331                                     for claim in truck_dump_row.claims])
332        cases_expired_pending = cases_expired - cases_expired_claimed
333
334        # units_expired
335        units_expired = truck_dump_row.units_expired or 0
336        units_expired_claimed = sum([claim.units_expired or 0
337                                     for claim in truck_dump_row.claims])
338        units_expired_pending = units_expired - units_expired_claimed
339
340        # TODO: should be calculating mispicks here too, right?
341
342        def make_claim(child_row):
343            c = model.PurchaseBatchRowClaim()
344            c.claiming_row = child_row
345            truck_dump_row.claims.append(c)
346            return c
347
348        for child_row in child_rows:
349
350            # stop now if everything in this parent row is accounted for
351            if not (cases_received_pending or units_received_pending
352                    or cases_damaged_pending or units_damaged_pending
353                    or cases_expired_pending or units_expired_pending):
354                break
355
356            # for each child row we also calculate all "present", and "claimed"
357            # vs. "pending" product quantities
358
359            # cases_ordered
360            cases_ordered = child_row.cases_ordered or 0
361            cases_ordered_claimed = sum([(claim.cases_received or 0)
362                                         + (claim.cases_damaged or 0)
363                                         + (claim.cases_expired or 0)
364                                         for claim in child_row.truck_dump_claims])
365            cases_ordered_pending = cases_ordered - cases_ordered_claimed
366
367            # units_ordered
368            units_ordered = child_row.units_ordered or 0
369            units_ordered_claimed = sum([(claim.units_received or 0)
370                                         + (claim.units_damaged or 0)
371                                         + (claim.units_expired or 0)
372                                         for claim in child_row.truck_dump_claims])
373            units_ordered_pending = units_ordered - units_ordered_claimed
374
375            # skip this child row if everything in it is accounted for
376            if not (cases_ordered_pending or units_ordered_pending):
377                continue
378
379            # there should only be one claim for this parent/child combo
380            claim = None
381
382            # let's cache this
383            case_quantity = child_row.case_quantity
384
385            # make case claims
386            if cases_ordered_pending and cases_received_pending:
387                claim = claim or make_claim(child_row)
388                if cases_received_pending >= cases_ordered_pending:
389                    claim.cases_received = (claim.cases_received or 0) + cases_ordered_pending
390                    child_row.cases_received = (child_row.cases_received or 0) + cases_ordered_pending
391                    cases_received_pending -= cases_ordered_pending
392                    cases_ordered_pending = 0
393                else: # ordered > received
394                    claim.cases_received = (claim.cases_received or 0) + cases_received_pending
395                    child_row.cases_received = (child_row.cases_received or 0) + cases_received_pending
396                    cases_ordered_pending -= cases_received_pending
397                    cases_received_pending = 0
398                self.refresh_row(child_row)
399            if cases_ordered_pending and cases_damaged_pending:
400                claim = claim or make_claim(child_row)
401                if cases_damaged_pending >= cases_ordered_pending:
402                    claim.cases_damaged = (claim.cases_damaged or 0) + cases_ordered_pending
403                    child_row.cases_damaged = (child_row.cases_damaged or 0) + cases_ordered_pending
404                    cases_damaged_pending -= cases_ordered_pending
405                    cases_ordered_pending = 0
406                else: # ordered > damaged
407                    claim.cases_damaged = (claim.cases_damaged or 0) + cases_damaged_pending
408                    child_row.cases_damaged = (child_row.cases_damaged or 0) + cases_damaged_pending
409                    cases_ordered_pending -= cases_damaged_pending
410                    cases_damaged_pending = 0
411                self.refresh_row(child_row)
412            if cases_ordered_pending and cases_expired_pending:
413                claim = claim or make_claim(child_row)
414                if cases_expired_pending >= cases_ordered_pending:
415                    claim.cases_expired = (claim.cases_expired or 0) + cases_ordered_pending
416                    child_row.cases_expired = (child_row.cases_expired or 0) + cases_ordered_pending
417                    cases_expired_pending -= cases_ordered_pending
418                    cases_ordered_pending = 0
419                else: # ordered > expired
420                    claim.cases_expired = (claim.cases_expired or 0) + cases_expired_pending
421                    child_row.cases_expired = (child_row.cases_expired or 0) + cases_expired_pending
422                    cases_ordered_pending -= cases_expired_pending
423                    cases_expired_pending = 0
424                self.refresh_row(child_row)
425
426            # make unit claims
427            if units_ordered_pending and units_received_pending:
428                claim = claim or make_claim(child_row)
429                if units_received_pending >= units_ordered_pending:
430                    claim.units_received = (claim.units_received or 0) + units_ordered_pending
431                    child_row.units_received = (child_row.units_received or 0) + units_ordered_pending
432                    units_received_pending -= units_ordered_pending
433                    units_ordered_pending = 0
434                else: # ordered > received
435                    claim.units_received = (claim.units_received or 0) + units_received_pending
436                    child_row.units_received = (child_row.units_received or 0) + units_received_pending
437                    units_ordered_pending -= units_received_pending
438                    units_received_pending = 0
439                self.refresh_row(child_row)
440            if units_ordered_pending and units_damaged_pending:
441                claim = claim or make_claim(child_row)
442                if units_damaged_pending >= units_ordered_pending:
443                    claim.units_damaged = (claim.units_damaged or 0) + units_ordered_pending
444                    child_row.units_damaged = (child_row.units_damaged or 0) + units_ordered_pending
445                    units_damaged_pending -= units_ordered_pending
446                    units_ordered_pending = 0
447                else: # ordered > damaged
448                    claim.units_damaged = (claim.units_damaged or 0) + units_damaged_pending
449                    child_row.units_damaged = (child_row.units_damaged or 0) + units_damaged_pending
450                    units_ordered_pending -= units_damaged_pending
451                    units_damaged_pending = 0
452                self.refresh_row(child_row)
453            if units_ordered_pending and units_expired_pending:
454                claim = claim or make_claim(child_row)
455                if units_expired_pending >= units_ordered_pending:
456                    claim.units_expired = (claim.units_expired or 0) + units_ordered_pending
457                    child_row.units_expired = (child_row.units_expired or 0) + units_ordered_pending
458                    units_expired_pending -= units_ordered_pending
459                    units_ordered_pending = 0
460                else: # ordered > expired
461                    claim.units_expired = (claim.units_expired or 0) + units_expired_pending
462                    child_row.units_expired = (child_row.units_expired or 0) + units_expired_pending
463                    units_ordered_pending -= units_expired_pending
464                    units_expired_pending = 0
465                self.refresh_row(child_row)
466
467            # claim units from parent, as cases for child.  note that this
468            # crosses the case/unit boundary, but is considered "safe" because
469            # we assume the child row has correct case quantity even if parent
470            # row has a different one.
471            if cases_ordered_pending and units_received_pending:
472                received = units_received_pending // case_quantity
473                if received:
474                    claim = claim or make_claim(child_row)
475                    if received >= cases_ordered_pending:
476                        claim.cases_received = (claim.cases_received or 0) + cases_ordered_pending
477                        child_row.cases_received = (child_row.units_received or 0) + cases_ordered_pending
478                        units_received_pending -= (cases_ordered_pending * case_quantity)
479                        cases_ordered_pending = 0
480                    else: # ordered > received
481                        claim.cases_received = (claim.cases_received or 0) + received
482                        child_row.cases_received = (child_row.units_received or 0) + received
483                        cases_ordered_pending -= received
484                        units_received_pending -= (received * case_quantity)
485                    self.refresh_row(child_row)
486            if cases_ordered_pending and units_damaged_pending:
487                damaged = units_damaged_pending // case_quantity
488                if damaged:
489                    claim = claim or make_claim(child_row)
490                    if damaged >= cases_ordered_pending:
491                        claim.cases_damaged = (claim.cases_damaged or 0) + cases_ordered_pending
492                        child_row.cases_damaged = (child_row.units_damaged or 0) + cases_ordered_pending
493                        units_damaged_pending -= (cases_ordered_pending * case_quantity)
494                        cases_ordered_pending = 0
495                    else: # ordered > damaged
496                        claim.cases_damaged = (claim.cases_damaged or 0) + damaged
497                        child_row.cases_damaged = (child_row.units_damaged or 0) + damaged
498                        cases_ordered_pending -= damaged
499                        units_damaged_pending -= (damaged * case_quantity)
500                    self.refresh_row(child_row)
501            if cases_ordered_pending and units_expired_pending:
502                expired = units_expired_pending // case_quantity
503                if expired:
504                    claim = claim or make_claim(child_row)
505                    if expired >= cases_ordered_pending:
506                        claim.cases_expired = (claim.cases_expired or 0) + cases_ordered_pending
507                        child_row.cases_expired = (child_row.units_expired or 0) + cases_ordered_pending
508                        units_expired_pending -= (cases_ordered_pending * case_quantity)
509                        cases_ordered_pending = 0
510                    else: # ordered > expired
511                        claim.cases_expired = (claim.cases_expired or 0) + expired
512                        child_row.cases_expired = (child_row.units_expired or 0) + expired
513                        cases_ordered_pending -= expired
514                        units_expired_pending -= (expired * case_quantity)
515                    self.refresh_row(child_row)
516
517            # if necessary, try to claim cases from parent, as units for child.
518            # this also crosses the case/unit boundary but is considered safe
519            # only if the case quantity matches between child and parent rows.
520            # (otherwise who knows what could go wrong.)
521            if case_quantity == truck_dump_row.case_quantity:
522                if units_ordered_pending and cases_received_pending:
523                    received = cases_received_pending * case_quantity
524                    claim = claim or make_claim(child_row)
525                    if received >= units_ordered_pending:
526                        claim.units_received = (claim.units_received or 0) + units_ordered_pending
527                        child_row.units_received = (child_row.units_received or 0) + units_ordered_pending
528                        leftover = received % units_ordered_pending
529                        if leftover == 0:
530                            cases_received_pending -= (received // units_ordered_pending)
531                        else:
532                            cases_received_pending -= (received // units_ordered_pending) - 1
533                            units_received_pending += leftover
534                        units_ordered_pending = 0
535                    else: # ordered > received
536                        claim.units_received = (claim.units_received or 0) + received
537                        child_row.units_received = (child_row.units_received or 0) + received
538                        units_ordered_pending -= received
539                        cases_received_pending = 0
540                    self.refresh_row(child_row)
541                if units_ordered_pending and cases_damaged_pending:
542                    damaged = cases_damaged_pending * case_quantity
543                    claim = claim or make_claim(child_row)
544                    if damaged >= units_ordered_pending:
545                        claim.units_damaged = (claim.units_damaged or 0) + units_ordered_pending
546                        child_row.units_damaged = (child_row.units_damaged or 0) + units_ordered_pending
547                        leftover = damaged % units_ordered_pending
548                        if leftover == 0:
549                            cases_damaged_pending -= (damaged // units_ordered_pending)
550                        else:
551                            cases_damaged_pending -= (damaged // units_ordered_pending) - 1
552                            units_damaged_pending += leftover
553                        units_ordered_pending = 0
554                    else: # ordered > damaged
555                        claim.units_damaged = (claim.units_damaged or 0) + damaged
556                        child_row.units_damaged = (child_row.units_damaged or 0) + damaged
557                        units_ordered_pending -= damaged
558                        cases_damaged_pending = 0
559                    self.refresh_row(child_row)
560                if units_ordered_pending and cases_expired_pending:
561                    expired = cases_expired_pending * case_quantity
562                    claim = claim or make_claim(child_row)
563                    if expired >= units_ordered_pending:
564                        claim.units_expired = (claim.units_expired or 0) + units_ordered_pending
565                        child_row.units_expired = (child_row.units_expired or 0) + units_ordered_pending
566                        leftover = expired % units_ordered_pending
567                        if leftover == 0:
568                            cases_expired_pending -= (expired // units_ordered_pending)
569                        else:
570                            cases_expired_pending -= (expired // units_ordered_pending) - 1
571                            units_expired_pending += leftover
572                        units_ordered_pending = 0
573                    else: # ordered > expired
574                        claim.units_expired = (claim.units_expired or 0) + expired
575                        child_row.units_expired = (child_row.units_expired or 0) + expired
576                        units_ordered_pending -= expired
577                        cases_expired_pending = 0
578                    self.refresh_row(child_row)
579
580            # refresh the parent row, to reflect any new claim(s) made
581            self.refresh_row(truck_dump_row)
582
583    def clone_truck_dump_credit(self, truck_dump_credit, child_row):
584        """
585        Clone a credit record from a truck dump, onto the given child row.
586        """
587        child_batch = child_row.batch
588        child_credit = model.PurchaseBatchCredit()
589        self.copy_credit_attributes(truck_dump_credit, child_credit)
590        child_credit.date_ordered = child_batch.date_ordered
591        child_credit.date_shipped = child_batch.date_shipped
592        child_credit.date_received = child_batch.date_received
593        child_credit.invoice_date = child_batch.invoice_date
594        child_credit.invoice_number = child_batch.invoice_number
595        child_credit.invoice_line_number = child_row.invoice_line_number
596        child_credit.invoice_case_cost = child_row.invoice_case_cost
597        child_credit.invoice_unit_cost = child_row.invoice_unit_cost
598        child_credit.invoice_total = child_row.invoice_total
599        child_row.credits.append(child_credit)
600        return child_credit
601
602    # TODO: surely this should live elsewhere
603    def calc_best_fit(self, units, case_quantity):
604        case_quantity = case_quantity or 1
605        if case_quantity == 1:
606            return 0, units
607        cases = units // case_quantity
608        if cases:
609            return cases, units - (cases * case_quantity)
610        return 0, units
611
612    def refresh(self, batch, progress=None):
613
614        # reset PO total for ordering batch
615        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
616            batch.po_total = 0
617
618        # reset invoice total for receiving and costing batch
619        elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
620                            self.enum.PURCHASE_BATCH_MODE_COSTING):
621            batch.invoice_total = 0
622
623        # refresh all rows etc. per usual
624        result = super(PurchaseBatchHandler, self).refresh(batch, progress=progress)
625        if result:
626
627            # here begins some extra magic for truck dump receiving batches
628            if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
629                session = orm.object_session(batch)
630                session.flush()
631
632                if batch.is_truck_dump_parent():
633
634                    # will try to establish new claims against the parent
635                    # batch, where possible
636                    unclaimed = [row for row in batch.active_rows()
637                                 if row.status_code in (row.STATUS_TRUCKDUMP_UNCLAIMED,
638                                                        row.STATUS_TRUCKDUMP_PARTCLAIMED)]
639                    for row in unclaimed:
640                        if row.product_uuid: # only support rows with product for now
641                            self.make_truck_dump_claims_for_parent_row(row)
642
643                    # all rows should be refreshed now, but batch status still needs it
644                    self.refresh_batch_status(batch)
645                    for child in batch.truck_dump_children:
646                        self.refresh_batch_status(child)
647
648                elif batch.is_truck_dump_child():
649
650                    # will try to establish claims against the parent batch,
651                    # for each "incomplete" row (i.e. those with unclaimed
652                    # order quantities)
653                    incomplete = [row for row in batch.active_rows()
654                                  if row.status_code in (row.STATUS_INCOMPLETE,
655                                                         row.STATUS_ORDERED_RECEIVED_DIFFER)]
656                    for row in incomplete:
657                        if row.product_uuid: # only support rows with product for now
658                            parent_rows = [parent_row for parent_row in batch.truck_dump_batch.active_rows()
659                                           if parent_row.product_uuid == row.product_uuid]
660                            for parent_row in parent_rows:
661                                self.make_truck_dump_claims(parent_row, [row])
662                                if row.status_code not in (row.STATUS_INCOMPLETE,
663                                                           row.STATUS_ORDERED_RECEIVED_DIFFER):
664                                    break
665
666                    # all rows should be refreshed now, but batch status still needs it
667                    self.refresh_batch_status(batch.truck_dump_batch)
668                    self.refresh_batch_status(batch)
669
670        return result
671
672    def refresh_batch_status(self, batch):
673        rows = batch.active_rows()
674
675        # "unknown product" is the most egregious status; we'll "prefer" it
676        # over all others in order to bring it to user's attention
677        if any([row.status_code == row.STATUS_PRODUCT_NOT_FOUND for row in rows]):
678            batch.status_code = batch.STATUS_UNKNOWN_PRODUCT
679
680        # for now anything else is considered ok
681        else:
682            batch.status_code = batch.STATUS_OK
683
684        # truck dump parent batch gets status to reflect how much is (un)claimed
685        if batch.is_truck_dump_parent():
686
687            # batch is "claimed" only if all rows are "settled" so to speak
688            if all([row.truck_dump_status == row.STATUS_TRUCKDUMP_CLAIMED
689                    for row in rows]):
690                batch.truck_dump_status = batch.STATUS_TRUCKDUMP_CLAIMED
691
692            # otherwise just call it "unclaimed"
693            else:
694                batch.truck_dump_status = batch.STATUS_TRUCKDUMP_UNCLAIMED
695
696    def locate_product_for_entry(self, session, entry, lookup_by_code=True):
697        """
698        Try to locate the product represented by the given "entry" - which is
699        assumed to be a "raw" string, e.g. as obtained from scanner or other
700        user input, or from a vendor-supplied spreadsheet etc.  (In other words
701        this method is not told "what" sort of entry it is being given.)
702
703        :param lookup_by_code: If set to ``False``, then the method will
704           attempt a lookup *only* on the product key field (either ``upc`` or
705           ``item_id`` depending on config).  If set to ``True`` (the default)
706           then the method will also attempt a lookup in the ``ProductCode``
707           table, aka. alternate codes.
708        """
709        # try to locate product by uuid before other, more specific key
710        product = session.query(model.Product).get(entry)
711        if product:
712            return product
713
714        product_key = self.config.product_key()
715        if product_key == 'upc':
716
717            if entry.isdigit():
718
719                # we first assume the user entry *does* include check digit
720                provided = GPC(entry, calc_check_digit=False)
721                product = api.get_product_by_upc(session, provided)
722                if product:
723                    return product
724
725                # but we can also calculate a check digit and try that
726                checked = GPC(entry, calc_check_digit='upc')
727                product = api.get_product_by_upc(session, checked)
728                if product:
729                    return product
730
731        elif product_key == 'item_id':
732
733            # try to locate product by item_id
734            product = api.get_product_by_item_id(session, entry)
735            if product:
736                return product
737
738        # if we made it this far, lookup by product key failed.
739
740        # okay then, let's maybe attempt lookup by "alternate" code
741        if lookup_by_code:
742            product = api.get_product_by_code(session, entry)
743            if product:
744                return product
745
746    def locate_product(self, row, session=None, vendor=None):
747        """
748        Try to locate the product represented by the given row.  Default
749        behavior here, is to do a simple lookup on either ``Product.upc`` or
750        ``Product.item_id``, depending on which is configured as your product
751        key field.
752        """
753        if not session:
754            session = orm.object_session(row)
755        product_key = self.config.product_key()
756
757        if product_key == 'upc':
758            if row.upc:
759                product = api.get_product_by_upc(session, row.upc)
760                if product:
761                    return product
762
763        elif product_key == 'item_id':
764            if row.item_id:
765                product = api.get_product_by_item_id(session, row.item_id)
766                if product:
767                    return product
768
769        # product key didn't work, but vendor item code just might
770        if row.vendor_code:
771            product = api.get_product_by_vendor_code(session, row.vendor_code,
772                                                     vendor=vendor or row.batch.vendor)
773            if product:
774                return product
775
776        # before giving up, let's do a lookup on alt codes too
777        if row.item_entry:
778            product = api.get_product_by_code(session, row.item_entry)
779            if product:
780                return product
781
782    def transform_pack_to_unit(self, row):
783        """
784        Transform the given row, which is assumed to associate with a "pack"
785        item, such that it associates with the "unit" item instead.
786        """
787        if not row.product:
788            return
789        if not row.product.is_pack_item():
790            return
791
792        assert row.batch.is_truck_dump_parent()
793
794        # remove any existing claims for this (parent) row
795        if row.claims:
796            session = orm.object_session(row)
797            del row.claims[:]
798            # set temporary status for the row, if needed.  this is to help
799            # with claiming logic below
800            if row.status_code in (row.STATUS_TRUCKDUMP_PARTCLAIMED,
801                                   row.STATUS_TRUCKDUMP_CLAIMED,
802                                   row.STATUS_TRUCKDUMP_OVERCLAIMED):
803                row.status_code = row.STATUS_TRUCKDUMP_UNCLAIMED
804            session.flush()
805            session.refresh(row)
806
807        # pretty sure this is the only status we're expecting at this point...
808        assert row.status_code == row.STATUS_TRUCKDUMP_UNCLAIMED
809
810        # replace the row's product association
811        pack = row.product
812        unit = pack.unit
813        row.product = unit
814        row.item_id = unit.item_id
815        row.upc = unit.upc
816
817        # set new case quantity, per preferred cost
818        cost = unit.cost_for_vendor(row.batch.vendor)
819        row.case_quantity = (cost.case_size or 1) if cost else 1
820
821        # must recalculate "units received" since those were for the pack item
822        if row.units_received:
823            row.units_received *= pack.pack_size
824
825        # try to establish "claims" between parent and child(ren)
826        self.make_truck_dump_claims_for_parent_row(row)
827
828        # refresh the row itself, so product attributes will be updated
829        self.refresh_row(row)
830
831        # refresh status for the batch(es) proper, just in case this changed things
832        self.refresh_batch_status(row.batch)
833        for child in row.batch.truck_dump_children:
834            self.refresh_batch_status(child)
835
836    def make_truck_dump_claims_for_parent_row(self, row):
837        """
838        Try to establish all "truck dump claims" between parent and children,
839        for the given parent row.
840        """
841        for child in row.batch.truck_dump_children:
842            child_rows = [child_row for child_row in child.active_rows()
843                          if child_row.product_uuid == row.product.uuid]
844            if child_rows:
845                self.make_truck_dump_claims(row, child_rows)
846                if row.status_code not in (row.STATUS_TRUCKDUMP_UNCLAIMED,
847                                           row.STATUS_TRUCKDUMP_PARTCLAIMED):
848                    break
849
850    def refresh_row(self, row, initial=False):
851        """
852        Refreshing a row will A) assume that ``row.product`` is already set to
853        a valid product, or else will attempt to locate the product, and B)
854        update various other fields on the row (description, size, etc.)  to
855        reflect the current product data.  It also will adjust the batch PO
856        total per the row PO total.
857        """
858        batch = row.batch
859
860        # first identify the product, or else we have nothing more to do
861        product = row.product
862        if not product:
863            product = self.locate_product(row)
864            if product:
865                row.product = product
866            else:
867                row.status_code = row.STATUS_PRODUCT_NOT_FOUND
868                return
869
870        # update various (cached) product attributes for the row
871        cost = product.cost_for_vendor(batch.vendor)
872        row.upc = product.upc
873        row.item_id = product.item_id
874        row.brand_name = six.text_type(product.brand or '')
875        row.description = product.description
876        row.size = product.size
877        if product.department:
878            row.department_number = product.department.number
879            row.department_name = product.department.name
880        else:
881            row.department_number = None
882            row.department_name = None
883        row.vendor_code = cost.code if cost else None
884
885        # figure out the effective case quantity, and whether it differs with
886        # what we previously had on file
887        case_quantity_differs = False
888        if cost and cost.case_size:
889            if not row.case_quantity:
890                row.case_quantity = cost.case_size
891            elif row.case_quantity != cost.case_size:
892                if batch.is_truck_dump_parent():
893                    if batch.truck_dump_children_first:
894                        # supposedly our case quantity came from a truck dump
895                        # child row, which we assume to be authoritative
896                        case_quantity_differs = True
897                    else:
898                        # truck dump has no children yet, which means we have
899                        # no special authority for case quantity; therefore
900                        # should treat master cost record as authority
901                        row.case_quantity = cost.case_size
902                else:
903                    case_quantity_differs = True
904
905        # determine PO / invoice unit cost if necessary
906        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING and row.po_unit_cost is None:
907            row.po_unit_cost = self.get_unit_cost(row.product, batch.vendor)
908        if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING and row.invoice_unit_cost is None:
909            row.invoice_unit_cost = row.po_unit_cost or (cost.unit_cost if cost else None)
910
911        # update PO / invoice totals for row and batch
912        self.refresh_totals(row, cost, initial)
913
914        # all that's left should be setting status for the row...and that logic
915        # will primarily depend on the 'mode' for this purchase batch
916
917        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
918            row.status_code = row.STATUS_OK
919
920        elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
921
922            # first check to see if we have *any* confirmed items
923            if not (row.cases_received or row.units_received or
924                    row.cases_damaged or row.units_damaged or
925                    row.cases_expired or row.units_expired or
926                    row.cases_mispick or row.units_mispick):
927
928                # no, we do not have any confirmed items...
929
930                # TODO: is this right? should row which ordered nothing just be removed?
931                if batch.order_quantities_known and not (row.cases_ordered or row.units_ordered):
932                    row.status_code = row.STATUS_OK
933                # TODO: is this right? should out of stock just be a filter for
934                # the user to specify, or should it affect status?
935                elif row.out_of_stock:
936                    row.status_code = row.STATUS_OUT_OF_STOCK
937                else:
938                    row.status_code = row.STATUS_INCOMPLETE
939
940                # truck dump parent rows are also given status for that, which
941                # reflects claimed vs. pending, i.e. child reconciliation
942                if batch.is_truck_dump_parent():
943                    row.truck_dump_status = row.STATUS_TRUCKDUMP_CLAIMED
944
945            else: # we do have some confirmed items
946
947                # primary status code for row should ideally reflect ordered
948                # vs. received, although there are some exceptions
949                # TODO: this used to prefer "case qty differs" and now i'm not
950                # sure what the priority should be..perhaps config should say?
951                if batch.order_quantities_known and (
952                        self.get_units_ordered(row) != self.get_units_accounted_for(row)):
953                    row.status_code = row.STATUS_ORDERED_RECEIVED_DIFFER
954                elif case_quantity_differs:
955                    row.status_code = row.STATUS_CASE_QUANTITY_DIFFERS
956                    row.status_text = "batch has {} but master cost has {}".format(
957                        repr(row.case_quantity), repr(cost.case_size))
958                # TODO: is this right? should out of stock just be a filter for
959                # the user to specify, or should it affect status?
960                elif row.out_of_stock:
961                    row.status_code = row.STATUS_OUT_OF_STOCK
962                else:
963                    row.status_code = row.STATUS_OK
964
965                # truck dump parent rows are also given status for that, which
966                # reflects claimed vs. pending, i.e. child reconciliation
967                if batch.is_truck_dump_parent():
968                    confirmed = self.get_units_confirmed(row)
969                    claimed = self.get_units_claimed(row)
970                    if claimed == confirmed:
971                        row.truck_dump_status = row.STATUS_TRUCKDUMP_CLAIMED
972                    elif not claimed:
973                        row.truck_dump_status = row.STATUS_TRUCKDUMP_UNCLAIMED
974                    elif claimed < confirmed:
975                        row.truck_dump_status = row.STATUS_TRUCKDUMP_PARTCLAIMED
976                    elif claimed > confirmed:
977                        row.truck_dump_status = row.STATUS_TRUCKDUMP_OVERCLAIMED
978                    else:
979                        raise NotImplementedError
980
981        else:
982            raise NotImplementedError("can't refresh row for batch of mode: {}".format(
983                self.enum.PURCHASE_BATCH_MODE.get(batch.mode, "unknown ({})".format(batch.mode))))
984
985    def receive_row(self, row, mode='received', cases=None, units=None, **kwargs):
986        """
987        This method is arguably the workhorse of the whole process. Callers
988        should invoke it as they receive input from the user during the
989        receiving workflow.
990
991        Each call to this method must include the row to be updated, as well as
992        the details of the update.  These details should reflect "changes"
993        which are to be made, as opposed to "final values" for the row.  In
994        other words if a row already has ``cases_received == 1`` and the user
995        is receiving a second case, this method should be called like so::
996
997           handler.receive_row(row, mode='received', cases=1)
998
999        The row will be updated such that ``cases_received == 2``; the main
1000        point here is that the caller should *not* specify ``cases=2`` because
1001        it is the handler's job to "apply changes" from the caller.  (If the
1002        caller speficies ``cases=2`` then the row would end up with
1003        ``cases_received == 3``.)
1004
1005        For "undo" type adjustments, caller can just send a negative amount,
1006        and the handler will apply the changes as expected::
1007
1008           handler.receive_row(row, mode='received', cases=-1)
1009
1010        Note that each call must specify *either* a (non-empty) ``cases`` or
1011        ``units`` value, but *not* both!
1012
1013        :param rattail.db.model.batch.purchase.PurchaseBatchRow row: Batch row
1014           which is to be updated with the given receiving data.  The row must
1015           exist, i.e. this method will not create a new row for you.
1016
1017        :param str mode: Must be one of the receiving modes which are
1018           "supported" according to the handler.  Possible modes include:
1019
1020           * ``'received'``
1021           * ``'damaged'``
1022           * ``'expired'``
1023           * ``'mispick'``
1024
1025        :param decimal.Decimal cases: Case quantity for the update, if applicable.
1026
1027        :param decimal.Decimal units: Unit quantity for the update, if applicable.
1028
1029        :param datetime.date expiration_date: Expiration date for the update,
1030           if applicable.  Only used if ``mode='expired'``.
1031
1032        This method exists mostly to consolidate the various logical steps which
1033        must be taken for each new receiving input from the user.  Under the hood
1034        it delegates to a few other methods:
1035
1036        * :meth:`receiving_update_row_attrs()`
1037        * :meth:`receiving_update_row_credits()`
1038        * :meth:`receiving_update_row_children()`
1039        """
1040        # make sure we have cases *or* units
1041        if not (cases or units):
1042            raise ValueError("must provide amount for cases *or* units")
1043        if cases and units:
1044            raise ValueError("must provide amount for cases *or* units (but not both)")
1045
1046        # make sure we have a receiving batch
1047        if row.batch.mode != self.enum.PURCHASE_BATCH_MODE_RECEIVING:
1048            raise NotImplementedError("receive_row() is only for receiving batches")
1049
1050        # update the given row
1051        self.receiving_update_row_attrs(row, mode, cases, units)
1052
1053        # update the given row's credits
1054        self.receiving_update_row_credits(row, mode, cases, units, **kwargs)
1055
1056        # update the given row's "children" (if this is truck dump parent)
1057        self.receiving_update_row_children(row, mode, cases, units, **kwargs)
1058
1059    def receiving_update_row_attrs(self, row, mode, cases, units):
1060        """
1061        Apply a receiving update to the row's attributes.
1062
1063        Note that this should not be called directly; it is invoked as part of
1064        :meth:`receive_row()`.
1065        """
1066        batch = row.batch
1067
1068        # add values as-is to existing case/unit amounts.  note
1069        # that this can sometimes give us negative values!  e.g. if
1070        # user scans 1 CS and then subtracts 2 EA, then we would
1071        # have 1 / -2 for our counts.  but we consider that to be
1072        # expected, and other logic must allow for the possibility
1073        if cases:
1074            setattr(row, 'cases_{}'.format(mode),
1075                    (getattr(row, 'cases_{}'.format(mode)) or 0) + cases)
1076        if units:
1077            setattr(row, 'units_{}'.format(mode),
1078                    (getattr(row, 'units_{}'.format(mode)) or 0) + units)
1079
1080        # undo any totals previously in effect for the row, then refresh.  this
1081        # updates some totals as well as status for the row
1082        if row.invoice_total:
1083            batch.invoice_total -= row.invoice_total
1084        self.refresh_row(row)
1085
1086    def receiving_update_row_credits(self, row, mode, cases, units, **kwargs):
1087        """
1088        Apply a receiving update to the row's credits, if applicable.
1089
1090        Note that this should not be called directly; it is invoked as part of
1091        :meth:`receive_row()`.
1092        """
1093        batch = row.batch
1094
1095        # only certain modes should involve credits
1096        if mode not in ('damaged', 'expired', 'mispick'):
1097            return
1098
1099        # TODO: need to add mispick support obviously
1100        if mode == 'mispick':
1101            raise NotImplementedError("mispick credits not yet supported")
1102
1103        # TODO: must account for negative values here! i.e. remove credit in
1104        # some scenarios, perhaps using `kwargs` to find the match?
1105        if (cases and cases > 0) or (units and units > 0):
1106            positive = True
1107        else:
1108            positive = False
1109            raise NotImplementedError("TODO: add support for negative values when updating credits")
1110
1111        # always make new credit; never aggregate
1112        credit = model.PurchaseBatchCredit()
1113        self.populate_credit(credit, row)
1114        credit.credit_type = mode
1115        credit.cases_shorted = cases or None
1116        credit.units_shorted = units or None
1117
1118        # calculate credit total
1119        # TODO: should this leverage case cost if present?
1120        credit_units = self.get_units(credit.cases_shorted,
1121                                      credit.units_shorted,
1122                                      credit.case_quantity)
1123        credit.credit_total = credit_units * (credit.invoice_unit_cost or 0)
1124
1125        # apply other attributes to credit, per caller kwargs
1126        credit.product_discarded = kwargs.get('discarded')
1127        if mode == 'expired':
1128            credit.expiration_date = kwargs.get('expiration_date')
1129        elif mode == 'mispick' and kwargs.get('mispick_product'):
1130            mispick_product = kwargs['mispick_product']
1131            credit.mispick_product = mispick_product
1132            credit.mispick_upc = mispick_product.upc
1133            if mispick_product.brand:
1134                credit.mispick_brand_name = mispick_product.brand.name
1135            credit.mispick_description = mispick_product.description
1136            credit.mispick_size = mispick_product.size
1137
1138        # attach credit to row
1139        row.credits.append(credit)
1140
1141    def populate_credit(self, credit, row):
1142        """
1143        Populate all basic attributes for the given credit, from the given row.
1144        """
1145        batch = row.batch
1146
1147        credit.store = batch.store
1148        credit.vendor = batch.vendor
1149        credit.date_ordered = batch.date_ordered
1150        credit.date_shipped = batch.date_shipped
1151        credit.date_received = batch.date_received
1152        credit.invoice_number = batch.invoice_number
1153        credit.invoice_date = batch.invoice_date
1154        credit.product = row.product
1155        credit.upc = row.upc
1156        credit.vendor_item_code = row.vendor_code
1157        credit.brand_name = row.brand_name
1158        credit.description = row.description
1159        credit.size = row.size
1160        credit.department_number = row.department_number
1161        credit.department_name = row.department_name
1162        credit.case_quantity = row.case_quantity
1163        credit.invoice_line_number = row.invoice_line_number
1164        credit.invoice_case_cost = row.invoice_case_cost
1165        credit.invoice_unit_cost = row.invoice_unit_cost
1166        credit.invoice_total = row.invoice_total
1167
1168    def receiving_update_row_children(self, row, mode, cases, units, **kwargs):
1169        """
1170        Apply a receiving update to the row's "children", if applicable.
1171
1172        Note that this should not be called directly; it is invoked as part of
1173        :meth:`receive_row()`.
1174
1175        This logic only applies to a "truck dump parent" row, since that is the
1176        only type which can have "children".  Also this logic is assumed only
1177        to apply if using the "children first" workflow.  If these criteria are
1178        not met then nothing is done.
1179
1180        This method is ultimately responsible for updating "everything"
1181        (relevant) about the children of the given parent row.  This includes
1182        updating the child row(s) as well as the "claim" records used for
1183        reconciliation, as well as any child credit(s).  However most of the
1184        heavy lifting is done by :meth:`receiving_update_row_child()`.
1185        """
1186        batch = row.batch
1187
1188        # updating row children is only applicable for truck dump parent, and
1189        # even then only if "children first" workflow
1190        if not batch.is_truck_dump_parent():
1191            return
1192        # TODO: maybe should just check for `batch.truck_dump_children` instead?
1193        if not batch.truck_dump_children_first:
1194            return
1195
1196        # apply changes to child row(s) until we exhaust update quantities
1197        while cases or units:
1198
1199            # find the "best match" child per current quantities, or quit if we
1200            # can no longer find any child match at all
1201            child_row = self.receiving_find_best_child_row(row, mode, cases, units)
1202            if not child_row:
1203                break
1204
1205            # apply update to child, which should reduce our quantities
1206            before = cases, units
1207            cases, units = self.receiving_update_row_child(row, child_row, mode, cases, units, **kwargs)
1208            if (cases, units) == before:
1209                raise RuntimeError("infinite loop detected; aborting")
1210
1211        # undo any totals previously in effect for the row, then refresh.  this
1212        # updates some totals as well as status for the row
1213        if row.invoice_total:
1214            batch.invoice_total -= row.invoice_total
1215        self.refresh_row(row)
1216
1217    def receiving_update_row_child(self, parent_row, child_row, mode, cases, units, **kwargs):
1218        """
1219        Update the given child row attributes, as well as the "claim" record
1220        which ties it to the parent, as well as any credit(s) which may apply.
1221
1222        Ideally the child row can accommodate the "full" case/unit amounts
1223        given, but if not then it must do as much as it can.  Note that the
1224        child row should have been located via :meth:`receiving_find_best_child_row()`
1225        and therefore should be able to accommodate *something* at least.
1226
1227        This method returns a 2-tuple of ``(cases, units)`` which reflect the
1228        amounts it was *not* able to claim (or relinquish, if incoming amounts
1229        are negative).  In other words these are the "leftovers" which still
1230        need to be dealt with somehow.
1231        """
1232        # were we given positive or negative values for the update?
1233        if (cases and cases > 0) or (units and units > 0):
1234            positive = True
1235        else:
1236            positive = False
1237
1238        ##############################
1239
1240        def update(cases, units):
1241
1242            # update child claim
1243            claim = get_claim()
1244            if cases:
1245                setattr(claim, 'cases_{}'.format(mode),
1246                        (getattr(claim, 'cases_{}'.format(mode)) or 0) + cases)
1247            if units:
1248                setattr(claim, 'units_{}'.format(mode),
1249                        (getattr(claim, 'units_{}'.format(mode)) or 0) + units)
1250            # remove claim if now empty (should only happen if negative values?)
1251            if claim.is_empty():
1252                parent_row.claims.remove(claim)
1253
1254            # update child row
1255            self.receiving_update_row_attrs(child_row, mode, cases, units)
1256            if cases:
1257                child_row.cases_ordered_claimed += cases
1258                child_row.cases_ordered_pending -= cases
1259            if units:
1260                child_row.units_ordered_claimed += units
1261                child_row.units_ordered_pending -= units
1262
1263            # update child credit, if applicable
1264            self.receiving_update_row_credits(child_row, mode, cases, units, **kwargs)
1265
1266        def get_claim():
1267            claims = [claim for claim in parent_row.claims
1268                      if claim.claiming_row is child_row]
1269            if claims:
1270                if len(claims) > 1:
1271                    raise ValueError("child row has too many claims on parent!")
1272                return claims[0]
1273            claim = model.PurchaseBatchRowClaim()
1274            claim.claiming_row = child_row
1275            parent_row.claims.append(claim)
1276            return claim
1277
1278        ##############################
1279
1280        # first we try to accommodate the full "as-is" amounts, if possible
1281        if positive:
1282            if cases and units:
1283                if child_row.cases_ordered_pending >= cases and child_row.units_ordered_pending >= units:
1284                    update(cases, units)
1285                    return 0, 0
1286            elif cases:
1287                if child_row.cases_ordered_pending >= cases:
1288                    update(cases, 0)
1289                    return 0, 0
1290            else: # units
1291                if child_row.units_ordered_pending >= units:
1292                    update(0, units)
1293                    return 0, 0
1294        else: # negative
1295            if cases and units:
1296                if child_row.cases_ordered_claimed >= -cases and child_row.units_ordered_claimed >= -units:
1297                    update(cases, units)
1298                    return 0, 0
1299            elif cases:
1300                if child_row.cases_ordered_claimed >= -cases:
1301                    update(cases, 0)
1302                    return 0, 0
1303            else: # units
1304                if child_row.units_ordered_claimed >= -units:
1305                    update(0, units)
1306                    return 0, 0
1307
1308        # next we try a couple more variations on that theme, aiming for "as
1309        # much as possible, as simply as possible"
1310        if cases and units:
1311            if positive:
1312                if child_row.cases_ordered_pending >= cases:
1313                    update(cases, 0)
1314                    return 0, units
1315                if child_row.units_ordered_pending >= units:
1316                    update(0, units)
1317                    return cases, 0
1318            else: # negative
1319                if child_row.cases_ordered_claimed >= -cases:
1320                    update(cases, 0)
1321                    return 0, units
1322                if child_row.units_ordered_claimed >= -units:
1323                    update(0, units)
1324                    return cases, 0
1325
1326        # okay then, try to (simply) use up any "child" quantities
1327        if positive:
1328            if cases and units and (child_row.cases_ordered_pending
1329                                    and child_row.units_ordered_pending):
1330                update(child_row.cases_ordered_pending, child_row.units_ordered_pending)
1331                return (cases - child_row.cases_ordered_pending,
1332                        units - child_row.units_ordered_pending)
1333            if cases and child_row.cases_ordered_pending:
1334                update(child_row.cases_ordered_pending, 0)
1335                return cases - child_row.cases_ordered_pending, 0
1336            if units and child_row.units_ordered_pending:
1337                update(0, child_row.units_ordered_pending)
1338                return 0, units - child_row.units_ordered_pending
1339        else: # negative
1340            if cases and units and (child_row.cases_ordered_claimed
1341                                    and child_row.units_ordered_claimed):
1342                update(-child_row.cases_ordered_claimed, -child_row.units_ordered_claimed)
1343                return (cases + child_row.cases_ordered_claimed,
1344                        units + child_row.units_ordered_claimed)
1345            if cases and child_row.cases_ordered_claimed:
1346                update(-child_row.cases_ordered_claimed, 0)
1347                return cases + child_row.cases_ordered_claimed, 0
1348            if units and child_row.units_ordered_claimed:
1349                update(0, -child_row.units_ordered_claimed)
1350                return 0, units + child_row.units_ordered_claimed
1351
1352        # looks like we're gonna have to split some cases, one way or another
1353        if parent_row.case_quantity != child_row.case_quantity:
1354            raise NotImplementedError("cannot split case when parent/child disagree about size")
1355        if positive:
1356            if cases and child_row.units_ordered_pending:
1357                if child_row.units_ordered_pending >= parent_row.case_quantity:
1358                    unit_cases = child_row.units_ordered_pending // parent_row.case_quantity
1359                    if unit_cases >= cases:
1360                        update(0, cases * parent_row.case_quantity)
1361                        return 0, units
1362                    else: # unit_cases < cases
1363                        update(0, unit_cases * parent_row.case_quantity)
1364                        return cases - unit_cases, units
1365                else: # units_pending < case_size
1366                    update(0, child_row.units_ordered_pending)
1367                    return (cases - 1,
1368                            (units or 0) + parent_row.case_quantity - child_row.units_ordered_pending)
1369            if units and child_row.cases_ordered_pending:
1370                if units >= parent_row.case_quantity:
1371                    unit_cases = units // parent_row.case_quantity
1372                    if unit_cases <= child_row.cases_ordered_pending:
1373                        update(unit_cases, 0)
1374                        return 0, units - (unit_cases * parent_row.case_quantity)
1375                    else: # unit_cases > cases_pending
1376                        update(child_row.cases_ordered_pending, 0)
1377                        return 0, units - (child_row.cases_ordered_pending * parent_row.case_quantity)
1378                else: # units < case_size
1379                    update(0, units)
1380                    return 0, 0
1381        else: # negative
1382            if cases and child_row.units_ordered_claimed:
1383                if child_row.units_ordered_claimed >= parent_row.case_quantity:
1384                    unit_cases = child_row.units_ordered_claimed // parent_row.case_quantity
1385                    if unit_cases >= -cases:
1386                        update(0, cases * parent_row.case_quantity)
1387                        return 0, units
1388                    else: # unit_cases < -cases
1389                        update(0, -unit_cases * parent_row.case_quantity)
1390                        return cases + unit_cases, units
1391                else: # units_claimed < case_size
1392                    update(0, -child_row.units_ordered_claimed)
1393                    return (cases + 1,
1394                            (units or 0) - parent_row.case_quantity + child_row.units_ordered_claimed)
1395            if units and child_row.cases_ordered_claimed:
1396                if -units >= parent_row.case_quantity:
1397                    unit_cases = -units // parent_row.case_quantity
1398                    if unit_cases <= child_row.cases_ordered_claimed:
1399                        update(-unit_cases, 0)
1400                        return 0, units + (unit_cases * parent_row.case_quantity)
1401                    else: # unit_cases > cases_claimed
1402                        update(-child_row.cases_ordered_claimed, 0)
1403                        return 0, units + (child_row.cases_ordered_claimed * parent_row.case_quantity)
1404                else: # -units < case_size
1405                    update(0, units)
1406                    return 0, 0
1407
1408        # TODO: this should theoretically never happen; should log/raise error?
1409        log.warning("unable to claim/relinquish any case/unit amounts for child row: %s", child_row)
1410        return cases, units
1411
1412    def receiving_find_best_child_row(self, row, mode, cases, units):
1413        """
1414        Locate and return the "best match" child row, for the given parent row
1415        and receiving update details.  The idea here is that the parent row
1416        will represent the "receiving" side of things, whereas the child row
1417        will be the "ordering" side.
1418
1419        For instance if the update is for say, "received 2 CS" and there are
1420        two child rows, one of which is for 1 CS and the other 2 CS, the latter
1421        will be returned.  This logic is capable of "splitting" a case where
1422        necessary, in order to find a partial match etc.
1423        """
1424        parent_row = row
1425        parent_batch = parent_row.batch
1426
1427        if not parent_row.product:
1428            raise NotImplementedError("not sure how to find best match for unknown product")
1429
1430        if not (cases or units):
1431            raise ValueError("must provide amount for cases and/or units")
1432
1433        if cases and units and (
1434                (cases > 0 and units < 0) or (cases < 0 and units > 0)):
1435            raise NotImplementedError("not sure how to handle mixed pos/neg for case/unit amounts")
1436
1437        # were we given positive or negative values for the update?
1438        if (cases and cases > 0) or (units and units > 0):
1439            positive = True
1440        else:
1441            positive = False
1442
1443        # first we collect all potential child rows
1444        all_child_rows = []
1445        for child_batch in parent_batch.truck_dump_children:
1446            child_rows = [child_row for child_row in child_batch.active_rows()
1447                          if child_row.product_uuid == parent_row.product.uuid]
1448            for child_row in child_rows:
1449
1450                # for each child row we also calculate "claimed" vs. "pending" amounts
1451
1452                # cases_ordered
1453                child_row.cases_ordered_claimed = sum([(claim.cases_received or 0)
1454                                                       + (claim.cases_damaged or 0)
1455                                                       + (claim.cases_expired or 0)
1456                                                       for claim in child_row.truck_dump_claims])
1457                child_row.cases_ordered_pending = (child_row.cases_ordered or 0) - child_row.cases_ordered_claimed
1458
1459                # units_ordered
1460                child_row.units_ordered_claimed = sum([(claim.units_received or 0)
1461                                                       + (claim.units_damaged or 0)
1462                                                       + (claim.units_expired or 0)
1463                                                       for claim in child_row.truck_dump_claims])
1464                child_row.units_ordered_pending = (child_row.units_ordered or 0) - child_row.units_ordered_claimed
1465
1466                # maybe account for split cases
1467                if child_row.units_ordered_pending < 0:
1468                    split_cases = -child_row.units_ordered_pending // child_row.case_quantity
1469                    if -child_row.units_ordered_pending % child_row.case_quantity:
1470                        split_cases += 1
1471                    if split_cases > child_row.cases_ordered_pending:
1472                        raise ValueError("too many cases have been split?")
1473                    child_row.cases_ordered_pending -= split_cases
1474                    child_row.units_ordered_pending += split_cases * child_row.case_quantity
1475
1476                all_child_rows.append(child_row)
1477
1478        def sortkey(row):
1479            if positive:
1480                return self.get_units(row.cases_ordered_pending,
1481                                      row.units_ordered_pending,
1482                                      row.case_quantity)
1483            else: # negative
1484                return self.get_units(row.cases_ordered_claimed,
1485                                      row.units_ordered_claimed,
1486                                      row.case_quantity)
1487
1488        # sort child rows such that smallest (relevant) quantities come first;
1489        # idea being we would prefer the "least common denominator" to match
1490        all_child_rows.sort(key=sortkey)
1491
1492        # first try to find an exact match
1493        for child_row in all_child_rows:
1494            if cases and units:
1495                if positive:
1496                    if child_row.cases_ordered_pending == cases and child_row.units_ordered_pending == units:
1497                        return child_row
1498                else: # negative
1499                    if child_row.cases_ordered_claimed == cases and child_row.units_ordered_claimed == units:
1500                        return child_row
1501            elif cases:
1502                if positive:
1503                    if child_row.cases_ordered_pending == cases:
1504                        return child_row
1505                else: # negative
1506                    if child_row.cases_ordered_claimed == cases:
1507                        return child_row
1508            else: # units
1509                if positive:
1510                    if child_row.units_ordered_pending == units:
1511                        return child_row
1512                else: # negative
1513                    if child_row.units_ordered_claimed == units:
1514                        return child_row
1515
1516        # next we try to find the "first" (smallest) match which satisfies, but
1517        # which does so *without* having to split up any cases
1518        for child_row in all_child_rows:
1519            if cases and units:
1520                if positive:
1521                    if child_row.cases_ordered_pending >= cases and child_row.units_ordered_pending >= units:
1522                        return child_row
1523                else: # negative
1524                    if child_row.cases_ordered_claimed >= -cases and child_row.units_ordered_claimed >= -units:
1525                        return child_row
1526            elif cases:
1527                if positive:
1528                    if child_row.cases_ordered_pending >= cases:
1529                        return child_row
1530                else: # negative
1531                    if child_row.cases_ordered_claimed >= -cases:
1532                        return child_row
1533            else: # units
1534                if positive:
1535                    if child_row.units_ordered_pending >= units:
1536                        return child_row
1537                else: # negative
1538                    if child_row.units_ordered_claimed >= -units:
1539                        return child_row
1540
1541        # okay, we're getting desperate now; let's start splitting cases and
1542        # may the first possible match (which fully satisfies) win...
1543        incoming_units = self.get_units(cases, units, parent_row.case_quantity)
1544        for child_row in all_child_rows:
1545            if positive:
1546                pending_units = self.get_units(child_row.cases_ordered_pending,
1547                                               child_row.units_ordered_pending,
1548                                               child_row.case_quantity)
1549                if pending_units >= incoming_units:
1550                    return child_row
1551            else: # negative
1552                claimed_units = self.get_units(child_row.cases_ordered_claimed,
1553                                               child_row.units_ordered_claimed,
1554                                               child_row.case_quantity)
1555                if claimed_units >= -incoming_units:
1556                    return child_row
1557
1558        # and now we're even more desperate.  at this point no child row can
1559        # fully (by itself) accommodate the update at hand, which means we must
1560        # look for the first child which can accommodate anything at all, and
1561        # settle for the partial match.  note that we traverse the child row
1562        # list *backwards* here, hoping for the "biggest" match
1563        for child_row in reversed(all_child_rows):
1564            if positive:
1565                if child_row.cases_ordered_pending or child_row.units_ordered_pending:
1566                    return child_row
1567            else: # negative
1568                if child_row.cases_ordered_claimed or child_row.units_ordered_claimed:
1569                    return child_row
1570
1571    def remove_row(self, row):
1572        """
1573        When removing a row from purchase batch, (maybe) must also update some
1574        totals for the batch.
1575        """
1576        batch = row.batch
1577
1578        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
1579            if row.po_total:
1580                batch.po_total -= row.po_total
1581
1582        if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
1583            if row.invoice_total:
1584                batch.invoice_total -= row.invoice_total
1585
1586        super(PurchaseBatchHandler, self).remove_row(row)
1587
1588    def refresh_totals(self, row, cost, initial):
1589        batch = row.batch
1590
1591        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
1592            if row.po_unit_cost:
1593                row.po_total = row.po_unit_cost * self.get_units_ordered(row)
1594                batch.po_total = (batch.po_total or 0) + row.po_total
1595            else:
1596                row.po_total = None
1597
1598        elif batch.mode in (self.enum.PURCHASE_BATCH_MODE_RECEIVING,
1599                            self.enum.PURCHASE_BATCH_MODE_COSTING):
1600            if row.invoice_unit_cost:
1601                row.invoice_total = row.invoice_unit_cost * self.get_units_accounted_for(row)
1602                batch.invoice_total = (batch.invoice_total or 0) + row.invoice_total
1603            else:
1604                row.invoice_total = None
1605
1606    def get_unit_cost(self, product, vendor):
1607        """
1608        Must return the PO unit cost for the given product, from the given vendor.
1609        """
1610        cost = product.cost_for_vendor(vendor) or product.cost
1611        if cost:
1612            return cost.unit_cost
1613
1614    def get_units(self, cases, units, case_quantity):
1615        case_quantity = case_quantity or 1
1616        return (units or 0) + case_quantity * (cases or 0)
1617
1618    def get_units_ordered(self, row, case_quantity=None):
1619        case_quantity = case_quantity or row.case_quantity or 1
1620        return self.get_units(row.cases_ordered, row.units_ordered, case_quantity)
1621
1622    # TODO: we now have shipped quantities...should return sum of those instead?
1623    def get_units_shipped(self, row, case_quantity=None):
1624        case_quantity = case_quantity or row.case_quantity or 1
1625        units_damaged = (row.units_damaged or 0) + case_quantity * (row.cases_damaged or 0)
1626        units_expired = (row.units_expired or 0) + case_quantity * (row.cases_expired or 0)
1627        return self.get_units_received(row) + units_damaged + units_expired
1628
1629    def get_units_received(self, row, case_quantity=None):
1630        case_quantity = case_quantity or row.case_quantity or 1
1631        return self.get_units(row.cases_received, row.units_received, case_quantity)
1632
1633    def get_units_damaged(self, row, case_quantity=None):
1634        case_quantity = case_quantity or row.case_quantity or 1
1635        return self.get_units(row.cases_damaged, row.units_damaged, case_quantity)
1636
1637    def get_units_expired(self, row, case_quantity=None):
1638        case_quantity = case_quantity or row.case_quantity or 1
1639        return self.get_units(row.cases_expired, row.units_expired, case_quantity)
1640
1641    def get_units_confirmed(self, row, case_quantity=None):
1642        received = self.get_units_received(row, case_quantity=case_quantity)
1643        damaged = self.get_units_damaged(row, case_quantity=case_quantity)
1644        expired = self.get_units_expired(row, case_quantity=case_quantity)
1645        return received + damaged + expired
1646
1647    def get_units_mispick(self, row, case_quantity=None):
1648        case_quantity = case_quantity or row.case_quantity or 1
1649        return self.get_units(row.cases_mispick, row.units_mispick, case_quantity)
1650
1651    def get_units_accounted_for(self, row, case_quantity=None):
1652        confirmed = self.get_units_confirmed(row, case_quantity=case_quantity)
1653        mispick = self.get_units_mispick(row, case_quantity=case_quantity)
1654        return confirmed + mispick
1655
1656    def get_units_shorted(self, obj, case_quantity=None):
1657        case_quantity = case_quantity or obj.case_quantity or 1
1658        if hasattr(obj, 'cases_shorted'):
1659            # obj is really a credit
1660            return self.get_units(obj.cases_shorted, obj.units_shorted, case_quantity)
1661        else:
1662            # obj is a row, so sum the credits
1663            return sum([self.get_units(credit.cases_shorted, credit.units_shorted, case_quantity)
1664                        for credit in obj.credits])
1665
1666    def get_units_claimed(self, row, case_quantity=None):
1667        """
1668        Returns the total number of units which are "claimed" by child rows,
1669        for the given truck dump parent row.
1670        """
1671        claimed = 0
1672        for claim in row.claims:
1673            # prefer child row's notion of case quantity, over parent row
1674            case_qty = case_quantity or claim.claiming_row.case_quantity or row.case_quantity
1675            claimed += self.get_units_confirmed(claim, case_quantity=case_qty)
1676        return claimed
1677
1678    def get_units_claimed_received(self, row, case_quantity=None):
1679        return sum([self.get_units_received(claim, case_quantity=row.case_quantity)
1680                    for claim in row.claims])
1681
1682    def get_units_claimed_damaged(self, row, case_quantity=None):
1683        return sum([self.get_units_damaged(claim, case_quantity=row.case_quantity)
1684                    for claim in row.claims])
1685
1686    def get_units_claimed_expired(self, row, case_quantity=None):
1687        return sum([self.get_units_expired(claim, case_quantity=row.case_quantity)
1688                    for claim in row.claims])
1689
1690    def get_units_available(self, row, case_quantity=None):
1691        confirmed = self.get_units_confirmed(row, case_quantity=case_quantity)
1692        claimed = self.get_units_claimed(row, case_quantity=case_quantity)
1693        return confirmed - claimed
1694
1695    def auto_receive_all_items(self, batch, progress=None):
1696        """
1697        Automatically "receive" all items for the given batch.  Meant for
1698        development purposes only!
1699        """
1700        if self.config.production():
1701            raise NotImplementedError("Feature is not meant for production use.")
1702
1703        def receive(row, i):
1704
1705            # auto-receive whatever is left
1706            cases, units = self.calculate_pending(row)
1707            if cases:
1708                self.receive_row(row, mode='received', cases=cases)
1709            if units:
1710                self.receive_row(row, mode='received', units=units)
1711
1712        self.progress_loop(receive, batch.active_rows(), progress,
1713                           message="Auto-receiving all items")
1714
1715        self.refresh(batch, progress=progress)
1716
1717    def update_order_counts(self, purchase, progress=None):
1718
1719        def update(item, i):
1720            if item.product:
1721                inventory = item.product.inventory or model.ProductInventory(product=item.product)
1722                inventory.on_order = (inventory.on_order or 0) + (item.units_ordered or 0) + (
1723                    (item.cases_ordered or 0) * (item.case_quantity or 1))
1724
1725        self.progress_loop(update, purchase.items, progress,
1726                           message="Updating inventory counts")
1727
1728    def update_receiving_inventory(self, purchase, consume_on_order=True, progress=None):
1729
1730        def update(item, i):
1731            if item.product:
1732                inventory = item.product.inventory or model.ProductInventory(product=item.product)
1733                count = (item.units_received or 0) + (item.cases_received or 0) * (item.case_quantity or 1)
1734                if count:
1735                    if consume_on_order:
1736                        if (inventory.on_order or 0) < count:
1737                            raise RuntimeError("Received {} units for {} but it only had {} on order".format(
1738                                count, item.product, inventory.on_order or 0))
1739                        inventory.on_order -= count
1740                    inventory.on_hand = (inventory.on_hand or 0) + count
1741
1742        self.progress_loop(update, purchase.items, progress,
1743                           message="Updating inventory counts")
1744
1745    def why_not_execute(self, batch):
1746        """
1747        This method should return a string indicating the reason why the given
1748        batch should not be considered executable.  By default it returns
1749        ``None`` which means the batch *is* to be considered executable.
1750
1751        Note that it is assumed the batch has not already been executed, since
1752        execution is globally prevented for such batches.
1753        """
1754        # not all receiving batches are executable
1755        if batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
1756
1757            if batch.is_truck_dump_parent() and batch.truck_dump_status != batch.STATUS_TRUCKDUMP_CLAIMED:
1758                return ("Can't execute a Truck Dump (parent) batch until "
1759                        "it has been fully claimed by children")
1760
1761            if batch.is_truck_dump_child():
1762                return ("Can't directly execute batch which is child of a truck dump "
1763                        "(must execute truck dump instead)")
1764
1765    def execute(self, batch, user, progress=None):
1766        """
1767        Default behavior for executing a purchase batch will create a new
1768        purchase, by invoking :meth:`make_purchase()`.
1769        """
1770        session = orm.object_session(batch)
1771
1772        if batch.mode == self.enum.PURCHASE_BATCH_MODE_ORDERING:
1773            purchase = self.make_purchase(batch, user, progress=progress)
1774            self.update_order_counts(purchase, progress=progress)
1775            return purchase
1776
1777        elif batch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING:
1778            if not batch.date_received:
1779                batch.date_received = localtime(self.config).date()
1780            if self.allow_truck_dump and batch.is_truck_dump_parent():
1781                self.execute_truck_dump(batch, user, progress=progress)
1782                return True
1783            else:
1784                with session.no_autoflush:
1785                    return self.receive_purchase(batch, progress=progress)
1786
1787        elif batch.mode == self.enum.PURCHASE_BATCH_MODE_COSTING:
1788            # TODO: finish this...
1789            # with session.no_autoflush:
1790            #     return self.cost_purchase(batch, progress=progress)
1791            purchase = batch.purchase
1792            purchase.invoice_date = batch.invoice_date
1793            purchase.status = self.enum.PURCHASE_STATUS_COSTED
1794            return purchase
1795
1796        assert False
1797
1798    def execute_truck_dump(self, batch, user, progress=None):
1799        now = make_utc()
1800        for child in batch.truck_dump_children:
1801            if not self.execute(child, user, progress=progress):
1802                raise RuntimeError("Failed to execute child batch: {}".format(child))
1803            child.executed = now
1804            child.executed_by = user
1805
1806    def make_credits(self, batch, progress=None):
1807        """
1808        Make all final credit records for the given batch.  Meant to be called
1809        as part of the batch execution process.
1810        """
1811        session = orm.object_session(batch)
1812        mapper = orm.class_mapper(model.PurchaseBatchCredit)
1813        date_received = batch.date_received
1814        if not date_received:
1815            date_received = localtime(self.config).date()
1816
1817        def add_credits(row, i):
1818
1819            # basically "clone" existing credits from batch row
1820            for batch_credit in row.credits:
1821                credit = model.PurchaseCredit()
1822                for prop in mapper.iterate_properties:
1823                    if isinstance(prop, orm.ColumnProperty) and hasattr(credit, prop.key):
1824                        setattr(credit, prop.key, getattr(batch_credit, prop.key))
1825                credit.status = self.enum.PURCHASE_CREDIT_STATUS_NEW
1826                if not credit.date_received:
1827                    credit.date_received = date_received
1828                session.add(credit)
1829
1830            # maybe create "missing" credits for items not accounted for
1831            if not row.out_of_stock:
1832                cases, units = self.calculate_pending(row)
1833                if cases or units:
1834                    credit = model.PurchaseCredit()
1835                    self.populate_credit(credit, row)
1836                    credit.credit_type = 'missing'
1837                    credit.cases_shorted = cases or None
1838                    credit.units_shorted = units or None
1839
1840                    # calculate credit total
1841                    # TODO: should this leverage case cost if present?
1842                    credit_units = self.get_units(credit.cases_shorted,
1843                                                  credit.units_shorted,
1844                                                  credit.case_quantity)
1845                    credit.credit_total = credit_units * (credit.invoice_unit_cost or 0)
1846
1847                    credit.status = self.enum.PURCHASE_CREDIT_STATUS_NEW
1848                    if not credit.date_received:
1849                        credit.date_received = date_received
1850                    session.add(credit)
1851
1852        return self.progress_loop(add_credits, batch.active_rows(), progress,
1853                                  message="Creating purchase credits")
1854
1855    def calculate_pending(self, row):
1856        """
1857        Calculate the "pending" case and unit amounts for the given row.  This
1858        essentially is the difference between "ordered" and "confirmed",
1859        e.g. if a row has ``cases_ordered == 2`` and ``cases_received == 1``
1860        then it is considered to have "1 pending case".
1861
1862        Note that this method *is* aware of the "split cases" problem, and will
1863        adjust the pending amounts if any split cases are detected.
1864
1865        :returns: A 2-tuple of ``(cases, units)`` pending amounts.
1866        """
1867        # calculate remaining cases, units
1868        cases_confirmed = ((row.cases_received or 0)
1869                           + (row.cases_damaged or 0)
1870                           + (row.cases_expired or 0))
1871        cases_pending = (row.cases_ordered or 0) - cases_confirmed
1872        units_confirmed = ((row.units_received or 0)
1873                           + (row.units_damaged or 0)
1874                           + (row.units_expired or 0))
1875        units_pending = (row.units_ordered or 0) - units_confirmed
1876
1877        # maybe account for split cases
1878        if units_pending < 0:
1879            split_cases = -units_pending // row.case_quantity
1880            if -units_pending % row.case_quantity:
1881                split_cases += 1
1882            if split_cases > cases_pending:
1883                raise ValueError("too many cases have been split?")
1884            cases_pending -= split_cases
1885            units_pending += split_cases * row.case_quantity
1886
1887        return cases_pending, units_pending
1888
1889    def make_purchase(self, batch, user, progress=None):
1890        """
1891        Effectively clones the given batch, creating a new Purchase in the
1892        Rattail system.
1893        """
1894        session = orm.object_session(batch)
1895        purchase = model.Purchase()
1896
1897        # TODO: should be smarter and only copy certain fields here
1898        skip_fields = [
1899            'date_received',
1900        ]
1901        for prop in orm.object_mapper(batch).iterate_properties:
1902            if prop.key in skip_fields:
1903                continue
1904            if hasattr(purchase, prop.key):
1905                setattr(purchase, prop.key, getattr(batch, prop.key))
1906
1907        def clone(row, i):
1908            item = model.PurchaseItem()
1909            # TODO: should be smarter and only copy certain fields here
1910            for prop in orm.object_mapper(row).iterate_properties:
1911                if hasattr(item, prop.key):
1912                    setattr(item, prop.key, getattr(row, prop.key))
1913            purchase.items.append(item)
1914
1915        with session.no_autoflush:
1916            self.progress_loop(clone, batch.active_rows(), progress,
1917                               message="Creating purchase items")
1918
1919        purchase.created = make_utc()
1920        purchase.created_by = user
1921        purchase.status = self.enum.PURCHASE_STATUS_ORDERED
1922        session.add(purchase)
1923        batch.purchase = purchase
1924        return purchase
1925
1926    def receive_purchase(self, batch, progress=None):
1927        """
1928        Update the purchase for the given batch, to indicate received status.
1929        """
1930        session = orm.object_session(batch)
1931        purchase = batch.purchase
1932        if not purchase:
1933            batch.purchase = purchase = model.Purchase()
1934
1935            # TODO: should be smarter and only copy certain fields here
1936            skip_fields = [
1937                'uuid',
1938                'date_received',
1939            ]
1940            with session.no_autoflush:
1941                for prop in orm.object_mapper(batch).iterate_properties:
1942                    if prop.key in skip_fields:
1943                        continue
1944                    if hasattr(purchase, prop.key):
1945                        setattr(purchase, prop.key, getattr(batch, prop.key))
1946
1947        purchase.invoice_number = batch.invoice_number
1948        purchase.invoice_date = batch.invoice_date
1949        purchase.invoice_total = batch.invoice_total
1950        purchase.date_received = batch.date_received
1951
1952        # determine which fields we'll copy when creating new purchase item
1953        copy_fields = []
1954        for prop in orm.class_mapper(model.PurchaseItem).iterate_properties:
1955            if hasattr(model.PurchaseBatchRow, prop.key):
1956                copy_fields.append(prop.key)
1957
1958        def update(row, i):
1959            item = row.item
1960            if not item:
1961                row.item = item = model.PurchaseItem()
1962                for field in copy_fields:
1963                    setattr(item, field, getattr(row, field))
1964                purchase.items.append(item)
1965
1966            item.cases_received = row.cases_received
1967            item.units_received = row.units_received
1968            item.cases_damaged = row.cases_damaged
1969            item.units_damaged = row.units_damaged
1970            item.cases_expired = row.cases_expired
1971            item.units_expired = row.units_expired
1972            item.invoice_line_number = row.invoice_line_number
1973            item.invoice_case_cost = row.invoice_case_cost
1974            item.invoice_unit_cost = row.invoice_unit_cost
1975            item.invoice_total = row.invoice_total
1976
1977        with session.no_autoflush:
1978            self.progress_loop(update, batch.active_rows(), progress,
1979                               message="Updating purchase line items")
1980
1981        purchase.status = self.enum.PURCHASE_STATUS_RECEIVED
1982        return purchase
1983
1984    def clone_row(self, oldrow):
1985        newrow = super(PurchaseBatchHandler, self).clone_row(oldrow)
1986
1987        for oldcredit in oldrow.credits:
1988            newcredit = model.PurchaseBatchCredit()
1989            self.copy_credit_attributes(oldcredit, newcredit)
1990            newrow.credits.append(newcredit)
1991
1992        return newrow
1993
1994    def copy_credit_attributes(self, source_credit, target_credit):
1995        mapper = orm.class_mapper(model.PurchaseBatchCredit)
1996        for prop in mapper.iterate_properties:
1997            if prop.key not in ('uuid', 'row_uuid'):
1998                if isinstance(prop, orm.ColumnProperty):
1999                    setattr(target_credit, prop.key, getattr(source_credit, prop.key))
Note: See TracBrowser for help on using the repository browser.