Matching
This commit is contained in:
@@ -1224,6 +1224,78 @@ class LotQt(
|
|||||||
return (l.lot_type == 'virtual')
|
return (l.lot_type == 'virtual')
|
||||||
return False
|
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
|
@classmethod
|
||||||
def match_lots(cls,lot_p,lot_s):
|
def match_lots(cls,lot_p,lot_s):
|
||||||
logger.info("MATCH_LOTS:%s",lot_p)
|
logger.info("MATCH_LOTS:%s",lot_p)
|
||||||
@@ -1235,19 +1307,21 @@ class LotQt(
|
|||||||
if qt_p <= 0:
|
if qt_p <= 0:
|
||||||
continue
|
continue
|
||||||
lqt = LotQt(lp.lot_r_id)
|
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:
|
if qt_p > available:
|
||||||
raise UserError(
|
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:
|
for ls in lot_s:
|
||||||
qt_s = Decimal(str(ls.lot_matched_qt or 0))
|
qt_s = Decimal(str(ls.lot_matched_qt or 0))
|
||||||
if qt_s <= 0:
|
if qt_s <= 0:
|
||||||
continue
|
continue
|
||||||
lqt = LotQt(ls.lot_r_id)
|
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:
|
if qt_s > available:
|
||||||
raise UserError(
|
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:
|
for lp in lot_p:
|
||||||
qt_p = lp.lot_matched_qt
|
qt_p = lp.lot_matched_qt
|
||||||
if qt_p == 0:
|
if qt_p == 0:
|
||||||
@@ -2821,7 +2895,7 @@ class LotMatching(Wizard):
|
|||||||
quantity = Decimal(str(lqt.lot_quantity or 0))
|
quantity = Decimal(str(lqt.lot_quantity or 0))
|
||||||
if side == 'sale':
|
if side == 'sale':
|
||||||
quantity = abs(quantity)
|
quantity = abs(quantity)
|
||||||
return {
|
values = {
|
||||||
'lot_id': cls._record_id(lot),
|
'lot_id': cls._record_id(lot),
|
||||||
'lot_r_id': cls._record_id(lqt),
|
'lot_r_id': cls._record_id(lqt),
|
||||||
'lot_purchase': cls._record_id(purchase),
|
'lot_purchase': cls._record_id(purchase),
|
||||||
@@ -2836,6 +2910,8 @@ class LotMatching(Wizard):
|
|||||||
'lot_matched_qt': 0,
|
'lot_matched_qt': 0,
|
||||||
'lot_cp': cls._record_id(party),
|
'lot_cp': cls._record_id(party),
|
||||||
}
|
}
|
||||||
|
values.update(LotQt._matching_line_tolerance_info(line))
|
||||||
|
return values
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _selected_matching_defaults(cls, active_ids):
|
def _selected_matching_defaults(cls, active_ids):
|
||||||
@@ -3001,6 +3077,9 @@ class LotMatchingStart(ModelView):
|
|||||||
val['lot_quantity'] = r['r_lot_matched']
|
val['lot_quantity'] = r['r_lot_matched']
|
||||||
val['lot_matched_qt'] = 0
|
val['lot_matched_qt'] = 0
|
||||||
val['lot_cp'] = r['r_client']
|
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)
|
lp.append(val)
|
||||||
return lp
|
return lp
|
||||||
|
|
||||||
@@ -3026,6 +3105,9 @@ class LotMatchingStart(ModelView):
|
|||||||
val['lot_quantity'] = r['r_lot_quantity']
|
val['lot_quantity'] = r['r_lot_quantity']
|
||||||
val['lot_matched_qt'] = 0
|
val['lot_matched_qt'] = 0
|
||||||
val['lot_cp'] = r['r_supplier']
|
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)
|
lp.append(val)
|
||||||
return lp
|
return lp
|
||||||
|
|
||||||
@@ -3049,6 +3131,10 @@ class LotMatchingLot(ModelView):
|
|||||||
lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out")
|
lot_shipment_out = fields.Many2One('stock.shipment.out',"Shipment Out")
|
||||||
lot_product = fields.Many2One('product.product',"Product",readonly=True)
|
lot_product = fields.Many2One('product.product',"Product",readonly=True)
|
||||||
lot_quantity = fields.Numeric("Qt",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_unit = fields.Many2One('product.uom',"Unit",readonly=True)
|
||||||
lot_matched_qt = fields.Numeric("Qt to match")
|
lot_matched_qt = fields.Numeric("Qt to match")
|
||||||
lot_cp = fields.Many2One('party.party',"Counterparty")
|
lot_cp = fields.Many2One('party.party',"Counterparty")
|
||||||
|
|||||||
@@ -2722,6 +2722,46 @@ class PurchaseTradeTestCase(ModuleTestCase):
|
|||||||
with self.assertRaises(UserError):
|
with self.assertRaises(UserError):
|
||||||
lot_module.LotQt.match_lots([purchase_lot], [])
|
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):
|
def test_go_matching_defaults_selected_open_lot_qts(self):
|
||||||
'go to matching only preloads selected unmatched open lot.qt rows'
|
'go to matching only preloads selected unmatched open lot.qt rows'
|
||||||
purchase = Mock(id=10, party=Mock(id=20))
|
purchase = Mock(id=10, party=Mock(id=20))
|
||||||
|
|||||||
@@ -7,5 +7,9 @@
|
|||||||
<field name="lot_cp"/>
|
<field name="lot_cp"/>
|
||||||
<field name="lot_product"/>
|
<field name="lot_product"/>
|
||||||
<field name="lot_quantity"/>
|
<field name="lot_quantity"/>
|
||||||
|
<field name="lot_qt_min"/>
|
||||||
|
<field name="lot_qt_max"/>
|
||||||
|
<field name="lot_tol_min"/>
|
||||||
|
<field name="lot_tol_max"/>
|
||||||
<field name="lot_matched_qt"/>
|
<field name="lot_matched_qt"/>
|
||||||
</tree>
|
</tree>
|
||||||
|
|||||||
@@ -7,5 +7,9 @@
|
|||||||
<field name="lot_cp"/>
|
<field name="lot_cp"/>
|
||||||
<field name="lot_product"/>
|
<field name="lot_product"/>
|
||||||
<field name="lot_quantity"/>
|
<field name="lot_quantity"/>
|
||||||
|
<field name="lot_qt_min"/>
|
||||||
|
<field name="lot_qt_max"/>
|
||||||
|
<field name="lot_tol_min"/>
|
||||||
|
<field name="lot_tol_max"/>
|
||||||
<field name="lot_matched_qt"/>
|
<field name="lot_matched_qt"/>
|
||||||
</tree>
|
</tree>
|
||||||
|
|||||||
Reference in New Issue
Block a user