source: rattail/rattail/batch/purchase.py @ 1242a5e

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

Fix how some "receive row" logic worked, for aggregated product rows

also fix the batch invoice total aggregation, hopefully

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