Changeset 277334e8 in rattail


Ignore:
Timestamp:
03/01/19 10:46:15 (7 weeks ago)
Author:
Lance Edgar <ledgar@…>
Branches:
master
Children:
a60b4f3
Parents:
5cff218
Message:

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

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

File:
1 edited

Legend:

Unmodified
Added
Removed
  • rattail/batch/purchase.py

    r5cff218 r277334e8  
    2525"""
    2626
    27 from __future__ import unicode_literals, absolute_import
     27from __future__ import unicode_literals, absolute_import, division
    2828
    2929import logging
     
    994994        ``cases_received == 3``.)
    995995
    996         For "undo" type adjustments, can just send a negative amount, and the
    997         handler will apply the changes as expected::
     996        For "undo" type adjustments, caller can just send a negative amount,
     997        and the handler will apply the changes as expected::
    998998
    999999           handler.receive_row(row, mode='received', cases=-1)
     
    10021002        ``units`` value, but *not* both!
    10031003
    1004         :param rattail.db.model.batch.purchase.PurchaseBatchRow row: Reference
    1005            to the batch row which is to be updated with the given receiving
    1006            data.  The row must exist, i.e. this method will not create a new
    1007            row for you.
     1004        :param rattail.db.model.batch.purchase.PurchaseBatchRow row: Batch row
     1005           which is to be updated with the given receiving data.  The row must
     1006           exist, i.e. this method will not create a new row for you.
    10081007
    10091008        :param str mode: Must be one of the receiving modes which are
     
    10921091        if mode == 'mispick':
    10931092            raise NotImplementedError("mispick credits not yet supported")
     1093
     1094        # TODO: must account for negative values here! i.e. remove credit in
     1095        # some scenarios, perhaps using `kwargs` to find the match?
     1096        if (cases and cases > 0) or (units and units > 0):
     1097            positive = True
     1098        else:
     1099            positive = False
     1100            raise NotImplementedError("TODO: add support for negative values when updating credits")
    10941101
    10951102        # always make new credit; never aggregate
     
    11481155        Note that this should not be called directly; it is invoked as part of
    11491156        :meth:`receive_row()`.
     1157
     1158        This logic only applies to a "truck dump parent" row, since that is the
     1159        only type which can have "children".  Also this logic is assumed only
     1160        to apply if using the "children first" workflow.  If these criteria are
     1161        not met then nothing is done.
     1162
     1163        This method is ultimately responsible for updating "everything"
     1164        (relevant) about the children of the given parent row.  This includes
     1165        updating the child row(s) as well as the "claim" records used for
     1166        reconciliation, as well as any child credit(s).  However most of the
     1167        heavy lifting is done by :meth:`receiving_update_row_child()`.
    11501168        """
    11511169        batch = row.batch
     
    11551173        if not batch.is_truck_dump_parent():
    11561174            return
     1175        # TODO: maybe should just check for `batch.truck_dump_children` instead?
    11571176        if not batch.truck_dump_children_first:
    11581177            return
    11591178
    1160         # TODO: this is *not* sufficient, but is all previous logic did
    1161         if row.product:
    1162             self.make_truck_dump_claims_for_parent_row(row)
     1179        # apply changes to child row(s) until we exhaust update quantities
     1180        while cases or units:
     1181
     1182            # find the "best match" child per current quantities, or quit if we
     1183            # can no longer find any child match at all
     1184            child_row = self.receiving_find_best_child_row(row, mode, cases, units)
     1185            if not child_row:
     1186                break
     1187
     1188            # apply update to child, which should reduce our quantities
     1189            before = cases, units
     1190            cases, units = self.receiving_update_row_child(row, child_row, mode, cases, units, **kwargs)
     1191            if (cases, units) == before:
     1192                raise RuntimeError("infinite loop detected; aborting")
     1193
     1194        # undo any totals previously in effect for the row, then refresh.  this
     1195        # updates some totals as well as status for the row
     1196        if row.invoice_total:
     1197            batch.invoice_total -= row.invoice_total
     1198        self.refresh_row(row)
     1199
     1200    def receiving_update_row_child(self, parent_row, child_row, mode, cases, units, **kwargs):
     1201        """
     1202        Update the given child row attributes, as well as the "claim" record
     1203        which ties it to the parent, as well as any credit(s) which may apply.
     1204
     1205        Ideally the child row can accommodate the "full" case/unit amounts
     1206        given, but if not then it must do as much as it can.  Note that the
     1207        child row should have been located via :meth:`receiving_find_best_child_row()`
     1208        and therefore should be able to accommodate *something* at least.
     1209
     1210        This method returns a 2-tuple of ``(cases, units)`` which reflect the
     1211        amounts it was *not* able to claim (or relinquish, if incoming amounts
     1212        are negative).  In other words these are the "leftovers" which still
     1213        need to be dealt with somehow.
     1214        """
     1215        # were we given positive or negative values for the update?
     1216        if (cases and cases > 0) or (units and units > 0):
     1217            positive = True
     1218        else:
     1219            positive = False
     1220
     1221        ##############################
     1222
     1223        def update(cases, units):
     1224
     1225            # update child claim
     1226            claim = get_claim()
     1227            if cases:
     1228                setattr(claim, 'cases_{}'.format(mode),
     1229                        (getattr(claim, 'cases_{}'.format(mode)) or 0) + cases)
     1230            if units:
     1231                setattr(claim, 'units_{}'.format(mode),
     1232                        (getattr(claim, 'units_{}'.format(mode)) or 0) + units)
     1233            # remove claim if now empty (should only happen if negative values?)
     1234            if claim.is_empty():
     1235                parent_row.claims.remove(claim)
     1236
     1237            # update child row
     1238            self.receiving_update_row_attrs(child_row, mode, cases, units)
     1239            if cases:
     1240                child_row.cases_ordered_claimed += cases
     1241                child_row.cases_ordered_pending -= cases
     1242            if units:
     1243                child_row.units_ordered_claimed += units
     1244                child_row.units_ordered_pending -= units
     1245
     1246            # update child credit, if applicable
     1247            self.receiving_update_row_credits(child_row, mode, cases, units, **kwargs)
     1248
     1249        def get_claim():
     1250            claims = [claim for claim in parent_row.claims
     1251                      if claim.claiming_row is child_row]
     1252            if claims:
     1253                if len(claims) > 1:
     1254                    raise ValueError("child row has too many claims on parent!")
     1255                return claims[0]
     1256            claim = model.PurchaseBatchRowClaim()
     1257            claim.claiming_row = child_row
     1258            parent_row.claims.append(claim)
     1259            return claim
     1260
     1261        ##############################
     1262
     1263        # first we try to accommodate the full "as-is" amounts, if possible
     1264        if positive:
     1265            if cases and units:
     1266                if child_row.cases_ordered_pending >= cases and child_row.units_ordered_pending >= units:
     1267                    update(cases, units)
     1268                    return 0, 0
     1269            elif cases:
     1270                if child_row.cases_ordered_pending >= cases:
     1271                    update(cases, 0)
     1272                    return 0, 0
     1273            else: # units
     1274                if child_row.units_ordered_pending >= units:
     1275                    update(0, units)
     1276                    return 0, 0
     1277        else: # negative
     1278            if cases and units:
     1279                if child_row.cases_ordered_claimed >= -cases and child_row.units_ordered_claimed >= -units:
     1280                    update(cases, units)
     1281                    return 0, 0
     1282            elif cases:
     1283                if child_row.cases_ordered_claimed >= -cases:
     1284                    update(cases, 0)
     1285                    return 0, 0
     1286            else: # units
     1287                if child_row.units_ordered_claimed >= -units:
     1288                    update(0, units)
     1289                    return 0, 0
     1290
     1291        # next we try a couple more variations on that theme, aiming for "as
     1292        # much as possible, as simply as possible"
     1293        if cases and units:
     1294            if positive:
     1295                if child_row.cases_ordered_pending >= cases:
     1296                    update(cases, 0)
     1297                    return 0, units
     1298                if child_row.units_ordered_pending >= units:
     1299                    update(0, units)
     1300                    return cases, 0
     1301            else: # negative
     1302                if child_row.cases_ordered_claimed >= -cases:
     1303                    update(cases, 0)
     1304                    return 0, units
     1305                if child_row.units_ordered_claimed >= -units:
     1306                    update(0, units)
     1307                    return cases, 0
     1308
     1309        # okay then, try to (simply) use up any "child" quantities
     1310        if positive:
     1311            if cases and units and (child_row.cases_ordered_pending
     1312                                    and child_row.units_ordered_pending):
     1313                update(child_row.cases_ordered_pending, child_row.units_ordered_pending)
     1314                return (cases - child_row.cases_ordered_pending,
     1315                        units - child_row.units_ordered_pending)
     1316            if cases and child_row.cases_ordered_pending:
     1317                update(child_row.cases_ordered_pending, 0)
     1318                return cases - child_row.cases_ordered_pending, 0
     1319            if units and child_row.units_ordered_pending:
     1320                update(0, child_row.units_ordered_pending)
     1321                return 0, units - child_row.units_ordered_pending
     1322        else: # negative
     1323            if cases and units and (child_row.cases_ordered_claimed
     1324                                    and child_row.units_ordered_claimed):
     1325                update(-child_row.cases_ordered_claimed, -child_row.units_ordered_claimed)
     1326                return (cases + child_row.cases_ordered_claimed,
     1327                        units + child_row.units_ordered_claimed)
     1328            if cases and child_row.cases_ordered_claimed:
     1329                update(-child_row.cases_ordered_claimed, 0)
     1330                return cases + child_row.cases_ordered_claimed, 0
     1331            if units and child_row.units_ordered_claimed:
     1332                update(0, -child_row.units_ordered_claimed)
     1333                return 0, units + child_row.units_ordered_claimed
     1334
     1335        # looks like we're gonna have to split some cases, one way or another
     1336        if parent_row.case_quantity != child_row.case_quantity:
     1337            raise NotImplementedError("cannot split case when parent/child disagree about size")
     1338        if positive:
     1339            if cases and child_row.units_ordered_pending:
     1340                if child_row.units_ordered_pending >= parent_row.case_quantity:
     1341                    unit_cases = child_row.units_ordered_pending // parent_row.case_quantity
     1342                    if unit_cases >= cases:
     1343                        update(0, cases * parent_row.case_quantity)
     1344                        return 0, units
     1345                    else: # unit_cases < cases
     1346                        update(0, unit_cases * parent_row.case_quantity)
     1347                        return cases - unit_cases, units
     1348                else: # units_pending < case_size
     1349                    update(0, child_row.units_ordered_pending)
     1350                    return (cases - 1,
     1351                            (units or 0) + parent_row.case_quantity - child_row.units_ordered_pending)
     1352            if units and child_row.cases_ordered_pending:
     1353                if units >= parent_row.case_quantity:
     1354                    unit_cases = units // parent_row.case_quantity
     1355                    if unit_cases <= child_row.cases_ordered_pending:
     1356                        update(unit_cases, 0)
     1357                        return 0, units - (unit_cases * parent_row.case_quantity)
     1358                    else: # unit_cases > cases_pending
     1359                        update(child_row.cases_ordered_pending, 0)
     1360                        return 0, units - (child_row.cases_ordered_pending * parent_row.case_quantity)
     1361                else: # units < case_size
     1362                    update(0, units)
     1363                    return 0, 0
     1364        else: # negative
     1365            if cases and child_row.units_ordered_claimed:
     1366                if child_row.units_ordered_claimed >= parent_row.case_quantity:
     1367                    unit_cases = child_row.units_ordered_claimed // parent_row.case_quantity
     1368                    if unit_cases >= -cases:
     1369                        update(0, cases * parent_row.case_quantity)
     1370                        return 0, units
     1371                    else: # unit_cases < -cases
     1372                        update(0, -unit_cases * parent_row.case_quantity)
     1373                        return cases + unit_cases, units
     1374                else: # units_claimed < case_size
     1375                    update(0, -child_row.units_ordered_claimed)
     1376                    return (cases + 1,
     1377                            (units or 0) - parent_row.case_quantity + child_row.units_ordered_claimed)
     1378            if units and child_row.cases_ordered_claimed:
     1379                if -units >= parent_row.case_quantity:
     1380                    unit_cases = -units // parent_row.case_quantity
     1381                    if unit_cases <= child_row.cases_ordered_claimed:
     1382                        update(-unit_cases, 0)
     1383                        return 0, units + (unit_cases * parent_row.case_quantity)
     1384                    else: # unit_cases > cases_claimed
     1385                        update(-child_row.cases_ordered_claimed, 0)
     1386                        return 0, units + (child_row.cases_ordered_claimed * parent_row.case_quantity)
     1387                else: # -units < case_size
     1388                    update(0, units)
     1389                    return 0, 0
     1390
     1391        # TODO: this should theoretically never happen; should log/raise error?
     1392        log.warning("unable to claim/relinquish any case/unit amounts for child row: %s", child_row)
     1393        return cases, units
     1394
     1395    def receiving_find_best_child_row(self, row, mode, cases, units):
     1396        """
     1397        Locate and return the "best match" child row, for the given parent row
     1398        and receiving update details.  The idea here is that the parent row
     1399        will represent the "receiving" side of things, whereas the child row
     1400        will be the "ordering" side.
     1401
     1402        For instance if the update is for say, "received 2 CS" and there are
     1403        two child rows, one of which is for 1 CS and the other 2 CS, the latter
     1404        will be returned.  This logic is capable of "splitting" a case where
     1405        necessary, in order to find a partial match etc.
     1406        """
     1407        parent_row = row
     1408        parent_batch = parent_row.batch
     1409
     1410        if not parent_row.product:
     1411            raise NotImplementedError("not sure how to find best match for unknown product")
     1412
     1413        if not (cases or units):
     1414            raise ValueError("must provide amount for cases and/or units")
     1415
     1416        if cases and units and (
     1417                (cases > 0 and units < 0) or (cases < 0 and units > 0)):
     1418            raise NotImplementedError("not sure how to handle mixed pos/neg for case/unit amounts")
     1419
     1420        # were we given positive or negative values for the update?
     1421        if (cases and cases > 0) or (units and units > 0):
     1422            positive = True
     1423        else:
     1424            positive = False
     1425
     1426        # first we collect all potential child rows
     1427        all_child_rows = []
     1428        for child_batch in parent_batch.truck_dump_children:
     1429            child_rows = [child_row for child_row in child_batch.active_rows()
     1430                          if child_row.product_uuid == parent_row.product.uuid]
     1431            for child_row in child_rows:
     1432
     1433                # for each child row we also calculate "claimed" vs. "pending" amounts
     1434
     1435                # cases_ordered
     1436                child_row.cases_ordered_claimed = sum([(claim.cases_received or 0)
     1437                                                       + (claim.cases_damaged or 0)
     1438                                                       + (claim.cases_expired or 0)
     1439                                                       for claim in child_row.truck_dump_claims])
     1440                child_row.cases_ordered_pending = (child_row.cases_ordered or 0) - child_row.cases_ordered_claimed
     1441
     1442                # units_ordered
     1443                child_row.units_ordered_claimed = sum([(claim.units_received or 0)
     1444                                                       + (claim.units_damaged or 0)
     1445                                                       + (claim.units_expired or 0)
     1446                                                       for claim in child_row.truck_dump_claims])
     1447                child_row.units_ordered_pending = (child_row.units_ordered or 0) - child_row.units_ordered_claimed
     1448
     1449                all_child_rows.append(child_row)
     1450
     1451        def sortkey(row):
     1452            if positive:
     1453                return self.get_units(row.cases_ordered_pending,
     1454                                      row.units_ordered_pending,
     1455                                      row.case_quantity)
     1456            else: # negative
     1457                return self.get_units(row.cases_ordered_claimed,
     1458                                      row.units_ordered_claimed,
     1459                                      row.case_quantity)
     1460
     1461        # sort child rows such that smallest (relevant) quantities come first;
     1462        # idea being we would prefer the "least common denominator" to match
     1463        all_child_rows.sort(key=sortkey)
     1464
     1465        # first try to find an exact match
     1466        for child_row in all_child_rows:
     1467            if cases and units:
     1468                if positive:
     1469                    if child_row.cases_ordered_pending == cases and child_row.units_ordered_pending == units:
     1470                        return child_row
     1471                else: # negative
     1472                    if child_row.cases_ordered_claimed == cases and child_row.units_ordered_claimed == units:
     1473                        return child_row
     1474            elif cases:
     1475                if positive:
     1476                    if child_row.cases_ordered_pending == cases:
     1477                        return child_row
     1478                else: # negative
     1479                    if child_row.cases_ordered_claimed == cases:
     1480                        return child_row
     1481            else: # units
     1482                if positive:
     1483                    if child_row.units_ordered_pending == units:
     1484                        return child_row
     1485                else: # negative
     1486                    if child_row.units_ordered_claimed == units:
     1487                        return child_row
     1488
     1489        # next we try to find the "first" (smallest) match which satisfies, but
     1490        # which does so *without* having to split up any cases
     1491        for child_row in all_child_rows:
     1492            if cases and units:
     1493                if positive:
     1494                    if child_row.cases_ordered_pending >= cases and child_row.units_ordered_pending >= units:
     1495                        return child_row
     1496                else: # negative
     1497                    if child_row.cases_ordered_claimed >= -cases and child_row.units_ordered_claimed >= -units:
     1498                        return child_row
     1499            elif cases:
     1500                if positive:
     1501                    if child_row.cases_ordered_pending >= cases:
     1502                        return child_row
     1503                else: # negative
     1504                    if child_row.cases_ordered_claimed >= -cases:
     1505                        return child_row
     1506            else: # units
     1507                if positive:
     1508                    if child_row.units_ordered_pending >= units:
     1509                        return child_row
     1510                else: # negative
     1511                    if child_row.units_ordered_claimed >= -units:
     1512                        return child_row
     1513
     1514        # okay, we're getting desperate now; let's start splitting cases and
     1515        # may the first possible match (which fully satisfies) win...
     1516        incoming_units = self.get_units(cases, units, parent_row.case_quantity)
     1517        for child_row in all_child_rows:
     1518            if positive:
     1519                pending_units = self.get_units(child_row.cases_ordered_pending,
     1520                                               child_row.units_ordered_pending,
     1521                                               child_row.case_quantity)
     1522                if pending_units >= incoming_units:
     1523                    return child_row
     1524            else: # negative
     1525                claimed_units = self.get_units(child_row.cases_ordered_claimed,
     1526                                               child_row.units_ordered_claimed,
     1527                                               child_row.case_quantity)
     1528                if claimed_units >= -incoming_units:
     1529                    return child_row
     1530
     1531        # and now we're even more desperate.  at this point no child row can
     1532        # fully (by itself) accommodate the update at hand, which means we must
     1533        # look for the first child which can accommodate anything at all, and
     1534        # settle for the partial match.  note that we traverse the child row
     1535        # list *backwards* here, hoping for the "biggest" match
     1536        for child_row in reversed(all_child_rows):
     1537            if positive:
     1538                if child_row.cases_ordered_pending or child_row.units_ordered_pending:
     1539                    return child_row
     1540            else: # negative
     1541                if child_row.cases_ordered_claimed or child_row.units_ordered_claimed:
     1542                    return child_row
    11631543
    11641544    def remove_row(self, row):
Note: See TracChangeset for help on using the changeset viewer.