source: rattail/rattail/batch/purchase.py @ 1d05f73

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

Use "shipped" instead of "ordered" for truck dump child row "claims"

i.e. if 1 CS is received, that should count against the *shipped* quantity and
not the "ordered" quantity. if shipped is not explicitly stated on the
invoice, its value should be copied from the ordered quantity

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