Merge lp:~camptocamp/purchase-wkfl/7.0-merge-po-hooks-lep into lp:~purchase-core-editors/purchase-wkfl/7.0

Proposed by Leonardo Pistone
Status: Merged
Approved by: Yannick Vaucher @ Camptocamp
Approved revision: 41
Merged at revision: 34
Proposed branch: lp:~camptocamp/purchase-wkfl/7.0-merge-po-hooks-lep
Merge into: lp:~purchase-core-editors/purchase-wkfl/7.0
Diff against target: 480 lines (+448/-0)
6 files modified
purchase_group_hooks/__init__.py (+22/-0)
purchase_group_hooks/__openerp__.py (+47/-0)
purchase_group_hooks/purchase_group_hooks.py (+236/-0)
purchase_group_hooks/test/merge_order.yml (+41/-0)
purchase_group_hooks/tests/__init__.py (+26/-0)
purchase_group_hooks/tests/test_merge_po.py (+76/-0)
To merge this branch: bzr merge lp:~camptocamp/purchase-wkfl/7.0-merge-po-hooks-lep
Reviewer Review Type Date Requested Status
Yannick Vaucher @ Camptocamp code review, no test Approve
martin (community) Approve
Pedro Manuel Baeza Abstain
Alexandre Fayolle - camptocamp code review, no test Approve
Romain Deheele - Camptocamp (community) Abstain
Review via email: mp+216745@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Pedro Manuel Baeza (pedro.baeza) wrote :

Hi, Leonardo, very good workaround, but I think it deserves to propose this change to 7.0/trunk official branches instead of a module, so that we don't have to update module with each change/fix made in the replaced part. It doesn't imply changes in DB layout, so it can be merged in stable 7.0, but at least if we get this on trunk, it's a good win.

Regards.

review: Needs Information
Revision history for this message
Leonardo Pistone (lepistone) wrote :

Hi Pedro.

For trunk, I am actually going to propose it in the official branches tomorrow. See lp:~camptocamp/openobject-addons/trunk-refactor-po-merge-lep

For 7.0, I thought it would be easier to do an extra module.

Thanks!

Revision history for this message
Leonardo Pistone (lepistone) wrote :
Revision history for this message
Romain Deheele - Camptocamp (romaindeheele) wrote :

I asked myself about the way to merge origins.
A case seems not managed:
If a PO has origins "A B" and an other PO has "B C", merge result won't be "A B C".
With standard OE processes, it's probably impossible, it's a bit a far-fetched case, so I abstain me.

review: Abstain
Revision history for this message
Leonardo Pistone (lepistone) wrote :

Your point makes sense, Romain.

I would rather leave it as it is, since:

1. This is just a refactor with a few tests added. It is probably better to keep the existing functionality for now.

2. I'm not sure that can happen often in practice, and the implementation could use string manipulation (brittle) or new field (complex).

Thanks!

Revision history for this message
martin (riess82) wrote :

I would like to suggest the following changes to purchase_group_hooks.py:

- Change _key_fields_for_grouping_lines to return a list like _key_fields_for_grouping and not a tuple
- function _update_merged_order_data exists but is not used? This might be the reason why the addition of field "origin" is not working.

Revision history for this message
Leonardo Pistone (lepistone) wrote :

You are right Martin, thanks.

You can set review: Needs fixing and press "save comment" (no comment is OK because you already commented).

Revision history for this message
martin (riess82) :
review: Needs Fixing
Revision history for this message
Leonardo Pistone (lepistone) wrote :

I fixed martin's remarks, and added a new unit test. We're back into "needs review".

Revision history for this message
Alexandre Fayolle - camptocamp (alexandre-fayolle-c2c) wrote :

LGTM

review: Approve (code review, no test)
Revision history for this message
Leonardo Pistone (lepistone) wrote :

Martin, can you please reconsider your review? your points should be fixed now.

Revision history for this message
Pedro Manuel Baeza (pedro.baeza) wrote :

I abstain for the moment until I can review the MP.

review: Abstain
Revision history for this message
Leonardo Pistone (lepistone) wrote :

highlights from the similar MP on trunk: Olivier Dony said they will merge on the core in trunk but not in 7.0, so this extra addon for 7.0 makes sense. Olivier made some remarks I agree with, they'll be fixed here as well ASAP.

thanks!

Revision history for this message
martin (riess82) wrote :

I would suggest that both _key_fields_for_grouping and _key_fields_for_grouping_lines do a return['a','b']. At the moment I get the message that the functions return tuples. If they return lists, a simple res.append('c') is sufficient to add custom fields.

review: Needs Fixing
Revision history for this message
Leonardo Pistone (lepistone) wrote :

Martin,

I am not convinced we need mutability here. Couldn't we still do "return res + ('c',)"?

Revision history for this message
martin (riess82) wrote :

Leonardo, you are right. Both methods will work.

Revision history for this message
Yannick Vaucher @ Camptocamp (yvaucher-c2c) wrote :

martin, can you change your status here ? Or is it intended to let it in Needs Fixing ?

Revision history for this message
martin (riess82) :
review: Approve
Revision history for this message
Yannick Vaucher @ Camptocamp (yvaucher-c2c) wrote :

LGTM

review: Approve (code review, no test)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'purchase_group_hooks'
2=== added file 'purchase_group_hooks/__init__.py'
3--- purchase_group_hooks/__init__.py 1970-01-01 00:00:00 +0000
4+++ purchase_group_hooks/__init__.py 2014-04-29 08:56:20 +0000
5@@ -0,0 +1,22 @@
6+# -*- coding: utf-8 -*-
7+##############################################################################
8+#
9+# Author: Leonardo Pistone
10+# Copyright 2014 Camptocamp SA
11+#
12+# This program is free software: you can redistribute it and/or modify
13+# it under the terms of the GNU Affero General Public License as
14+# published by the Free Software Foundation, either version 3 of the
15+# License, or (at your option) any later version.
16+#
17+# This program is distributed in the hope that it will be useful,
18+# but WITHOUT ANY WARRANTY; without even the implied warranty of
19+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20+# GNU Affero General Public License for more details.
21+#
22+# You should have received a copy of the GNU Affero General Public License
23+# along with this program. If not, see <http://www.gnu.org/licenses/>.
24+#
25+##############################################################################
26+
27+from . import purchase_group_hooks # noqa
28
29=== added file 'purchase_group_hooks/__openerp__.py'
30--- purchase_group_hooks/__openerp__.py 1970-01-01 00:00:00 +0000
31+++ purchase_group_hooks/__openerp__.py 2014-04-29 08:56:20 +0000
32@@ -0,0 +1,47 @@
33+# -*- coding: utf-8 -*-
34+##############################################################################
35+#
36+# Author: Leonardo Pistone
37+# Copyright 2014 Camptocamp SA
38+#
39+# This program is free software: you can redistribute it and/or modify
40+# it under the terms of the GNU Affero General Public License as
41+# published by the Free Software Foundation, either version 3 of the
42+# License, or (at your option) any later version.
43+#
44+# This program is distributed in the hope that it will be useful,
45+# but WITHOUT ANY WARRANTY; without even the implied warranty of
46+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
47+# GNU Affero General Public License for more details.
48+#
49+# You should have received a copy of the GNU Affero General Public License
50+# along with this program. If not, see <http://www.gnu.org/licenses/>.
51+#
52+##############################################################################
53+{'name': 'Add hooks to the merge PO feature.',
54+ 'version': '0.1',
55+ 'author': 'Camptocamp',
56+ 'maintainer': 'Camptocamp',
57+ 'category': 'Purchase Management',
58+ 'complexity': "normal",
59+ 'depends': ['purchase'],
60+ 'description': """
61+ In the core OpenERP purchase module, there is a wizard to merge purchase
62+ orders. That feature is convenient, but as soon as a field is added to the
63+ purchase order, it does not work anymore and needs to be patched.
64+ The original implementation does not provide any hooks for extension, and
65+ modules can only reimplement a method completely. This required a lot of copy
66+ and paste, and worse, it breaks if two modules attempt to do that.
67+
68+ Therefore, this module reimplements the feature, with the same basic result
69+ in the standard case. Hooks are provided for extra modules that add fields
70+ or change the logic.
71+ """,
72+ 'website': 'http://www.camptocamp.com/',
73+ 'data': [],
74+ 'installable': True,
75+ 'auto_install': False,
76+ 'license': 'AGPL-3',
77+ 'application': False,
78+ 'test': ['test/merge_order.yml'],
79+ }
80
81=== added file 'purchase_group_hooks/purchase_group_hooks.py'
82--- purchase_group_hooks/purchase_group_hooks.py 1970-01-01 00:00:00 +0000
83+++ purchase_group_hooks/purchase_group_hooks.py 2014-04-29 08:56:20 +0000
84@@ -0,0 +1,236 @@
85+# -*- coding: utf-8 -*-
86+##############################################################################
87+#
88+# Author: Leonardo Pistone
89+# Copyright 2014 Camptocamp SA
90+#
91+# This program is free software: you can redistribute it and/or modify
92+# it under the terms of the GNU Affero General Public License as
93+# published by the Free Software Foundation, either version 3 of the
94+# License, or (at your option) any later version.
95+#
96+# This program is distributed in the hope that it will be useful,
97+# but WITHOUT ANY WARRANTY; without even the implied warranty of
98+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
99+# GNU Affero General Public License for more details.
100+#
101+# You should have received a copy of the GNU Affero General Public License
102+# along with this program. If not, see <http://www.gnu.org/licenses/>.
103+#
104+##############################################################################
105+
106+from openerp.osv.orm import Model
107+from openerp import netsvc
108+from openerp.osv.orm import browse_record, browse_null
109+
110+
111+class PurchaseOrder(Model):
112+ _inherit = 'purchase.order'
113+
114+ def _key_fields_for_grouping(self):
115+ """Return a list of fields used to identify orders that can be merged.
116+
117+ Orders that have this fields equal can be merged.
118+
119+ This function can be extended by other modules to modify the list.
120+ """
121+ return ('partner_id', 'location_id', 'pricelist_id')
122+
123+ def _key_fields_for_grouping_lines(self):
124+ """Return a list of fields used to identify order lines that can be
125+ merged.
126+
127+ Lines that have this fields equal can be merged.
128+
129+ This function can be extended by other modules to modify the list.
130+ """
131+ return ('name', 'date_planned', 'taxes_id', 'price_unit', 'product_id',
132+ 'move_dest_id', 'account_analytic_id')
133+
134+ def _make_key_for_grouping(self, order, fields):
135+ """From an order, return a tuple to be used as a key.
136+
137+ If two orders have the same key, they can be merged.
138+ """
139+ key_list = []
140+ for field in fields:
141+ field_value = getattr(order, field)
142+
143+ if isinstance(field_value, browse_record):
144+ field_value = field_value.id
145+ elif isinstance(field_value, browse_null):
146+ field_value = False
147+ elif isinstance(field_value, list):
148+ field_value = ((6, 0, tuple([v.id for v in field_value])),)
149+ key_list.append((field, field_value))
150+ key_list.sort()
151+ return tuple(key_list)
152+
153+ def _can_merge(self, order):
154+ """Can the order be considered for merging with others?
155+
156+ This method can be surcharged in other modules.
157+ """
158+ return order.state == 'draft'
159+
160+ def _initial_merged_order_data(self, order):
161+ """Build the initial values of a merged order."""
162+ return {
163+ 'origin': order.origin,
164+ 'date_order': order.date_order,
165+ 'partner_id': order.partner_id.id,
166+ 'dest_address_id': order.dest_address_id.id,
167+ 'warehouse_id': order.warehouse_id.id,
168+ 'location_id': order.location_id.id,
169+ 'pricelist_id': order.pricelist_id.id,
170+ 'state': 'draft',
171+ 'order_line': {},
172+ 'notes': '%s' % (order.notes or '',),
173+ 'fiscal_position': (
174+ order.fiscal_position and order.fiscal_position.id or False
175+ ),
176+ }
177+
178+ def _update_merged_order_data(self, merged_data, order):
179+ if order.date_order < merged_data['date_order']:
180+ merged_data['date_order'] = order.date_order
181+ if order.notes:
182+ merged_data['notes'] = (
183+ (merged_data['notes'] or '') + ('\n%s' % (order.notes,))
184+ )
185+ if order.origin:
186+ if (
187+ order.origin not in merged_data['origin']
188+ and merged_data['origin'] not in order.origin
189+ ):
190+ merged_data['origin'] = (
191+ (merged_data['origin'] or '') + ' ' + order.origin
192+ )
193+ return merged_data
194+
195+ def _group_orders(self, input_orders):
196+ """Return a dictionary where each element is in the form:
197+
198+ tuple_key: (dict_of_new_order_data, list_of_old_order_ids)
199+
200+ """
201+ key_fields = self._key_fields_for_grouping()
202+ grouped_orders = {}
203+
204+ if len(input_orders) < 2:
205+ return {}
206+
207+ for input_order in input_orders:
208+ key = self._make_key_for_grouping(input_order, key_fields)
209+ if key in grouped_orders:
210+ grouped_orders[key] = (
211+ self._update_merged_order_data(
212+ grouped_orders[key][0],
213+ input_order
214+ ),
215+ grouped_orders[key][1] + [input_order.id]
216+ )
217+ else:
218+ grouped_orders[key] = (
219+ self._initial_merged_order_data(input_order),
220+ [input_order.id]
221+ )
222+ grouped_order_data = grouped_orders[key][0]
223+
224+ for input_line in input_order.order_line:
225+ line_key = self._make_key_for_grouping(
226+ input_line,
227+ self._key_fields_for_grouping_lines()
228+ )
229+ o_line = grouped_order_data['order_line'].setdefault(
230+ line_key, {}
231+ )
232+ if o_line:
233+ # merge the line with an existing line
234+ o_line['product_qty'] += (
235+ input_line.product_qty
236+ * input_line.product_uom.factor
237+ / o_line['uom_factor']
238+ )
239+ else:
240+ # append a new "standalone" line
241+ for field in ('product_qty', 'product_uom'):
242+ field_val = getattr(input_line, field)
243+ if isinstance(field_val, browse_record):
244+ field_val = field_val.id
245+ o_line[field] = field_val
246+ o_line['uom_factor'] = (
247+ input_line.product_uom
248+ and input_line.product_uom.factor
249+ or 1.0)
250+
251+ return self._cleanup_merged_line_data(grouped_orders)
252+
253+ def _cleanup_merged_line_data(self, grouped_orders):
254+ """Remove keys from merged lines, and merges of 1 order."""
255+ result = {}
256+ for order_key, (order_data, old_ids) in grouped_orders.iteritems():
257+ if len(old_ids) > 1:
258+ for key, value in order_data['order_line'].iteritems():
259+ del value['uom_factor']
260+ value.update(dict(key))
261+ order_data['order_line'] = [
262+ (0, 0, value)
263+ for value in order_data['order_line'].itervalues()
264+ ]
265+ result[order_key] = (order_data, old_ids)
266+ return result
267+
268+ def _create_new_orders(self, cr, uid, grouped_orders, context=None):
269+ """Create the new merged orders in the database.
270+
271+ Return a dictionary that puts the created order ids in relation to the
272+ original ones, in the form
273+
274+ new_order_id: [old_order_1_id, old_order_2_id]
275+
276+ """
277+ new_old_rel = {}
278+ for key in grouped_orders:
279+ new_order_data, old_order_ids = grouped_orders[key]
280+ new_id = self.create(cr, uid, new_order_data, context=context)
281+ new_old_rel[new_id] = old_order_ids
282+ return new_old_rel
283+
284+ def _fix_workflow(self, cr, uid, new_old_rel):
285+ """Fix the workflow of the old and new orders.
286+
287+ Specifically, cancel the old ones and assign workflows to the new ones.
288+
289+ """
290+ wf_service = netsvc.LocalService("workflow")
291+ for new_order_id in new_old_rel:
292+ old_order_ids = new_old_rel[new_order_id]
293+ for old_id in old_order_ids:
294+ wf_service.trg_redirect(uid, 'purchase.order', old_id,
295+ new_order_id, cr)
296+ wf_service.trg_validate(uid, 'purchase.order', old_id,
297+ 'purchase_cancel', cr)
298+
299+ def do_merge(self, cr, uid, input_order_ids, context=None):
300+ """Merge Purchase Orders.
301+
302+ This method replaces the original one in the purchase module because
303+ it did not provide any hooks for customization.
304+
305+ Receive a list of order ids, and return a dictionary where each
306+ element is in the form:
307+
308+ new_order_id: [old_order_1_id, old_order_2_id]
309+
310+ New orders are created, and old orders are deleted.
311+
312+ """
313+ input_orders = self.browse(cr, uid, input_order_ids, context=context)
314+ mergeable_orders = filter(self._can_merge, input_orders)
315+ grouped_orders = self._group_orders(mergeable_orders)
316+
317+ new_old_rel = self._create_new_orders(cr, uid, grouped_orders,
318+ context=context)
319+ self._fix_workflow(cr, uid, new_old_rel)
320+ return new_old_rel
321
322=== added directory 'purchase_group_hooks/test'
323=== added file 'purchase_group_hooks/test/merge_order.yml'
324--- purchase_group_hooks/test/merge_order.yml 1970-01-01 00:00:00 +0000
325+++ purchase_group_hooks/test/merge_order.yml 2014-04-29 08:56:20 +0000
326@@ -0,0 +1,41 @@
327+-
328+ In order to merge RFQ, I merge two RFQ which has same supplier and check new merged order.
329+-
330+ !python {model: purchase.order}: |
331+ new_id = self.do_merge(cr, uid, [ref('purchase.purchase_order_4'), ref('purchase.purchase_order_7')])
332+ order4 = self.browse(cr, uid, ref('purchase.purchase_order_4'))
333+ order7 = self.browse(cr, uid, ref('purchase.purchase_order_7'))
334+ total_qty = sum([x.product_qty for x in order4.order_line] + [x.product_qty for x in order7.order_line])
335+
336+ assert order4.state == 'cancel', "Merged order should be canceled"
337+ assert order7.state == 'cancel', "Merged order should be canceled"
338+
339+ def merged_data(lines):
340+ product_id =[]
341+ product_uom = []
342+ res = {}
343+ for line in lines:
344+ product_id.append(line.product_id.id)
345+ product_uom.append(line.product_uom.id)
346+ res.update({'product_ids': product_id,'product_uom':product_uom})
347+ return res
348+
349+ for order in self.browse(cr, uid, new_id.keys()):
350+ total_new_qty = [x.product_qty for x in order.order_line]
351+ total_new_qty = sum(total_new_qty)
352+
353+ assert total_new_qty == total_qty,"product quantities are not correspond: {} != {}".format(total_new_qty, total_qty)
354+ assert order.partner_id == order4.partner_id ,"partner is not correspond"
355+ assert order.warehouse_id == order4.warehouse_id or order7.warehouse_id,"Warehouse is not correspond"
356+ assert order.state == 'draft',"New created order state should be in draft"
357+ assert order.pricelist_id == order4.pricelist_id,"Price list is not correspond"
358+ assert order.date_order == order4.date_order ,"Date of order is not correspond"
359+ assert order.location_id == order4.location_id ,"Location is not correspond"
360+ n_product_data = merged_data(order.order_line)
361+ o_product_data= merged_data(order4.order_line)
362+ o_pro_data = merged_data(order7.order_line)
363+
364+ assert n_product_data == o_product_data or o_pro_data,"product data are not correspond"
365+
366+
367+
368
369=== added directory 'purchase_group_hooks/tests'
370=== added file 'purchase_group_hooks/tests/__init__.py'
371--- purchase_group_hooks/tests/__init__.py 1970-01-01 00:00:00 +0000
372+++ purchase_group_hooks/tests/__init__.py 2014-04-29 08:56:20 +0000
373@@ -0,0 +1,26 @@
374+# -*- coding: utf-8 -*-
375+##############################################################################
376+#
377+# Author: Leonardo Pistone
378+# Copyright 2014 Camptocamp SA
379+#
380+# This program is free software: you can redistribute it and/or modify
381+# it under the terms of the GNU Affero General Public License as
382+# published by the Free Software Foundation, either version 3 of the
383+# License, or (at your option) any later version.
384+#
385+# This program is distributed in the hope that it will be useful,
386+# but WITHOUT ANY WARRANTY; without even the implied warranty of
387+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
388+# GNU Affero General Public License for more details.
389+#
390+# You should have received a copy of the GNU Affero General Public License
391+# along with this program. If not, see <http://www.gnu.org/licenses/>.
392+#
393+##############################################################################
394+
395+from . import test_merge_po
396+
397+checks = [
398+ test_merge_po,
399+]
400
401=== added file 'purchase_group_hooks/tests/test_merge_po.py'
402--- purchase_group_hooks/tests/test_merge_po.py 1970-01-01 00:00:00 +0000
403+++ purchase_group_hooks/tests/test_merge_po.py 2014-04-29 08:56:20 +0000
404@@ -0,0 +1,76 @@
405+from mock import Mock
406+
407+from openerp.tests.common import BaseCase
408+from openerp.osv.orm import browse_record
409+
410+
411+class TestGroupOrders(BaseCase):
412+
413+ def setUp(self):
414+ super(TestGroupOrders, self).setUp()
415+ self.order1 = Mock()
416+ self.order2 = Mock()
417+
418+ self.order1.order_line = self.order2.order_line = []
419+ self.order1.origin = self.order2.origin = ''
420+ self.order1.notes = self.order2.notes = ''
421+
422+ # I have to use the registry to get an instance of a model. I cannot
423+ # use the class constructor because that is modified to return nothing.
424+ self.po = self.registry('purchase.order')
425+
426+ def test_no_orders(self):
427+ """Group an empty list of orders as an empty dictionary."""
428+
429+ grouped = self.po._group_orders([])
430+ self.assertEquals(grouped, {})
431+
432+ def test_one_order(self):
433+ """A single order will not be grouped."""
434+ grouped = self.po._group_orders([self.order1])
435+ self.assertEquals(grouped, {})
436+
437+ def test_two_similar_orders(self):
438+ """Two orders with the right conditions can be merged.
439+
440+ We do not care about the order lines here.
441+ """
442+ self.order1.partner_id = self.order2.partner_id = Mock(
443+ spec=browse_record, id=1)
444+ self.order1.location_id = self.order2.location_id = Mock(
445+ spec=browse_record, id=2)
446+ self.order1.pricelist_id = self.order2.pricelist_id = Mock(
447+ spec=browse_record, id=3)
448+
449+ self.order1.id = 51
450+ self.order2.id = 52
451+
452+ grouped = self.po._group_orders([self.order1, self.order2])
453+ expected_key = (('location_id', 2), ('partner_id', 1),
454+ ('pricelist_id', 3))
455+ self.assertEquals(grouped.keys(), [expected_key])
456+ self.assertEquals(grouped[expected_key][1], [51, 52])
457+
458+ def test_merge_origin_and_notes(self):
459+ self.order1.origin = 'ORIGIN1'
460+ self.order2.origin = 'ORIGIN2'
461+
462+ self.order1.notes = 'Notes1'
463+ self.order2.notes = 'Notes2'
464+
465+ self.order1.partner_id = self.order2.partner_id = Mock(
466+ spec=browse_record, id=1)
467+ self.order1.location_id = self.order2.location_id = Mock(
468+ spec=browse_record, id=2)
469+ self.order1.pricelist_id = self.order2.pricelist_id = Mock(
470+ spec=browse_record, id=3)
471+
472+ grouped = self.po._group_orders([self.order1, self.order2])
473+
474+ expected_key = (('location_id', 2), ('partner_id', 1),
475+ ('pricelist_id', 3))
476+
477+ merged_data = grouped[expected_key][0]
478+
479+ self.assertEquals(merged_data['origin'], 'ORIGIN1 ORIGIN2')
480+ self.assertEquals(merged_data['notes'], 'Notes1\nNotes2')

Subscribers

People subscribed via source and target branches