This commit is contained in:
2026-04-01 14:09:42 +02:00
parent 5054b64cd0
commit c687828ba5
2 changed files with 373 additions and 2 deletions

View File

@@ -92,6 +92,18 @@ class PurchaseTradeTestCase(ModuleTestCase):
valuation_module.Valuation._get_strategy_mtm_price(strategy, line),
Decimal('100.0000'))
def test_sale_line_is_unmatched_checks_lot_links(self):
'sale line unmatched helper ignores empty matches and detects linked purchases'
sale_line = Mock()
sale_line.get_matched_lines.return_value = []
self.assertTrue(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
linked = Mock(lot_p=Mock(line=Mock()))
sale_line.get_matched_lines.return_value = [linked]
self.assertFalse(
valuation_module.ValuationProcess._sale_line_is_unmatched(sale_line))
def test_parse_numbers_supports_inline_and_legacy_separators(self):
'parse_numbers keeps supporting inline entry and legacy separators'
self.assertEqual(

View File

@@ -107,7 +107,31 @@ class ValuationBase(ModelSQL):
ValuationLine.delete(valuation_lines)
@classmethod
def _base_pnl(cls, *, line, lot, pnl_type, sale=None):
def _delete_existing_sale_line(cls, sale_line, selected_types=None):
Date = Pool().get('ir.date')
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
valuation_domain = [
('sale_line', '=', sale_line.id),
('date', '=', Date.today()),
]
valuation_line_domain = [('sale_line', '=', sale_line.id)]
if selected_types is not None:
valuation_domain.append(('type', 'in', list(selected_types)))
valuation_line_domain.append(('type', 'in', list(selected_types)))
valuations = Valuation.search(valuation_domain)
if valuations:
Valuation.delete(valuations)
valuation_lines = ValuationLine.search(valuation_line_domain)
if valuation_lines:
ValuationLine.delete(valuation_lines)
@classmethod
def _base_pnl(cls, *, line, lot, pnl_type, sale=None, sale_line=None):
Date = Pool().get('ir.date')
values = {
@@ -120,9 +144,22 @@ class ValuationBase(ModelSQL):
if sale:
values['sale'] = sale.id
if sale_line:
values['sale_line'] = sale_line.id
return values
@classmethod
def _base_sale_pnl(cls, *, sale_line, lot, pnl_type):
Date = Pool().get('ir.date')
return {
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'type': pnl_type,
'date': Date.today(),
'lot': lot.id,
}
@classmethod
def _get_strategy_mtm_price(cls, strategy, line):
total = Decimal(0)
@@ -156,6 +193,7 @@ class ValuationBase(ModelSQL):
line=line,
lot=lot,
sale=sale_line.sale if sale_line else None,
sale_line=sale_line if sale_line else None,
pnl_type='sale priced' if sale_line else 'pur. priced'
)
@@ -217,6 +255,7 @@ class ValuationBase(ModelSQL):
line=line,
lot=lot,
sale=sale_line.sale if sale_line else None,
sale_line=sale_line if sale_line else None,
pnl_type=pnl_type
)
@@ -350,6 +389,139 @@ class ValuationBase(ModelSQL):
return price_lines
@classmethod
def _build_basis_pnl_from_sale_line(cls, *, sale_line, lot, pc):
Currency = Pool().get('currency.currency')
Date = Pool().get('ir.date')
values = cls._base_sale_pnl(
sale_line=sale_line,
lot=lot,
pnl_type='sale priced'
)
qty = lot.get_current_quantity_converted()
price = pc.price
values.update({
'reference': f"{pc.get_name()} / {pc.ratio}%",
'price': round(price, 4),
'counterparty': sale_line.sale.party.id,
'product': sale_line.product.id,
})
if pc.unfixed_qt == 0:
values['state'] = 'fixed'
elif pc.fixed_qt == 0:
values['state'] = 'unfixed'
else:
base = sale_line.quantity_theorical
values['state'] = f"part. fixed {round(pc.fixed_qt / Decimal(base) * 100, 0)}%"
if price is not None:
amount = round(price * qty, 2)
base_amount = amount
currency = sale_line.sale.currency.id
rate = Decimal(1)
if sale_line.sale.company.currency != currency:
with Transaction().set_context(date=Date.today()):
base_amount = Currency.compute(
currency, amount, sale_line.sale.company.currency)
rate = round(amount / (base_amount if base_amount else 1), 6)
values.update({
'quantity': round(qty, 5),
'amount': amount,
'base_amount': base_amount,
'rate': rate,
'mtm_price': None,
'mtm': None,
'unit': sale_line.unit.id,
'currency': currency,
})
return values
@classmethod
def _build_simple_pnl_from_sale_line(cls, *, sale_line, lot, price, state, pnl_type):
Currency = Pool().get('currency.currency')
Date = Pool().get('ir.date')
values = cls._base_sale_pnl(
sale_line=sale_line,
lot=lot,
pnl_type=pnl_type
)
qty = lot.get_current_quantity_converted()
amount = round(price * qty, 2)
base_amount = amount
currency = sale_line.sale.currency.id
company_currency = sale_line.sale.company.currency
rate = Decimal(1)
if sale_line.sale.company.currency != currency:
with Transaction().set_context(date=Date.today()):
base_amount = Currency.compute(currency, amount, company_currency)
if base_amount and amount:
rate = round(amount / base_amount, 6)
values.update({
'price': round(price, 4),
'quantity': round(qty, 5),
'amount': amount,
'base_amount': base_amount,
'rate': rate,
'mtm_price': None,
'mtm': Decimal(0),
'state': state,
'unit': sale_line.unit.id,
'currency': currency,
'counterparty': sale_line.sale.party.id,
'product': sale_line.product.id,
'reference': 'Sale/Physic' if lot.lot_type == 'physic' else 'Sale/Open',
})
return values
@classmethod
def create_pnl_price_from_sale_line(cls, sale_line):
price_lines = []
for lot in sale_line.lots or []:
if sale_line.price_type == 'basis':
for pc in sale_line.price_summary or []:
values = cls._build_basis_pnl_from_sale_line(
sale_line=sale_line, lot=lot, pc=pc)
if sale_line.mtm:
for strat in sale_line.mtm:
values['mtm_price'] = cls._get_strategy_mtm_price(strat, sale_line)
values['mtm'] = strat.get_mtm(sale_line, values['quantity'])
values['strategy'] = strat
if values:
price_lines.append(values)
else:
if values:
price_lines.append(values)
elif sale_line.price_type in ('priced', 'efp') and lot.lot_price_sale:
values = cls._build_simple_pnl_from_sale_line(
sale_line=sale_line,
lot=lot,
price=lot.lot_price_sale,
state='fixed' if sale_line.price_type == 'priced' else 'not fixed',
pnl_type=f'sale {sale_line.price_type}'
)
if sale_line.mtm:
for strat in sale_line.mtm:
values['mtm_price'] = cls._get_strategy_mtm_price(strat, sale_line)
values['mtm'] = strat.get_mtm(sale_line, values['quantity'])
values['strategy'] = strat
if values:
price_lines.append(values)
else:
if values:
price_lines.append(values)
return price_lines
@classmethod
def group_fees_by_type_supplier(cls,line,fees):
grouped = defaultdict(list)
@@ -454,6 +626,88 @@ class ValuationBase(ModelSQL):
return fee_lines
@classmethod
def create_pnl_fee_from_sale_line(cls, sale_line):
fee_lines = []
Date = Pool().get('ir.date')
Currency = Pool().get('currency.currency')
FeeLots = Pool().get('fee.lots')
for lot in sale_line.lots or ():
fl = FeeLots.search([('lot', '=', lot.id)])
if not fl:
continue
fees = [
e.fee for e in fl
if e.fee and (not e.fee.sale_line or e.fee.sale_line.id == sale_line.id)
]
for sf in cls.group_fees_by_type_supplier(sale_line, fees):
sign = -1 if sf.p_r == 'pay' else 1
qty = round(lot.get_current_quantity_converted(), 5)
if sf.mode == 'ppack' or sf.mode == 'rate':
price = sf.price
amount = sf.amount * sign
elif sf.mode == 'lumpsum':
price = sf.price
amount = sf.price * sign
qty = 1
else:
price = Decimal(sf.get_price_per_qt())
amount = round(price * lot.get_current_quantity_converted() * sign, 2)
if sf.currency != sale_line.sale.currency:
with Transaction().set_context(date=Date.today()):
price = Currency.compute(sf.currency, price, sale_line.sale.currency)
if sale_line.mtm:
for strat in sale_line.mtm:
fee_lines.append({
'lot': lot.id,
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': cls._get_strategy_mtm_price(strat, sale_line),
'mtm': strat.get_mtm(sale_line, qty),
'strategy': strat,
'unit': sf.unit.id if sf.unit else sale_line.unit.id,
'currency': sf.currency.id,
})
else:
fee_lines.append({
'lot': lot.id,
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'type': (
'shipment fee' if sf.shipment_in
else 'sale fee'
),
'date': Date.today(),
'price': price,
'counterparty': sf.supplier.id,
'reference': f"{sf.product.name}/{'Physic' if lot.lot_type == 'physic' else 'Open'}",
'product': sf.product.id,
'state': sf.type,
'quantity': qty,
'amount': amount,
'mtm_price': None,
'mtm': Decimal(0),
'strategy': None,
'unit': sf.unit.id if sf.unit else sale_line.unit.id,
'currency': sf.currency.id,
})
return fee_lines
@classmethod
def create_pnl_der_from_line(cls, line):
Date = Pool().get('ir.date')
@@ -487,6 +741,40 @@ class ValuationBase(ModelSQL):
})
return der_lines
@classmethod
def create_pnl_der_from_sale_line(cls, sale_line):
Date = Pool().get('ir.date')
der_lines = []
for d in sale_line.derivatives or []:
price = Decimal(d.price_index.get_price_per_qt(
d.price, sale_line.unit, sale_line.sale.currency
))
mtm_price = Decimal(d.price_index.get_price(
Date.today(), sale_line.unit, sale_line.sale.currency, True
))
der_lines.append({
'sale': sale_line.sale.id,
'sale_line': sale_line.id,
'type': 'derivative',
'date': Date.today(),
'reference': d.price_index.price_index,
'price': round(price, 4),
'counterparty': d.party.id,
'product': d.product.id,
'state': 'fixed',
'quantity': round(d.quantity, 5),
'amount': round(price * d.quantity * Decimal(-1), 2),
'mtm_price': round(mtm_price, 4),
'mtm': round((price * d.quantity * Decimal(-1)) - (mtm_price * d.quantity * Decimal(-1)), 2),
'unit': sale_line.unit.id,
'currency': sale_line.sale.currency.id,
})
return der_lines
@classmethod
def generate(cls, line, valuation_type='all'):
@@ -504,6 +792,22 @@ class ValuationBase(ModelSQL):
Valuation.create(values)
ValuationLine.create(values)
@classmethod
def generate_from_sale_line(cls, sale_line, valuation_type='all'):
selected_types = cls._get_generate_types(valuation_type)
cls._delete_existing_sale_line(sale_line, selected_types=selected_types)
values = []
values.extend(cls.create_pnl_fee_from_sale_line(sale_line))
values.extend(cls.create_pnl_price_from_sale_line(sale_line))
values.extend(cls.create_pnl_der_from_sale_line(sale_line))
values = cls._filter_values_by_types(values, selected_types)
if values:
Valuation = Pool().get('valuation.valuation')
ValuationLine = Pool().get('valuation.valuation.line')
Valuation.create(values)
ValuationLine.create(values)
class Valuation(ValuationBase, ModelView):
"Valuation"
__name__ = 'valuation.valuation'
@@ -855,6 +1159,52 @@ class ValuationProcess(Wizard):
purchase_line_ids.add(matched_line.lot_p.line.id)
return purchase_line_ids
@classmethod
def _sale_line_is_unmatched(cls, sale_line):
for matched_line in sale_line.get_matched_lines() or []:
if matched_line.lot_p and matched_line.lot_p.line:
return False
return True
@classmethod
def _sale_line_ids_from_sale_ids(cls, sale_ids, unmatched_only=False):
if not sale_ids:
return set()
SaleLine = Pool().get('sale.line')
sale_lines = SaleLine.search([('sale', 'in', list(sale_ids))])
if unmatched_only:
sale_lines = [
sale_line for sale_line in sale_lines
if cls._sale_line_is_unmatched(sale_line)
]
return {sale_line.id for sale_line in sale_lines}
@classmethod
def _get_target_sale_line_ids(cls, start):
Sale = Pool().get('sale.sale')
dimension_filters = cls._get_dimension_filters(start)
has_purchase_filters = bool(
start.purchase_from_date
or start.purchase_to_date
or cls._parse_numbers(start.purchase_numbers)
)
has_sale_filters = bool(
start.sale_from_date
or start.sale_to_date
or cls._parse_numbers(start.sale_numbers)
)
if has_sale_filters:
sale_ids = cls._search_sale_ids(start, dimension_filters)
return cls._sale_line_ids_from_sale_ids(sale_ids, unmatched_only=True)
if dimension_filters and not has_purchase_filters:
sale_ids = cls._search_sale_ids(start, dimension_filters)
return cls._sale_line_ids_from_sale_ids(sale_ids, unmatched_only=True)
if not has_purchase_filters and not has_sale_filters and not dimension_filters:
sale_ids = {sale.id for sale in Sale.search([])}
return cls._sale_line_ids_from_sale_ids(sale_ids, unmatched_only=True)
return set()
@classmethod
def _get_target_purchase_line_ids(cls, start):
PurchaseLine = Pool().get('purchase.line')
@@ -899,8 +1249,11 @@ class ValuationProcess(Wizard):
def transition_process(self):
PurchaseLine = Pool().get('purchase.line')
SaleLine = Pool().get('sale.line')
target_ids = self._get_target_purchase_line_ids(self.start)
target_sale_line_ids = self._get_target_sale_line_ids(self.start)
lines = PurchaseLine.browse(list(target_ids))
sale_lines = SaleLine.browse(list(target_sale_line_ids))
purchase_ids = {line.purchase.id for line in lines if line.purchase}
sale_ids = set()
for line in lines:
@@ -910,10 +1263,16 @@ class ValuationProcess(Wizard):
Valuation.generate(line, valuation_type=self.start.valuation_type)
for sale_line in sale_lines:
sale_ids.add(sale_line.sale.id)
Valuation.generate_from_sale_line(
sale_line, valuation_type=self.start.valuation_type)
self._result_message = (
f"Processed {len(lines)} purchase line(s) "
f"and {len(sale_lines)} unmatched sale line(s) "
f"from {len(purchase_ids)} purchase(s) "
f"and {len(sale_ids)} linked sale(s)."
f"and {len(sale_ids)} sale(s)."
)
return 'result'