From 0833baa2ab455db6b0996c09e4159d0142b70d68 Mon Sep 17 00:00:00 2001 From: laurentbarontini Date: Tue, 16 Jun 2026 19:53:29 +0200 Subject: [PATCH] Fee linked curr --- modules/purchase_trade/fee.py | 210 +++++++++++------- modules/purchase_trade/tests/test_module.py | 54 +++++ .../purchase_trade/view/fee_tree_sequence.xml | 2 +- .../view/fee_tree_sequence2.xml | 2 +- 4 files changed, 188 insertions(+), 80 deletions(-) diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 40e60f0..434b097 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -59,11 +59,11 @@ class Fee(ModelSQL,ModelView): ], "P/R", required=True) product = fields.Many2One('product.product',"Product", required=True, domain=[('type', '=', 'service')]) price = fields.Numeric("Price",digits=(1,4)) - enable_linked_currency = fields.Boolean("Linked currencies") + enable_linked_currency = fields.Boolean("Linked curr") linked_price = fields.Numeric("Linked Price", digits='unit', states={ 'invisible': ~Eval('enable_linked_currency'), }, depends=['enable_linked_currency']) - linked_currency = fields.Many2One('currency.linked', "Linked Currency", + linked_currency = fields.Many2One('currency.linked', "Linked curr", states={ 'invisible': ~Eval('enable_linked_currency'), }, depends=['enable_linked_currency']) @@ -366,18 +366,26 @@ class Fee(ModelSQL,ModelView): if fl[0].lot.sale_line: return fl[0].lot.sale_line.unit - @classmethod - @ModelView.button - @filter_state('not invoiced') - def invoice(cls, fees): - Purchase = Pool().get('purchase.purchase') - FeeLots = Pool().get('fee.lots') - for fee in fees: - if fee.purchase: - fl = FeeLots.search([('fee','=',fee.id)]) - logger.info("PROCESS_FROM_FEE:%s",fl) - Purchase._process_invoice([fee.purchase],[e.lot for e in fl],'service') - cls.write(fees, {'state': 'invoiced',}) + @classmethod + @ModelView.button + @filter_state('not invoiced') + def invoice(cls, fees): + Purchase = Pool().get('purchase.purchase') + FeeLots = Pool().get('fee.lots') + fees_to_invoice = [] + for fee in fees: + fee.ensure_ordered_purchase() + if fee.purchase: + fl = FeeLots.search([('fee','=',fee.id)]) + logger.info("PROCESS_FROM_FEE:%s",fl) + Purchase._process_invoice([fee.purchase],[e.lot for e in fl],'service') + fees_to_invoice.append(fee) + else: + raise UserError( + "Cannot invoice fee %s: no generated service purchase " + "is linked to this fee." % getattr(fee, 'id', None)) + if fees_to_invoice: + cls.write(fees_to_invoice, {'state': 'invoiced',}) @classmethod def default_type(cls): @@ -810,17 +818,117 @@ class Fee(ModelSQL,ModelView): "accounting period is missing or incomplete. Do you want to " "continue without creating the accrual entry?") + def _can_create_ordered_purchase(self): + return ( + getattr(self, 'type', None) == 'ordered' + and not getattr(self, 'purchase', None) + and getattr(self, 'currency', None) + and getattr(self, 'price', None) is not None) + + def _get_ordered_purchase_quantity_unit(self): + quantity = Decimal(0) + unit = None + lots = self._get_effective_fee_lots() + if lots: + quantity = sum( + Decimal(lot.get_current_quantity_converted() or 0) + for lot in lots) + first_lot = lots[0] + if getattr(first_lot, 'line', None): + unit = first_lot.line.unit + elif getattr(first_lot, 'sale_line', None): + unit = first_lot.sale_line.unit + elif getattr(self, 'quantity', None) is not None: + quantity = Decimal(self.quantity or 0) + unit = getattr(self, 'unit', None) + return quantity, unit + + def _get_ordered_purchase_locations(self): + if getattr(self, 'shipment_in', None): + return self.shipment_in.from_location, self.shipment_in.to_location + if getattr(self, 'line', None): + return self.line.purchase.from_location, self.line.purchase.to_location + return self.sale_line.sale.from_location, self.sale_line.sale.to_location + + def _get_ordered_purchase_payment_term(self): + if getattr(self, 'shipment_in', None) and self.shipment_in.lotqt: + return self.shipment_in.lotqt[0].lot_p.line.purchase.payment_term + if getattr(self, 'line', None): + return self.line.purchase.payment_term + return self.sale_line.sale.payment_term + + def _set_ordered_purchase_line_values(self, line, quantity, unit): + line.product = self.product + line.quantity = round(quantity, 5) + line.unit = unit + line.fee_ = self.id + if getattr(self, 'price', None) is not None: + fee_price = self.get_price_per_qt() + logger.info("GET_FEE_PRICE_PER_QT:%s", fee_price) + line.unit_price = round(Decimal(fee_price), 4) + if self.mode == 'lumpsum': + line.quantity = 1 + line.unit_price = round(Decimal(self.amount or 0), 4) + elif self.mode == 'ppack': + line.unit_price = self.price + + def _create_fee_accruals(self): + if self.sale_line: + return + FeeLots = Pool().get('fee.lots') + feelots = FeeLots.search(['fee','=',self.id]) + for fl in feelots: + if self.product.template.landed_cost: + move = fl.lot.get_received_move() + if move: + Warning = Pool().get('res.user.warning') + warning_name = Warning.format("Lot ever received", []) + if Warning.check(warning_name): + raise UserWarning(warning_name, + "By clicking yes, an accrual for this fee will be created") + account_move = move._get_account_stock_move_fee(self) + self.__class__._save_fee_accrual_or_warn( + account_move, self, fl.lot) + else: + account_move = self._get_account_move_fee(fl.lot) + self.__class__._save_fee_accrual_or_warn( + account_move, self, fl.lot) + + def ensure_ordered_purchase(self): + if not self._can_create_ordered_purchase(): + return False + Purchase = Pool().get('purchase.purchase') + PurchaseLine = Pool().get('purchase.line') + quantity, unit = self._get_ordered_purchase_quantity_unit() + line = PurchaseLine() + self._set_ordered_purchase_line_values(line, quantity, unit) + purchase = Purchase() + purchase.lines = [line] + purchase.party = self.supplier + if purchase.party.addresses: + purchase.invoice_address = purchase.party.addresses[0] + purchase.currency = self.currency + purchase.line_type = 'service' + purchase.from_location, purchase.to_location = ( + self._get_ordered_purchase_locations()) + purchase.payment_term = self._get_ordered_purchase_payment_term() + Purchase.save([purchase]) + self.purchase = purchase + with Transaction().set_context(_purchase_trade_skip_fee_sync=True): + self.__class__.save([self]) + self._create_fee_accruals() + return True + @classmethod def create(cls, vlist): vlist = [x.copy() for x in vlist] fees = super(Fee, cls).create(vlist) - qt_sh = Decimal(0) - qt_line = Decimal(0) - unit = None - for fee in fees: - FeeLots = Pool().get('fee.lots') - Lots = Pool().get('lot.lot') - LotQt = Pool().get('lot.qt') + for fee in fees: + qt_sh = Decimal(0) + qt_line = Decimal(0) + FeeLots = Pool().get('fee.lots') + Lots = Pool().get('lot.lot') + LotQt = Pool().get('lot.qt') if fee.line: for l in fee.line.lots: if (l.lot_type == 'virtual' and len(fee.line.lots)==1) or (l.lot_type == 'physic' and len(fee.line.lots)>1): @@ -871,63 +979,8 @@ class Fee(ModelSQL,ModelView): else: raise UserError("You cannot add fee on received shipment!") - type = fee.type - if type == 'ordered': - Purchase = Pool().get('purchase.purchase') - PurchaseLine = Pool().get('purchase.line') - pl = PurchaseLine() - pl.product = fee.product - if fee.line or fee.sale_line: - pl.quantity = round(qt_line,5) - if fee.shipment_in: - pl.quantity = round(qt_sh,5) - logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s",pl.quantity) - pl.unit = unit - pl.fee_ = fee.id - if fee.price: - fee_price = fee.get_price_per_qt() - logger.info("GET_FEE_PRICE_PER_QT:%s",fee_price) - pl.unit_price = round(Decimal(fee_price),4) - if fee.mode == 'lumpsum': - pl.quantity = 1 - pl.unit_price = round(Decimal(fee.amount),4) - elif fee.mode == 'ppack': - pl.unit_price = fee.price - p = Purchase() - p.lines = [pl] - p.party = fee.supplier - if p.party.addresses: - p.invoice_address = p.party.addresses[0] - p.currency = fee.currency - p.line_type = 'service' - p.from_location = fee.shipment_in.from_location if fee.shipment_in else (fee.line.purchase.from_location if fee.line else fee.sale_line.sale.from_location) - p.to_location = fee.shipment_in.to_location if fee.shipment_in else (fee.line.purchase.to_location if fee.line else fee.sale_line.sale.to_location) - if fee.shipment_in and fee.shipment_in.lotqt: - p.payment_term = fee.shipment_in.lotqt[0].lot_p.line.purchase.payment_term - elif fee.line: - p.payment_term = fee.line.purchase.payment_term - elif fee.sale_line: - p.payment_term = fee.sale_line.sale.payment_term - Purchase.save([p]) - #if reception of moves done we need to generate accrual for fee - if not fee.sale_line: - feelots = FeeLots.search(['fee','=',fee.id]) - for fl in feelots: - if fee.product.template.landed_cost: - move = fl.lot.get_received_move() - if move: - Warning = Pool().get('res.user.warning') - warning_name = Warning.format("Lot ever received", []) - if Warning.check(warning_name): - raise UserWarning(warning_name, - "By clicking yes, an accrual for this fee will be created") - account_move = move._get_account_stock_move_fee(fee) - cls._save_fee_accrual_or_warn( - account_move, fee, fl.lot) - else: - account_move = fee._get_account_move_fee(fl.lot) - cls._save_fee_accrual_or_warn( - account_move, fee, fl.lot) + logger.info("CREATE_PURHCASE_FOR_FEE_QT:%s", qt_line or qt_sh) + fee.ensure_ordered_purchase() for fee in fees: fee.sync_quantity_from_lots() @@ -947,6 +1000,7 @@ class Fee(ModelSQL,ModelView): fees = sum(args[::2], []) for fee in fees: fee.sync_quantity_from_lots() + fee.ensure_ordered_purchase() fee.adjust_purchase_values() cls.assert_quantities_consistency(fees) purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees) diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 2dd4329..36ad46b 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -2,6 +2,7 @@ # this repository contains the full copyright notices and license terms. import datetime +from contextlib import nullcontext from decimal import Decimal from unittest.mock import ANY, Mock, patch from xml.etree import ElementTree @@ -2552,6 +2553,59 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertFalse(field.required) self.assertNotIn('required', field.states) + def test_fee_ensure_ordered_purchase_creates_missing_service_purchase(self): + 'ordered fee creates missing purchase after linked values are completed' + Fee = Pool().get('fee.fee') + fee = Fee() + fee.id = 10 + fee.type = 'ordered' + fee.purchase = None + fee.currency = Mock() + fee.price = Decimal('12') + fee.mode = 'perqt' + fee.product = Mock() + fee.supplier = Mock(addresses=[]) + fee.quantity = Decimal('5') + fee.unit = Mock() + fee.shipment_in = None + fee.sale_line = None + fee.line = Mock( + purchase=Mock( + from_location=Mock(), + to_location=Mock(), + payment_term=Mock())) + + purchase = Mock() + purchase_model = Mock(return_value=purchase) + line = Mock() + line_model = Mock(return_value=line) + + with patch( + 'trytond.modules.purchase_trade.fee.Pool' + ) as PoolMock, patch( + 'trytond.modules.purchase_trade.fee.Transaction' + ) as TransactionMock, patch.object( + Fee, 'save') as save, patch.object( + fee, '_get_effective_fee_lots', return_value=[]), patch.object( + fee, '_create_fee_accruals') as create_accruals: + PoolMock.return_value.get.side_effect = lambda name: { + 'purchase.purchase': purchase_model, + 'purchase.line': line_model, + }[name] + TransactionMock.return_value.set_context.return_value = ( + nullcontext()) + + self.assertTrue(fee.ensure_ordered_purchase()) + + self.assertEqual(fee.purchase, purchase) + self.assertEqual(purchase.lines, [line]) + self.assertEqual(purchase.currency, fee.currency) + self.assertEqual(line.fee_, fee.id) + self.assertEqual(line.unit_price, Decimal('12.0000')) + purchase_model.save.assert_called_once_with([purchase]) + save.assert_called_once_with([fee]) + create_accruals.assert_called_once_with() + def test_fee_get_non_cog_returns_zero_without_move_lines(self): 'fee non-cog amount is zero before any accounting move line exists' fee = fee_module.Fee() diff --git a/modules/purchase_trade/view/fee_tree_sequence.xml b/modules/purchase_trade/view/fee_tree_sequence.xml index b64929f..532b730 100755 --- a/modules/purchase_trade/view/fee_tree_sequence.xml +++ b/modules/purchase_trade/view/fee_tree_sequence.xml @@ -8,7 +8,7 @@ this repository contains the full copyright notices and license terms. --> - + diff --git a/modules/purchase_trade/view/fee_tree_sequence2.xml b/modules/purchase_trade/view/fee_tree_sequence2.xml index 5c66ca1..a42989f 100755 --- a/modules/purchase_trade/view/fee_tree_sequence2.xml +++ b/modules/purchase_trade/view/fee_tree_sequence2.xml @@ -8,7 +8,7 @@ this repository contains the full copyright notices and license terms. --> - +