diff --git a/modules/purchase_trade/__init__.py b/modules/purchase_trade/__init__.py index 2d4749b..8384a52 100755 --- a/modules/purchase_trade/__init__.py +++ b/modules/purchase_trade/__init__.py @@ -137,9 +137,10 @@ def register(): open_position.OpenPosition, open_position.OpenPositionReport, open_position.OpenPositionContext, - optional.OptionalScenario, - fee.Fee, - fee.FeeLots, + optional.OptionalScenario, + fee.Fee, + fee.FeeRule, + fee.FeeLots, purchase.FeeLots, valuation.Valuation, valuation.ValuationLine, diff --git a/modules/purchase_trade/fee.py b/modules/purchase_trade/fee.py index 0e30740..c2fa3fc 100755 --- a/modules/purchase_trade/fee.py +++ b/modules/purchase_trade/fee.py @@ -35,7 +35,9 @@ class Fee(ModelSQL,ModelView): "Fee" __name__ = 'fee.fee' - line = fields.Many2One('purchase.line',"Line") + line = fields.Many2One('purchase.line',"Line") + source_rule = fields.Many2One('fee.rule', "Source Rule", readonly=True) + generated_by_rule = fields.Boolean("Generated by Rule", readonly=True) shipment_in = fields.Many2One('stock.shipment.in') shipment_out = fields.Many2One('stock.shipment.out') shipment_internal = fields.Many2One('stock.shipment.internal') @@ -99,9 +101,13 @@ class Fee(ModelSQL,ModelView): fee_date = fields.Date("Date") @classmethod - def default_fee_date(cls): - Date = Pool().get('ir.date') - return Date.today() + def default_fee_date(cls): + Date = Pool().get('ir.date') + return Date.today() + + @classmethod + def default_generated_by_rule(cls): + return False @classmethod def default_qt_state(cls): @@ -971,7 +977,177 @@ class Fee(ModelSQL,ModelView): description=description, lines=[move_line,move_line_], ) - + +class FeeRule(ModelSQL, ModelView): + "Fee Rule" + __name__ = 'fee.rule' + + name = fields.Char("Name", required=True) + active = fields.Boolean("Active") + sequence = fields.Integer("Sequence") + apply_on = fields.Selection([ + ('purchase_line', "Purchase Line"), + ('sale_line', "Sale Line"), + ], "Apply On", required=True) + auto_apply = fields.Boolean("Auto Apply") + + party = fields.Many2One('party.party', "Counterparty") + line_product = fields.Many2One('product.product', "Line Product") + incoterm = fields.Many2One('incoterm.incoterm', "Incoterm") + from_location = fields.Many2One('stock.location', "From Location") + to_location = fields.Many2One('stock.location', "To Location") + execution_plan = fields.Many2One('workflow.plan', "Execution Plan") + + fee_product = fields.Many2One( + 'product.product', "Fee Product", required=True, + domain=[('type', '=', 'service')]) + supplier = fields.Many2One('party.party', "Supplier") + p_r = fields.Selection([ + ('pay', 'PAY'), + ('rec', 'REC'), + ], "P/R", required=True) + mode = fields.Selection([ + ('lumpsum', 'Lump sum'), + ('perqt', 'Per qt'), + ('pprice', '% price'), + ('rate', '% rate'), + ('pcost', '% cost price'), + ('ppack', 'Per packing'), + ], "Mode", required=True) + price = fields.Numeric("Price", digits=(16, 4)) + quantity = fields.Numeric("Qt", digits='unit') + currency = fields.Many2One('currency.currency', "Currency") + unit = fields.Many2One('product.uom', "Unit") + qt_state = fields.Many2One('lot.qt.type', "Qt State") + weight_type = fields.Selection([ + ('net', 'Net'), + ('brut', 'Gross'), + ], "W. type") + + @classmethod + def default_active(cls): + return True + + @classmethod + def default_sequence(cls): + return 10 + + @classmethod + def default_auto_apply(cls): + return False + + @classmethod + def default_p_r(cls): + return 'pay' + + @classmethod + def default_mode(cls): + return 'lumpsum' + + @classmethod + def default_weight_type(cls): + return 'brut' + + @staticmethod + def _same_record(left, right): + return bool( + left and right + and getattr(left, 'id', left) == getattr(right, 'id', right)) + + @classmethod + def _rules_for_lines(cls, apply_on, auto_only=False): + domain = [ + ('active', '=', True), + ('apply_on', '=', apply_on), + ] + if auto_only: + domain.append(('auto_apply', '=', True)) + return cls.search(domain, order=[('sequence', 'ASC'), ('id', 'ASC')]) + + def _matches_line(self, line, apply_on): + contract = getattr(line, 'purchase', None) or getattr(line, 'sale', None) + if not contract or self.apply_on != apply_on: + return False + checks = [ + (self.party, getattr(contract, 'party', None)), + (self.line_product, getattr(line, 'product', None)), + (self.incoterm, getattr(contract, 'incoterm', None)), + (self.from_location, getattr(contract, 'from_location', None)), + (self.to_location, getattr(contract, 'to_location', None)), + ] + if any(rule_value and not self._same_record(rule_value, actual) + for rule_value, actual in checks): + return False + if self.execution_plan and not self._same_record( + self.execution_plan, getattr(contract, 'plan', None)): + return False + return True + + def _existing_fee(self, line, apply_on): + Fee = Pool().get('fee.fee') + field_name = 'line' if apply_on == 'purchase_line' else 'sale_line' + existing = Fee.search([ + ('source_rule', '=', self.id), + (field_name, '=', line.id), + ], limit=1) + return existing[0] if existing else None + + def _fee_values(self, line, apply_on): + contract = getattr(line, 'purchase', None) or getattr(line, 'sale', None) + values = { + 'source_rule': self.id, + 'generated_by_rule': True, + 'type': 'budgeted', + 'p_r': self.p_r, + 'product': self.fee_product.id, + 'supplier': ( + getattr(self.supplier, 'id', None) + or getattr(getattr(contract, 'party', None), 'id', None)), + 'mode': self.mode, + 'price': self.price if self.price is not None else Decimal(0), + 'currency': ( + getattr(self.currency, 'id', None) + or getattr(getattr(contract, 'currency', None), 'id', None)), + 'unit': ( + getattr(self.unit, 'id', None) + or getattr(getattr(line, 'unit', None), 'id', None)), + 'quantity': ( + self.quantity if self.quantity is not None + else getattr(line, 'quantity_theorical', None) + or getattr(line, 'quantity', None)), + 'qt_state': getattr(self.qt_state, 'id', None), + 'weight_type': self.weight_type, + } + values['line' if apply_on == 'purchase_line' else 'sale_line'] = line.id + return values + + @classmethod + def apply_to_lines(cls, lines, apply_on, auto_only=False): + Fee = Pool().get('fee.fee') + rules = cls._rules_for_lines(apply_on, auto_only=auto_only) + created = [] + for line in lines: + if not getattr(line, 'id', None): + continue + for rule in rules: + if not rule._matches_line(line, apply_on): + continue + if rule._existing_fee(line, apply_on): + continue + values = rule._fee_values(line, apply_on) + if not ( + values.get('supplier') and values.get('currency') + and values.get('unit')): + logger.info( + "FEE_RULE_SKIPPED_MISSING_VALUES rule=%s line=%s", + rule.id, line.id) + continue + with Transaction().set_context( + _purchase_trade_skip_fee_rules=True): + created.extend(Fee.create([values])) + return created + + class FeeLots(ModelSQL,ModelView): "Fee lots" diff --git a/modules/purchase_trade/fee.xml b/modules/purchase_trade/fee.xml index eeeba06..1d6ed50 100755 --- a/modules/purchase_trade/fee.xml +++ b/modules/purchase_trade/fee.xml @@ -19,11 +19,36 @@ this repository contains the full copyright notices and license terms. --> fee_tree_sequence - - fee.fee - tree - fee_tree_sequence2 - + + fee.fee + tree + fee_tree_sequence2 + + + + fee.rule + form + fee_rule_form + + + fee.rule + tree + fee_rule_tree + + + Fee Rules + fee.rule + + + + + + + + + + + fee.report @@ -51,13 +76,49 @@ this repository contains the full copyright notices and license terms. --> invoice Invoice - - - - - - fee.fee - + + + + + + purchase.purchase + apply_default_fees + Apply Default Fees + + + + + + + sale.sale + apply_default_fees + Apply Default Fees + + + + + + + purchase.line + apply_default_fees + Apply Default Fees + + + + + + + sale.line + apply_default_fees + Apply Default Fees + + + + + + + fee.fee + @@ -67,15 +128,36 @@ this repository contains the full copyright notices and license terms. --> - - - - - + + + + fee.rule + + + + + + + fee.rule + + + + + + + + - + sequence="20" + id="menu_fee_report_form"/> + + diff --git a/modules/purchase_trade/purchase.py b/modules/purchase_trade/purchase.py index 734b86d..fb3188c 100755 --- a/modules/purchase_trade/purchase.py +++ b/modules/purchase_trade/purchase.py @@ -410,6 +410,14 @@ class Purchase(metaclass=PoolMeta): def default_tolerance_option(cls): return '' + @classmethod + @ModelView.button + def apply_default_fees(cls, purchases): + lines = [ + line for purchase in purchases for line in (purchase.lines or []) + if getattr(line, 'type', None) == 'line'] + Pool().get('fee.rule').apply_to_lines(lines, 'purchase_line') + def _line_quantity_in_unit(self, quantity, from_unit, to_unit): if not from_unit or not to_unit or from_unit == to_unit: return Decimal(str(quantity or 0)) @@ -1312,7 +1320,16 @@ class Line(metaclass=PoolMeta): for values in vlist: cls._check_delivery_period_values([cls()], values) cls._set_initial_quantity_values(values) - return super().create(vlist) + lines = super().create(vlist) + if not Transaction().context.get('_purchase_trade_skip_fee_rules'): + Pool().get('fee.rule').apply_to_lines( + lines, 'purchase_line', auto_only=True) + return lines + + @classmethod + @ModelView.button + def apply_default_fees(cls, lines): + Pool().get('fee.rule').apply_to_lines(lines, 'purchase_line') def _check_delivery_period(self): if self._has_invalid_delivery_period(self): @@ -1877,6 +1894,9 @@ class Line(metaclass=PoolMeta): Pool().get('lot.lot').assert_lines_quantity_consistency( cls._fresh_lines_for_quantity_consistency(lines)) + if not Transaction().context.get('_purchase_trade_skip_fee_rules'): + Pool().get('fee.rule').apply_to_lines( + lines, 'purchase_line', auto_only=True) @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): diff --git a/modules/purchase_trade/sale.py b/modules/purchase_trade/sale.py index ae7aac1..3332254 100755 --- a/modules/purchase_trade/sale.py +++ b/modules/purchase_trade/sale.py @@ -374,6 +374,14 @@ class Sale(metaclass=PoolMeta): def default_tolerance_option(cls): return '' + @classmethod + @ModelView.button + def apply_default_fees(cls, sales): + lines = [ + line for sale in sales for line in (sale.lines or []) + if getattr(line, 'type', None) == 'line'] + Pool().get('fee.rule').apply_to_lines(lines, 'sale_line') + def _line_quantity_in_unit(self, quantity, from_unit, to_unit): if not from_unit or not to_unit or from_unit == to_unit: return abs(Decimal(str(quantity or 0))) @@ -1358,7 +1366,16 @@ class SaleLine(metaclass=PoolMeta): for values in vlist: cls._check_delivery_period_values([cls()], values) cls._set_initial_quantity_values(values) - return super().create(vlist) + lines = super().create(vlist) + if not Transaction().context.get('_purchase_trade_skip_fee_rules'): + Pool().get('fee.rule').apply_to_lines( + lines, 'sale_line', auto_only=True) + return lines + + @classmethod + @ModelView.button + def apply_default_fees(cls, lines): + Pool().get('fee.rule').apply_to_lines(lines, 'sale_line') def _check_delivery_period(self): if self._has_invalid_delivery_period(self): @@ -2057,6 +2074,9 @@ class SaleLine(metaclass=PoolMeta): Pool().get('lot.lot').assert_lines_quantity_consistency( cls._fresh_lines_for_quantity_consistency(lines)) + if not Transaction().context.get('_purchase_trade_skip_fee_rules'): + Pool().get('fee.rule').apply_to_lines( + lines, 'sale_line', auto_only=True) @classmethod def _sync_open_lot_quantity(cls, line, vlot, target_quantity): diff --git a/modules/purchase_trade/tests/test_module.py b/modules/purchase_trade/tests/test_module.py index 127b347..94aeb67 100644 --- a/modules/purchase_trade/tests/test_module.py +++ b/modules/purchase_trade/tests/test_module.py @@ -1784,6 +1784,75 @@ class PurchaseTradeTestCase(ModuleTestCase): self.assertEqual(Sale.default_tolerance_option(), '') self.assertEqual(Purchase.default_tolerance_option(), '') + def test_fee_rule_matches_purchase_line_conditions(self): + 'fee rule matches purchase line conditions' + party = Mock(id=1) + product = Mock(id=2) + incoterm = Mock(id=3) + from_location = Mock(id=4) + to_location = Mock(id=5) + plan = Mock(id=6) + contract = Mock( + party=party, + incoterm=incoterm, + from_location=from_location, + to_location=to_location, + plan=plan, + ) + line = Mock(product=product, purchase=contract, sale=None) + rule = fee_module.FeeRule() + rule.apply_on = 'purchase_line' + rule.party = party + rule.line_product = product + rule.incoterm = incoterm + rule.from_location = from_location + rule.to_location = to_location + rule.execution_plan = plan + + self.assertTrue(rule._matches_line(line, 'purchase_line')) + + def test_fee_rule_builds_budgeted_sale_fee_values(self): + 'fee rule builds budgeted fee values for a sale line' + currency = Mock(id=1) + unit = Mock(id=2) + party = Mock(id=3) + product = Mock(id=4) + fee_product = Mock(id=5) + sale = Mock(party=party, currency=currency) + line = Mock( + id=10, + product=product, + sale=sale, + purchase=None, + unit=unit, + quantity_theorical=Decimal('100'), + quantity=Decimal('90'), + ) + rule = fee_module.FeeRule() + rule.id = 20 + rule.fee_product = fee_product + rule.supplier = None + rule.p_r = 'pay' + rule.mode = 'perqt' + rule.price = Decimal('12.50') + rule.quantity = None + rule.currency = None + rule.unit = None + rule.qt_state = None + rule.weight_type = 'brut' + + values = rule._fee_values(line, 'sale_line') + + self.assertEqual(values['source_rule'], 20) + self.assertTrue(values['generated_by_rule']) + self.assertEqual(values['type'], 'budgeted') + self.assertEqual(values['sale_line'], 10) + self.assertEqual(values['product'], 5) + self.assertEqual(values['supplier'], 3) + self.assertEqual(values['currency'], 1) + self.assertEqual(values['unit'], 2) + self.assertEqual(values['quantity'], Decimal('100')) + def test_sale_line_basis_price_and_progress_use_manual_summary_without_component(self): 'sale line basis values use manual summary rows even without a component' SaleLine = Pool().get('sale.line') diff --git a/modules/purchase_trade/view/fee_rule_form.xml b/modules/purchase_trade/view/fee_rule_form.xml new file mode 100644 index 0000000..7860cdb --- /dev/null +++ b/modules/purchase_trade/view/fee_rule_form.xml @@ -0,0 +1,46 @@ +
+