source: rattail/rattail/batch/purchase.py @ 277334e8

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

Add improved logic for finding/updating row children for truck dump

i.e. during the "receive row" process on the parent

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