diff --git a/modules/purchase_trade/lot.py b/modules/purchase_trade/lot.py index 3b867dd..d07a150 100755 --- a/modules/purchase_trade/lot.py +++ b/modules/purchase_trade/lot.py @@ -1217,13 +1217,85 @@ class LotQt( return (l.lot_type == 'virtual') return False - def isVirtualS(self): - if self.lot_s: - Lot = Pool().get('lot.lot') - l = Lot(self.lot_s) - return (l.lot_type == 'virtual') - return False - + def isVirtualS(self): + if self.lot_s: + Lot = Pool().get('lot.lot') + l = Lot(self.lot_s) + return (l.lot_type == 'virtual') + return False + + @staticmethod + def _matching_line(lqt, side): + if side == 'purchase': + lot = getattr(lqt, 'lot_p', None) + return getattr(lot, 'line', None) + lot = getattr(lqt, 'lot_s', None) + return getattr(lot, 'sale_line', None) + + @classmethod + def _matching_line_tolerance_percent(cls, line, field): + contract = getattr(line, 'purchase', None) or getattr(line, 'sale', None) + if getattr(line, 'inherit_tol', False) and contract: + return Decimal(str(getattr(contract, field, None) or 0)) + return Decimal(str(getattr(line, field, None) or 0)) + + @classmethod + def _matching_line_tolerance_info(cls, line): + if not line: + return {} + theoretical = Decimal(str( + getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None) + or 0)) + tol_min = cls._matching_line_tolerance_percent(line, 'tol_min') + tol_max = cls._matching_line_tolerance_percent(line, 'tol_max') + return { + 'lot_tol_min': tol_min, + 'lot_tol_max': tol_max, + 'lot_qt_min': round( + theoretical * (Decimal(1) - tol_min / Decimal(100)), 5), + 'lot_qt_max': round( + theoretical * (Decimal(1) + tol_max / Decimal(100)), 5), + } + + @classmethod + def _matching_quantity_in_line_unit(cls, lqt, line): + quantity = abs(Decimal(str(getattr(lqt, 'lot_quantity', 0) or 0))) + lot_unit = getattr(lqt, 'lot_unit', None) + line_unit = getattr(line, 'unit', None) + if not lot_unit or not line_unit or lot_unit == line_unit: + return quantity + Uom = Pool().get('product.uom') + factor = None + rate = None + if lot_unit.category.id != line_unit.category.id: + factor = 1 + rate = 1 + return Decimal(str(Uom.compute_qty( + lot_unit, float(quantity), line_unit, True, factor, rate))) + + @classmethod + def _matching_tolerated_available(cls, lqt, side): + available = abs(Decimal(str(getattr(lqt, 'lot_quantity', 0) or 0))) + line = cls._matching_line(lqt, side) + if not line: + return available + info = cls._matching_line_tolerance_info(line) + qt_max = info.get('lot_qt_max') + if qt_max is None: + return available + domain = [('lot_s', '>', 0)] + if side == 'purchase': + domain.append(('lot_p', '=', getattr(lqt.lot_p, 'id', lqt.lot_p))) + else: + domain = [('lot_p', '>', 0), + ('lot_s', '=', getattr(lqt.lot_s, 'id', lqt.lot_s))] + matched = sum( + cls._matching_quantity_in_line_unit(matched_lqt, line) + for matched_lqt in cls.search(domain)) + tolerated_available = Decimal(str(qt_max)) - matched + return max(available, tolerated_available, Decimal(0)) + @classmethod def match_lots(cls,lot_p,lot_s): logger.info("MATCH_LOTS:%s",lot_p) @@ -1235,19 +1307,21 @@ class LotQt( if qt_p <= 0: continue lqt = LotQt(lp.lot_r_id) - available = Decimal(str(lqt.lot_quantity or 0)) + available = cls._matching_tolerated_available(lqt, 'purchase') if qt_p > available: raise UserError( - "Quantity to match exceeds the available purchase quantity.") + "Quantity to match exceeds the available purchase " + "quantity with tolerance.") for ls in lot_s: qt_s = Decimal(str(ls.lot_matched_qt or 0)) if qt_s <= 0: continue lqt = LotQt(ls.lot_r_id) - available = abs(Decimal(str(lqt.lot_quantity or 0))) + available = cls._matching_tolerated_available(lqt, 'sale') if qt_s > available: raise UserError( - "Quantity to match exceeds the available sale quantity.") + "Quantity to match exceeds the available sale quantity " + "with tolerance.") for lp in lot_p: qt_p = lp.lot_matched_qt if qt_p == 0: @@ -2821,7 +2895,7 @@ class LotMatching(Wizard): quantity = Decimal(str(lqt.lot_quantity or 0)) if side == 'sale': quantity = abs(quantity) - return { + values = { 'lot_id': cls._record_id(lot), 'lot_r_id': cls._record_id(lqt), 'lot_purchase': cls._record_id(purchase), @@ -2836,6 +2910,8 @@ class LotMatching(Wizard): 'lot_matched_qt': 0, 'lot_cp': cls._record_id(party), } + values.update(LotQt._matching_line_tolerance_info(line)) + return values @classmethod def _selected_matching_defaults(cls, active_ids): @@ -2996,15 +3072,18 @@ class LotMatchingStart(ModelView): val['lot_shipment_out'] = r['r_lot_shipment_out'] val['lot_type'] = r['r_lot_type'] val['lot_product'] = r['r_lot_product'] - val['lot_quantity'] = -r['r_lot_quantity'] - if r['r_sale'] and r['r_purchase']: - val['lot_quantity'] = r['r_lot_matched'] - val['lot_matched_qt'] = 0 - val['lot_cp'] = r['r_client'] - lp.append(val) - return lp - - @fields.depends('cp_p','qt_type','purchase','product_p') + val['lot_quantity'] = -r['r_lot_quantity'] + if r['r_sale'] and r['r_purchase']: + val['lot_quantity'] = r['r_lot_matched'] + val['lot_matched_qt'] = 0 + val['lot_cp'] = r['r_client'] + lqt = LotQt(r['id'] - 10000000) + val.update(LotQt._matching_line_tolerance_info( + LotQt._matching_line(lqt, 'sale'))) + lp.append(val) + return lp + + @fields.depends('cp_p','qt_type','purchase','product_p') def on_change_with_lot_p(self, name=None): lp = [] LotQt = Pool().get('lot.qt') @@ -3022,12 +3101,15 @@ class LotMatchingStart(ModelView): val['lot_shipment_internal'] = r['r_lot_shipment_internal'] val['lot_shipment_out'] = r['r_lot_shipment_out'] val['lot_type'] = r['r_lot_type'] - val['lot_product'] = r['r_lot_product'] - val['lot_quantity'] = r['r_lot_quantity'] - val['lot_matched_qt'] = 0 - val['lot_cp'] = r['r_supplier'] - lp.append(val) - return lp + val['lot_product'] = r['r_lot_product'] + val['lot_quantity'] = r['r_lot_quantity'] + val['lot_matched_qt'] = 0 + val['lot_cp'] = r['r_supplier'] + lqt = LotQt(r['id'] - 10000000) + val.update(LotQt._matching_line_tolerance_info( + LotQt._matching_line(lqt, 'purchase'))) + lp.append(val) + return lp class LotMatchingLot(ModelView): "Lots" @@ -3047,10 +3129,14 @@ class LotMatchingLot(ModelView): lot_shipment_in = fields.Many2One('stock.shipment.in',"Shipment In") lot_shipment_internal = fields.Many2One('stock.shipment.internal',"Shipment Internal") lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out") - lot_product = fields.Many2One('product.product',"Product",readonly=True) - lot_quantity = fields.Numeric("Qt",readonly=True) - lot_unit = fields.Many2One('product.uom',"Unit",readonly=True) - lot_matched_qt = fields.Numeric("Qt to match") + lot_product = fields.Many2One('product.product',"Product",readonly=True) + lot_quantity = fields.Numeric("Qt",readonly=True) + lot_qt_min = fields.Numeric("Qt min", readonly=True) + lot_qt_max = fields.Numeric("Qt max", readonly=True) + lot_tol_min = fields.Numeric("Tol -", readonly=True) + lot_tol_max = fields.Numeric("Tol +", readonly=True) + lot_unit = fields.Many2One('product.uom',"Unit",readonly=True) + lot_matched_qt = fields.Numeric("Qt to match") lot_cp = fields.Many2One('party.party',"Counterparty") lot_shipment_origin = fields.Function( fields.Reference( diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index dd6da10..f2a9b1f 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -2722,6 +2722,46 @@ class PurchaseTradeTestCase(ModuleTestCase): with self.assertRaises(UserError): lot_module.LotQt.match_lots([purchase_lot], []) + def test_lot_matching_available_includes_tolerance_remaining(self): + 'matching can use remaining purchase tolerance above open quantity' + purchase = Mock(tol_max=Decimal('10')) + line = Mock( + purchase=purchase, inherit_tol=True, + quantity_theorical=Decimal('100'), unit=None) + vlot = Mock(id=10, line=line) + lqt = Mock(lot_p=vlot, lot_quantity=Decimal('60')) + matched_lqt = Mock(lot_quantity=Decimal('40'), lot_unit=None) + + with patch.object( + lot_module.LotQt, 'search', return_value=[matched_lqt]): + self.assertEqual( + lot_module.LotQt._matching_tolerated_available( + lqt, 'purchase'), + Decimal('70.00000')) + + def test_lot_matching_accepts_purchase_quantity_within_tolerance(self): + 'apply matching can consume purchase quantity up to tolerance max' + purchase = Mock(tol_max=Decimal('10')) + line = Mock( + purchase=purchase, inherit_tol=True, + quantity_theorical=Decimal('100'), unit=None) + vlot = Mock(id=10, line=line) + lqt = Mock(lot_p=vlot, lot_s=None, lot_quantity=Decimal('100')) + + class LotQtMock: + def __call__(self, _id): + return lqt + + pool = Mock() + pool.get.side_effect = ( + lambda name: LotQtMock() if name == 'lot.qt' else Mock()) + purchase_lot = Mock( + lot_matched_qt=Decimal('105'), lot_r_id=1) + + with patch.object(lot_module, 'Pool', return_value=pool), \ + patch.object(lot_module.LotQt, 'search', return_value=[]): + lot_module.LotQt.match_lots([purchase_lot], []) + def test_go_matching_defaults_selected_open_lot_qts(self): 'go to matching only preloads selected unmatched open lot.qt rows' purchase = Mock(id=10, party=Mock(id=20)) diff --git a/modules/purchase_trade/view/lot_matching_lot_tree.xml b/modules/purchase_trade/view/lot_matching_lot_tree.xml index dbfd420..c8d8e4e 100755 --- a/modules/purchase_trade/view/lot_matching_lot_tree.xml +++ b/modules/purchase_trade/view/lot_matching_lot_tree.xml @@ -7,5 +7,9 @@ + + + + diff --git a/modules/purchase_trade/view/lot_matching_lot_tree2.xml b/modules/purchase_trade/view/lot_matching_lot_tree2.xml index 3d4fe92..e48baf3 100755 --- a/modules/purchase_trade/view/lot_matching_lot_tree2.xml +++ b/modules/purchase_trade/view/lot_matching_lot_tree2.xml @@ -7,5 +7,9 @@ + + + +