This commit is contained in:
2026-05-17 15:27:55 +02:00
parent ba79f88b43
commit 9a6bb83c19
4 changed files with 165 additions and 31 deletions

View File

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

View File

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

View File

@@ -7,5 +7,9 @@
<field name="lot_cp"/>
<field name="lot_product"/>
<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"/>
</tree>

View File

@@ -7,5 +7,9 @@
<field name="lot_cp"/>
<field name="lot_product"/>
<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"/>
</tree>