diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 6b5a7c5..9313ef3 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -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( diff --git a/modules/purchase_trade/valuation.py b/modules/purchase_trade/valuation.py index 6f1a205..ce049ee 100644 --- a/modules/purchase_trade/valuation.py +++ b/modules/purchase_trade/valuation.py @@ -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'