source: rattail/rattail/batch/purchase.py @ bdfaff6

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

Prefer null over zero, for empty amounts in credit record

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