diff --git a/modules/purchase_trade/docs/business-rules.md b/modules/purchase_trade/docs/business-rules.md index eee54bf..6918e41 100644 --- a/modules/purchase_trade/docs/business-rules.md +++ b/modules/purchase_trade/docs/business-rules.md @@ -629,6 +629,8 @@ Owner technique: `a completer` - Intent: eviter qu'un fee cree sur un lot virtuel reste calcule sur la quantite contractuelle totale apres creation de lots physiques. +- Detail implementation / PnL: + - voir `modules/purchase_trade/docs/fees.md` - Description: - Le lien `fee.lots` avec le lot virtuel est conserve comme fallback. - Des qu'un fee possede au moins un lot physique dans `fee.lots`, les lots diff --git a/modules/purchase_trade/docs/fees.md b/modules/purchase_trade/docs/fees.md new file mode 100644 index 0000000..a0d0545 --- /dev/null +++ b/modules/purchase_trade/docs/fees.md @@ -0,0 +1,51 @@ +# Fees - regles de synchronisation PnL + +## PnL apres ajout ou modification d'un fee + +Un changement sur un `fee.fee` doit relancer le PnL des lignes commerciales +impactees, limite aux types de valuation fees: + +- `pur. fee` +- `sale fee` +- `shipment fee` +- `line fee` + +Le recalcul utilise `valuation_type = 'fees'` pour ne pas reecrire les lignes de +prix, derives ou MTM. + +## Lignes impactees + +Les lignes a recalculer sont retrouvees depuis les lots lies au fee: + +- `lot.line` pour la `purchase.line` +- `lot.sale_line` pour la `sale.line` + +Pour un fee de `stock.shipment.in`, si le lien direct par `fee.lots` ne suffit +pas, le shipment sert de fallback: + +- `shipment.incoming_moves[].lot` +- `shipment.lotqt[].lot_p` +- `shipment.lotqt[].lot_s` + +Ce fallback couvre les shipments encore ouverts ou le fee est pose sur des +`lot.qt` avant creation de lots physiques. + +## Lots effectifs du fee + +Le PnL des fees suit la regle BR-PT-021: + +- tant qu'il n'existe que des lots virtuels, le fee valorise l'ouvert; +- des qu'un lot physique est lie au fee, les lots physiques deviennent la base + effective; +- le lien virtuel peut rester present comme fallback. + +## Points de declenchement + +Le recalcul est declenche apres: + +- creation, modification ou suppression d'un `fee.fee`; +- creation, modification ou suppression d'un lien `fee.lots`. + +`stock.shipment.in.validate()` recalcule deja le PnL depuis les lots du +shipment, mais ce workflow ne couvre pas les changements de fee saisis apres +coup dans l'onglet Fees. diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index fc1463c..8f1b3af 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -449,18 +449,10 @@ class Fee(ModelSQL,ModelView): if lqts: return round(self.price * Decimal(lqts[0].lot_p.get_lot_price()) / 100 * lqts[0].lot_quantity * sign,2) - - @classmethod - def write(cls, *args): - super().write(*args) - fees = sum(args[::2], []) - for fee in fees: - fee.adjust_purchase_values() - - @classmethod - def copy(cls, fees, default=None): - if default is None: - default = {} + @classmethod + def copy(cls, fees, default=None): + if default is None: + default = {} else: default = default.copy() @@ -480,9 +472,9 @@ class Fee(ModelSQL,ModelView): return qt def adjust_purchase_values(self): - Purchase = Pool().get('purchase.purchase') - PurchaseLine = Pool().get('purchase.line') - logger.info("ADJUST_PURCHASE_VALUES:%s",self) + Purchase = Pool().get('purchase.purchase') + PurchaseLine = Pool().get('purchase.line') + logger.info("ADJUST_PURCHASE_VALUES:%s",self) if self.type == 'ordered' and self.state == 'not invoiced' and self.purchase: logger.info("ADJUST_PURCHASE_VALUES_QT:%s",self.purchase.lines[0].quantity) if self.mode == 'lumpsum': @@ -502,14 +494,81 @@ class Fee(ModelSQL,ModelView): PurchaseLine.save([self.purchase.lines[0]]) if self.supplier != self.purchase.party: self.purchase.party = self.supplier - if self.currency != self.purchase.currency: - self.purchase.currency = self.currency - Purchase.save([self.purchase]) - - # @classmethod - # def validate(cls, fees): - # super(Fee, cls).validate(fees) - + if self.currency != self.purchase.currency: + self.purchase.currency = self.currency + Purchase.save([self.purchase]) + + @classmethod + def _add_lot_pnl_lines(cls, lot, purchase_lines, sale_lines): + if not lot: + return + if getattr(lot, 'line', None): + purchase_lines[lot.line.id] = lot.line + if getattr(lot, 'sale_line', None): + sale_lines[lot.sale_line.id] = lot.sale_line + + @classmethod + def _collect_pnl_lines(cls, fees=None, lots=None): + purchase_lines = {} + sale_lines = {} + + for lot in lots or []: + cls._add_lot_pnl_lines(lot, purchase_lines, sale_lines) + + for fee in fees or []: + if getattr(fee, 'line', None): + purchase_lines[fee.line.id] = fee.line + if getattr(fee, 'sale_line', None): + sale_lines[fee.sale_line.id] = fee.sale_line + + for lot in getattr(fee, 'lots', None) or []: + cls._add_lot_pnl_lines(lot, purchase_lines, sale_lines) + + shipment = getattr(fee, 'shipment_in', None) + if not shipment: + continue + for move in getattr(shipment, 'incoming_moves', None) or []: + cls._add_lot_pnl_lines( + getattr(move, 'lot', None), purchase_lines, sale_lines) + for lqt in getattr(shipment, 'lotqt', None) or []: + cls._add_lot_pnl_lines( + getattr(lqt, 'lot_p', None), purchase_lines, sale_lines) + cls._add_lot_pnl_lines( + getattr(lqt, 'lot_s', None), purchase_lines, sale_lines) + + return purchase_lines.values(), sale_lines.values() + + @classmethod + def _regenerate_fee_pnl(cls, fees=None, lots=None): + purchase_lines, sale_lines = cls._collect_pnl_lines(fees, lots) + cls._regenerate_fee_pnl_for_lines(purchase_lines, sale_lines) + + @classmethod + def _regenerate_fee_pnl_for_lines(cls, purchase_lines, sale_lines): + purchase_lines = cls._unique_records(purchase_lines) + sale_lines = cls._unique_records(sale_lines) + if not purchase_lines and not sale_lines: + return + Valuation = Pool().get('valuation.valuation') + for line in purchase_lines: + Valuation.generate(line, valuation_type='fees') + for sale_line in sale_lines: + Valuation.generate_from_sale_line( + sale_line, valuation_type='fees') + + @staticmethod + def _unique_records(records): + unique = {} + for record in records: + if not record: + continue + unique[getattr(record, 'id', id(record))] = record + return list(unique.values()) + + # @classmethod + # def validate(cls, fees): + # super(Fee, cls).validate(fees) + @classmethod def create(cls, vlist): vlist = [x.copy() for x in vlist] @@ -552,14 +611,24 @@ class Fee(ModelSQL,ModelView): FeeLots.save([fl]) qt_sh += l.get_current_quantity_converted() unit = l.line.unit - else: - lqts = LotQt.search(['lot_shipment_in','=',fee.shipment_in.id]) - if lqts: - for l in lqts: - qt_sh += l.lot_p.get_current_quantity_converted() - unit = l.lot_p.line.unit - else: - raise UserError("You cannot add fee on received shipment!") + else: + lqts = LotQt.search(['lot_shipment_in','=',fee.shipment_in.id]) + if lqts: + for l in lqts: + if l.lot_p: + exist = FeeLots.search([ + ('fee','=',fee.id), + ('lot','=',l.lot_p.id), + ]) + if not exist: + fl = FeeLots() + fl.fee = fee.id + fl.lot = l.lot_p.id + FeeLots.save([fl]) + qt_sh += l.lot_p.get_current_quantity_converted() + unit = l.lot_p.line.unit + else: + raise UserError("You cannot add fee on received shipment!") type = fee.type if type == 'ordered': @@ -615,13 +684,35 @@ class Fee(ModelSQL,ModelView): account_move = move._get_account_stock_move_fee(fee) AccountMove.save([account_move]) else: - AccountMove = Pool().get('account.move') - account_move = fee._get_account_move_fee(fl.lot) - AccountMove.save([account_move]) - - return fees - - def _get_account_move_fee(self,lot,in_out='in',amt = None): + AccountMove = Pool().get('account.move') + account_move = fee._get_account_move_fee(fl.lot) + AccountMove.save([account_move]) + + cls._regenerate_fee_pnl(fees=fees) + return fees + + @classmethod + def write(cls, *args): + old_purchase_lines, old_sale_lines = cls._collect_pnl_lines( + fees=sum(args[::2], [])) + old_purchase_lines = list(old_purchase_lines) + old_sale_lines = list(old_sale_lines) + super().write(*args) + fees = sum(args[::2], []) + for fee in fees: + fee.adjust_purchase_values() + purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees) + cls._regenerate_fee_pnl_for_lines( + old_purchase_lines + list(purchase_lines), + old_sale_lines + list(sale_lines)) + + @classmethod + def delete(cls, fees): + purchase_lines, sale_lines = cls._collect_pnl_lines(fees=fees) + super().delete(fees) + cls._regenerate_fee_pnl_for_lines(purchase_lines, sale_lines) + + def _get_account_move_fee(self,lot,in_out='in',amt = None): pool = Pool() AccountMove = pool.get('account.move') Date = pool.get('ir.date') @@ -726,10 +817,16 @@ class FeeLots(ModelSQL,ModelView): for fee in Fee.browse(list(fee_ids)): fee.sync_quantity_from_lots() fee.adjust_purchase_values() + Fee._regenerate_fee_pnl( + fees=Fee.browse(list(fee_ids)), + lots=[record.lot for record in records if getattr(record, 'lot', None)]) return records @classmethod def write(cls, *args): + old_lots = [ + record.lot for record in sum(args[::2], []) + if getattr(record, 'lot', None)] super().write(*args) records = sum(args[::2], []) Fee = Pool().get('fee.fee') @@ -738,16 +835,24 @@ class FeeLots(ModelSQL,ModelView): for fee in Fee.browse(list(fee_ids)): fee.sync_quantity_from_lots() fee.adjust_purchase_values() + Fee._regenerate_fee_pnl( + fees=Fee.browse(list(fee_ids)), + lots=old_lots + [ + record.lot for record in records + if getattr(record, 'lot', None)]) @classmethod def delete(cls, records): Fee = Pool().get('fee.fee') fee_ids = { record.fee.id for record in records if getattr(record, 'fee', None)} + lots = [record.lot for record in records if getattr(record, 'lot', None)] super().delete(records) for fee in Fee.browse(list(fee_ids)): fee.sync_quantity_from_lots() fee.adjust_purchase_values() + Fee._regenerate_fee_pnl( + fees=Fee.browse(list(fee_ids)), lots=lots) class FeeReport( ModelSQL, ModelView): diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index dfcd4a7..6e37116 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -308,13 +308,14 @@ class Purchase(metaclass=PoolMeta): company_visible = fields.Function( fields.Boolean("Visible"), 'on_change_with_company_visible') lc_date = fields.Date("LC date") - product_origin = fields.Char("Origin") + product_origin = fields.Char( + "Origin", states={'invisible': Eval('company_visible'),}) @fields.depends('company', '_parent_company.party') def on_change_with_company_visible(self, name=None): return bool( self.company and self.company.party - and self.company.party.name == 'MELYA') + and self.company.party.name in {'MELYA', 'ITSA'}) def _get_default_bank_account(self): if not self.party or not self.party.bank_accounts: diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index 74df158..fd1e5a2 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -272,13 +272,14 @@ class Sale(metaclass=PoolMeta): company_visible = fields.Function( fields.Boolean("Visible"), 'on_change_with_company_visible') lc_date = fields.Date("LC date") - product_origin = fields.Char("Origin") + product_origin = fields.Char( + "Origin", states={'invisible': Eval('company_visible'),}) @fields.depends('company', '_parent_company.party') def on_change_with_company_visible(self, name=None): return bool( self.company and self.company.party - and self.company.party.name == 'MELYA') + and self.company.party.name in {'MELYA', 'ITSA'}) def _get_default_bank_account(self): if not self.party or not self.party.bank_accounts: diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index d15ed76..29c9652 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -272,6 +272,46 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(fee.quantity, Decimal('40.00000')) save.assert_called_once_with([fee]) + def test_fee_pnl_regeneration_uses_shipment_lotqt_purchase_line(self): + 'fee pnl regeneration follows shipment lot.qt back to purchase line' + Fee = Pool().get('fee.fee') + purchase_line = Mock(id=1) + lot_p = Mock(line=purchase_line, sale_line=None) + shipment = Mock( + incoming_moves=[], + lotqt=[Mock(lot_p=lot_p, lot_s=None)], + ) + fee = Mock(line=None, sale_line=None, lots=[], shipment_in=shipment) + valuation = Mock() + + with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock: + PoolMock.return_value.get.return_value = valuation + + Fee._regenerate_fee_pnl(fees=[fee]) + + valuation.generate.assert_called_once_with( + purchase_line, valuation_type='fees') + valuation.generate_from_sale_line.assert_not_called() + + def test_fee_pnl_regeneration_updates_purchase_and_sale_lot_lines(self): + 'fee pnl regeneration follows physical fee lots to purchase and sale' + Fee = Pool().get('fee.fee') + purchase_line = Mock(id=1) + sale_line = Mock(id=2) + lot = Mock(line=purchase_line, sale_line=sale_line) + fee = Mock(line=None, sale_line=None, lots=[lot], shipment_in=None) + valuation = Mock() + + with patch('trytond.modules.purchase_trade.fee.Pool') as PoolMock: + PoolMock.return_value.get.return_value = valuation + + Fee._regenerate_fee_pnl(fees=[fee]) + + valuation.generate.assert_called_once_with( + purchase_line, valuation_type='fees') + valuation.generate_from_sale_line.assert_called_once_with( + sale_line, valuation_type='fees') + def test_purchase_fee_pnl_ignores_virtual_fee_lot_when_physical_exists(self): 'purchase fee pnl uses physical fee lots instead of the residual virtual' Valuation = Pool().get('valuation.valuation')