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 @@
+
+
+
+