source: rattail/rattail/batch/purchase.py @ 7b4d418

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

Add "calculated" invoice total for receiving row, batch

so then invoice_total is meant to reflect the "original" total as obtained
from the invoice proper, whereas invoice_total_calculated is up to us

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