Merge lp:~camptocamp/purchase-wkfl/7.0-add_framework_agreement-nbi into lp:~purchase-core-editors/purchase-wkfl/7.0

Proposed by Nicolas Bessi - Camptocamp
Status: Merged
Merged at revision: 24
Proposed branch: lp:~camptocamp/purchase-wkfl/7.0-add_framework_agreement-nbi
Merge into: lp:~purchase-core-editors/purchase-wkfl/7.0
Diff against target: 2182 lines (+2066/-0)
22 files modified
framework_agreement/__init__.py (+22/-0)
framework_agreement/__openerp__.py (+67/-0)
framework_agreement/data.xml (+19/-0)
framework_agreement/model/__init__.py (+25/-0)
framework_agreement/model/company.py (+32/-0)
framework_agreement/model/framework_agreement.py (+734/-0)
framework_agreement/model/pricelist.py (+80/-0)
framework_agreement/model/product.py (+40/-0)
framework_agreement/model/purchase.py (+155/-0)
framework_agreement/security/ir.model.access.csv (+5/-0)
framework_agreement/security/multicompany.xml (+11/-0)
framework_agreement/tests/__init__.py (+30/-0)
framework_agreement/tests/common.py (+97/-0)
framework_agreement/tests/test_framework_agreement_consumed_qty.py (+74/-0)
framework_agreement/tests/test_framework_agreement_dates_and_constraints.py (+135/-0)
framework_agreement/tests/test_framework_agreement_on_change.py (+166/-0)
framework_agreement/tests/test_framework_agreement_price_list.py (+73/-0)
framework_agreement/utils.py (+29/-0)
framework_agreement/view/company_view.xml (+17/-0)
framework_agreement/view/framework_agreement_view.xml (+118/-0)
framework_agreement/view/product_view.xml (+86/-0)
framework_agreement/view/purchase_view.xml (+51/-0)
To merge this branch: bzr merge lp:~camptocamp/purchase-wkfl/7.0-add_framework_agreement-nbi
Reviewer Review Type Date Requested Status
Romain Deheele - Camptocamp (community) code review Approve
Joël Grand-Guillaume @ camptocamp code review + test Approve
Review via email: mp+196681@code.launchpad.net

Description of the change

Add framework agreement

To post a comment you must log in.
33. By Nicolas Bessi - Camptocamp

[FIX] PO onchange

34. By Nicolas Bessi - Camptocamp

[FIX] sequence.get is deprecated

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

Hello,

I see 3 points:

- integrity error on framework agreement deletion

- import pdb in def open_agreement (maybe voluntary)

- change price in a FA doesn't change price in existing purchase order lines, is it voluntary?

Romain

review: Needs Information (code review, tests)
35. By Nicolas Bessi - Camptocamp

[RM] forgotten pdb

Revision history for this message
Nicolas Bessi - Camptocamp (nbessi-c2c-deactivatedaccount) wrote :

Hello,

thanks for the review

>
> I see 3 points:
>
> - integrity error on framework agreement deletion
As discussed a better message will be nice but nice to have.
>
> - import pdb in def open_agreement (maybe voluntary)>
Fixed
> - change price in a FA doesn't change price in existing purchase order lines,
> is it voluntary?
Yes but we should add a warning on change of price if related to a PO

Regards

Nicolas

36. By Nicolas Bessi - Camptocamp

[IMP] better docstring

37. By Nicolas Bessi - Camptocamp

[ADD] missing security access

38. By Nicolas Bessi - Camptocamp

[FIX] open agreement button attrs

39. By Nicolas Bessi - Camptocamp

[FIX] available quantity that was buggy because of 3 reasons:
First the related field on po_line po was not updated
Second There was missind an and close on product_id
Third we do not manage correct trigger when we remove LTA from a PO action that was first impossible

40. By Nicolas Bessi - Camptocamp

[FIX] abusive consumption of agreement sequence

41. By Romain Deheele - Camptocamp

[MRG] missing domain on supplier field

Revision history for this message
Joël Grand-Guillaume @ camptocamp (jgrandguillaume-c2c) wrote :

Hi,

This LGTM. I just have the little remark I already made:

 * Adding a LTA on a product should add a supplierinfo entry as well.

A part from that, LGTM. I don't want to block it for that. Just to let you know.

Regards,

review: Approve (code review + test)
Revision history for this message
Romain Deheele - Camptocamp (romaindeheele) wrote :

LGTM,

Regards,

Romain

review: Approve (code review)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added directory 'framework_agreement'
2=== added file 'framework_agreement/__init__.py'
3--- framework_agreement/__init__.py 1970-01-01 00:00:00 +0000
4+++ framework_agreement/__init__.py 2014-02-06 10:10:57 +0000
5@@ -0,0 +1,22 @@
6+# -*- coding: utf-8 -*-
7+##############################################################################
8+#
9+# Author: Nicolas Bessi
10+# Copyright 2013 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+from . import model
27+from . import utils
28
29=== added file 'framework_agreement/__openerp__.py'
30--- framework_agreement/__openerp__.py 1970-01-01 00:00:00 +0000
31+++ framework_agreement/__openerp__.py 2014-02-06 10:10:57 +0000
32@@ -0,0 +1,67 @@
33+# -*- coding: utf-8 -*-
34+##############################################################################
35+#
36+# Author: Nicolas Bessi
37+# Copyright 2013 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': 'Simple Framework Agreement',
54+ 'version': '0.1',
55+ 'author': 'Camptocamp',
56+ 'maintainer': 'Camptocamp',
57+ 'category': 'Purchase Management',
58+ 'complexity': 'normal',
59+ 'depends': ['stock', 'procurement', 'purchase'],
60+ 'description': """
61+Long Term Agreement (or Framework Agreement) on price.
62+======================================================
63+
64+Agreements are defined by a product, a date range , a supplier, a price, a lead time
65+and agreed quantity.
66+
67+Agreements are set on a product view or using a menu in the product configuration.
68+
69+There can be only one agreement for the same supplier and product at the same time, even
70+if we may have different prices depending on lead time/qty.
71+
72+There is an option on company to restrict one agreement per product at same time.
73+
74+If an agreement is running its price will be automatically used in PO.
75+A warning will be raised in case of exhaustion of override of agreement price.
76+
77+**Technical aspect**
78+
79+The module provide an observalbe mixin to enable generic on_change management on various model
80+related to agreements.
81+
82+The framework agreement is by default related to purchase order but the addon
83+provides a library to integrate it with any other model easily
84+""",
85+ 'website': 'http://www.camptocamp.com',
86+ 'data': ['data.xml',
87+ 'view/product_view.xml',
88+ 'view/framework_agreement_view.xml',
89+ 'view/purchase_view.xml',
90+ 'view/company_view.xml',
91+ 'security/multicompany.xml',
92+ 'security/ir.model.access.csv'],
93+ 'demo': [],
94+ 'test': [],
95+ 'installable': True,
96+ 'auto_install': False,
97+ 'license': 'AGPL-3',
98+ 'application': False,
99+ }
100
101=== added file 'framework_agreement/data.xml'
102--- framework_agreement/data.xml 1970-01-01 00:00:00 +0000
103+++ framework_agreement/data.xml 2014-02-06 10:10:57 +0000
104@@ -0,0 +1,19 @@
105+<?xml version="1.0" encoding="utf-8"?>
106+<openerp>
107+ <data>
108+ <record id="framework_agreement_sequence_type" model="ir.sequence.type">
109+ <field name="name">Framework Agreement</field>
110+ <field name="code">framework.agreement</field>
111+ </record>
112+ <record id="seq_mrp_repair" model="ir.sequence">
113+ <field name="name">framework.agreement</field>
114+ <field name="code">framework.agreement</field>
115+ <field name="prefix">LTA</field>
116+ </record>
117+
118+ <record id="framework_agreement_currency_type" model="res.currency.rate.type">
119+ <field name="name">Framework Agreement</field>
120+ </record>
121+
122+ </data>
123+</openerp>
124
125=== added directory 'framework_agreement/i18n'
126=== added directory 'framework_agreement/model'
127=== added file 'framework_agreement/model/__init__.py'
128--- framework_agreement/model/__init__.py 1970-01-01 00:00:00 +0000
129+++ framework_agreement/model/__init__.py 2014-02-06 10:10:57 +0000
130@@ -0,0 +1,25 @@
131+# -*- coding: utf-8 -*-
132+##############################################################################
133+#
134+# Author: Nicolas Bessi
135+# Copyright 2013 Camptocamp SA
136+#
137+# This program is free software: you can redistribute it and/or modify
138+# it under the terms of the GNU Affero General Public License as
139+# published by the Free Software Foundation, either version 3 of the
140+# License, or (at your option) any later version.
141+#
142+# This program is distributed in the hope that it will be useful,
143+# but WITHOUT ANY WARRANTY; without even the implied warranty of
144+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
145+# GNU Affero General Public License for more details.
146+#
147+# You should have received a copy of the GNU Affero General Public License
148+# along with this program. If not, see <http://www.gnu.org/licenses/>.
149+#
150+##############################################################################
151+from . import pricelist
152+from . import product
153+from . import framework_agreement
154+from . import purchase
155+from . import company
156
157=== added file 'framework_agreement/model/company.py'
158--- framework_agreement/model/company.py 1970-01-01 00:00:00 +0000
159+++ framework_agreement/model/company.py 2014-02-06 10:10:57 +0000
160@@ -0,0 +1,32 @@
161+# -*- coding: utf-8 -*-
162+##############################################################################
163+#
164+# Author: Nicolas Bessi
165+# Copyright 2013 Camptocamp SA
166+#
167+# This program is free software: you can redistribute it and/or modify
168+# it under the terms of the GNU Affero General Public License as
169+# published by the Free Software Foundation, either version 3 of the
170+# License, or (at your option) any later version.
171+#
172+# This program is distributed in the hope that it will be useful,
173+# but WITHOUT ANY WARRANTY; without even the implied warranty of
174+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
175+# GNU Affero General Public License for more details.
176+#
177+# You should have received a copy of the GNU Affero General Public License
178+# along with this program. If not, see <http://www.gnu.org/licenses/>.
179+#
180+##############################################################################
181+from openerp.osv import orm, fields
182+
183+
184+class res_Company(orm.Model):
185+ """Add a field on company"""
186+
187+ _inherit = "res.company"
188+ _columns = {'one_agreement_per_product': fields.boolean('One agreement per product',
189+ help='If checked you can have only'
190+ ' one framework agreement '
191+ ' per product at the same time')}
192+ # TODO add check on activation deactivation of check box
193
194=== added file 'framework_agreement/model/framework_agreement.py'
195--- framework_agreement/model/framework_agreement.py 1970-01-01 00:00:00 +0000
196+++ framework_agreement/model/framework_agreement.py 2014-02-06 10:10:57 +0000
197@@ -0,0 +1,734 @@
198+# -*- coding: utf-8 -*-
199+##############################################################################
200+#
201+# Author: Nicolas Bessi
202+# Copyright 2013 Camptocamp SA
203+#
204+# This program is free software: you can redistribute it and/or modify
205+# it under the terms of the GNU Affero General Public License as
206+# published by the Free Software Foundation, either version 3 of the
207+# License, or (at your option) any later version.
208+#
209+# This program is distributed in the hope that it will be useful,
210+# but WITHOUT ANY WARRANTY; without even the implied warranty of
211+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
212+# GNU Affero General Public License for more details.
213+#
214+# You should have received a copy of the GNU Affero General Public License
215+# along with this program. If not, see <http://www.gnu.org/licenses/>.
216+#
217+##############################################################################
218+from operator import attrgetter
219+from collections import namedtuple
220+from datetime import datetime
221+from openerp.osv import orm, fields
222+from openerp.osv.orm import except_orm
223+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
224+from openerp.tools.translate import _
225+import openerp.addons.decimal_precision as dp
226+
227+AGR_PO_STATE = ('confirmed', 'approved',
228+ 'done', 'except_picking', 'except_invoice')
229+
230+
231+class framework_agreement(orm.Model):
232+ """Long term agreement on product price with a supplier"""
233+
234+ _name = 'framework.agreement'
235+ _description = 'Agreement on price'
236+
237+ def _check_running_date(self, cr, agreement, context=None):
238+ """ Returns agreement state based on date.
239+
240+ Available qty is ignored in this method
241+
242+ :param agreement: an agreement record
243+
244+ :returns: a string - "running" if now is between,
245+ - "future" if agreement is in future,
246+ - "closed" if agreement is outdated
247+
248+ """
249+ now, start, end = self._get_dates(agreement, context=context)
250+ if start > now:
251+ return 'future'
252+ elif end < now:
253+ return 'closed'
254+ elif start <= now <= end:
255+ return 'running'
256+ else:
257+ raise ValueError('Agreement start/end dates are incorrect')
258+
259+ def _get_dates(self, agreement, context=None):
260+ """Return current time, start date and end date of agreement
261+
262+ Boiler plate as OpenERP returns string instead of date/time objects...
263+
264+ :param agreement: agreement record
265+
266+ :returns: (now, start, end)
267+
268+ """
269+ AGDates = namedtuple('AGDates', ['now', 'start', 'end'])
270+ now = datetime.strptime(fields.date.today(),
271+ DEFAULT_SERVER_DATE_FORMAT)
272+ start = datetime.strptime(agreement.start_date,
273+ DEFAULT_SERVER_DATE_FORMAT)
274+ end = datetime.strptime(agreement.end_date,
275+ DEFAULT_SERVER_DATE_FORMAT)
276+ return AGDates(now, start, end)
277+
278+ def date_valid(self, cr, uid, agreement_id, date, context=None):
279+ """Predicate that checks that date is in agreement
280+
281+ :param date: date to validate
282+
283+ :returns: True if date is valid
284+
285+ """
286+
287+ if isinstance(agreement_id, (list, tuple)):
288+ assert len(agreement_id) == 1
289+ agreement_id = agreement_id[0]
290+ current = self.browse(cr, uid, agreement_id, context=context)
291+ now, start, end = self._get_dates(current, context=context)
292+ pdate = datetime.strptime(date,
293+ DEFAULT_SERVER_DATE_FORMAT)
294+ return start <= pdate <= end
295+
296+ def _get_self(self, cr, uid, ids, context=None):
297+ """ Store field function to get current ids
298+
299+ :returns: list of current ids
300+
301+ """
302+ return ids
303+
304+ def _compute_state(self, cr, uid, ids, field_name, arg, context=None):
305+ """ Compute current state of agreement based on date and consumption
306+
307+ Please refer to function field documentation for more details.
308+
309+ """
310+ res = {}
311+ for agreement in self.browse(cr, uid, ids, context=context):
312+ if (agreement.draft or not agreement.start_date or
313+ not agreement.end_date):
314+ res[agreement.id] = 'draft'
315+ continue
316+ dates_state = self._check_running_date(cr, agreement,
317+ context=context)
318+ if dates_state == 'running':
319+ if agreement.available_quantity <= 0:
320+ res[agreement.id] = 'consumed'
321+ else:
322+ res[agreement.id] = 'running'
323+ else:
324+ res[agreement.id] = dates_state
325+ return res
326+
327+ def _search_state(self, cr, uid, obj, name, args, context=None):
328+ """Implement search on state function field.
329+
330+ Only support "and" mode.
331+ supported opperators are =, in, not in, <>.
332+ For more information please refer to fnct_search OpenERP documentation.
333+
334+ """
335+ if not args:
336+ return []
337+ ids = self.search(cr, uid, [], context=context)
338+ # this can be problematic in term of performace but the
339+ # state field can be changed by values and time evolution
340+ # In a business point of view there should be around 30 yearly LTA
341+
342+ found_ids = []
343+ res = self.read(cr, uid, ids, ['state'], context=context)
344+ for field, operator, value in args:
345+ assert field == name
346+ if operator == '=':
347+ found_ids += [frm['id'] for frm in res if frm['state'] in value]
348+ elif operator == 'in' and isinstance(value, list):
349+ found_ids += [frm['id'] for frm in res if frm['state'] in value]
350+ elif operator in ("!=", "<>"):
351+ found_ids += [frm['id'] for frm in res if frm['state'] != value]
352+ elif operator == 'not in'and isinstance(value, list):
353+ found_ids += [frm['id'] for frm in res if frm['state'] not in value]
354+ else:
355+ raise NotImplementedError('Search operator %s not implemented'
356+ ' for value %s'
357+ % (operator, value))
358+ to_return = set(found_ids)
359+ return [('id', 'in', [x['id'] for x in to_return])]
360+
361+ def _compute_available_qty(self, cr, uid, ids, field_name, arg,
362+ context=None):
363+ """Compute available qty of current agreements.
364+
365+ Consumption is based on confirmed po lines.
366+ Please refer to function field documentation for more details.
367+
368+ """
369+ company_id = self._company_get(cr, uid, context=None)
370+ res = {}
371+ for agreement in self.browse(cr, uid, ids, context=context):
372+ sql = """SELECT SUM(po_line.product_qty) FROM purchase_order_line AS po_line
373+ LEFT JOIN purchase_order AS po ON po_line.order_id = po.id
374+ WHERE po_line.framework_agreement_id = %s
375+ AND po_line.product_id = %s
376+ AND po.partner_id = %s
377+ AND po.state IN %s
378+ AND po.company_id = %s"""
379+ cr.execute(sql, (agreement.id,
380+ agreement.product_id.id,
381+ agreement.supplier_id.id,
382+ AGR_PO_STATE,
383+ company_id))
384+ amount = cr.fetchone()[0]
385+ if amount is None:
386+ amount = 0
387+ res[agreement.id] = agreement.quantity - amount
388+ return res
389+
390+ def _get_available_qty(self, cr, uid, ids, field_name, arg, context=None):
391+ """Compute available qty of current agreements.
392+
393+ Consumption is based on confirmed po lines.
394+ Please refer to function field documentation for more details.
395+
396+ """
397+ return self._compute_available_qty(cr, uid, ids, field_name, arg,
398+ context=context)
399+
400+ def _get_state(self, cr, uid, ids, field_name, arg, context=None):
401+ """ Compute current state of agreement based on date and consumption
402+
403+ Please refer to function field documentation for more details.
404+
405+ """
406+ return self._compute_state(cr, uid, ids, field_name, arg,
407+ context=context)
408+
409+ def open_agreement(self, cr, uid, ids, context=None):
410+ """Open agreement
411+
412+ Agreement goes from state draft to X
413+
414+ """
415+ if isinstance(ids, (int, long)):
416+ ids = [ids]
417+ for agr in self.browse(cr, uid, ids, context=context):
418+ mandatory = [agr.start_date,
419+ agr.end_date,
420+ agr.framework_agreement_pricelist_id]
421+ if not all(mandatory):
422+ raise orm.except_orm(_('Data are missing'),
423+ _('Please enter dates'
424+ ' and price informations'))
425+ self.write(cr, uid, ids, {'draft': False}, context=context)
426+
427+ def _get_po_store(self, cr, uid, ids, context=None):
428+ res = set()
429+ agr_obj = self.pool['framework.agreement']
430+ po_obj = self.pool['purchase.order']
431+ for row in po_obj.browse(cr, uid, ids, context=context):
432+ if row.framework_agreement_id:
433+ res.update([row.framework_agreement_id.id])
434+ else:
435+ product_ids = [x.product_id.id for x in row.order_line
436+ if x.product_id]
437+ f_ids = agr_obj.search(cr, uid,
438+ [('product_id', 'in', product_ids)],
439+ context=context)
440+ res.update(f_ids)
441+
442+ return res
443+
444+ def _get_po_line_store(self, cr, uid, ids, context=None):
445+ # TODO DRY with _get_po_store
446+ res = set()
447+ pol_obj = self.pool.get('purchase.order.line')
448+ for row in pol_obj.browse(cr, uid, ids, context=context):
449+ if row.framework_agreement_id:
450+ res.update([row.framework_agreement_id.id])
451+ return res
452+
453+ _store_tuple = (lambda self, cr, uid, ids, c={}: ids, ['quantity'], 10)
454+ _po_store_tuple = (_get_po_store, ['framework_agreement_id', 'state'], 20)
455+ _po_line_store_tuple = (_get_po_line_store, [], 20)
456+
457+ _columns = {'name': fields.char('Number',
458+ readonly=True),
459+ 'supplier_id': fields.many2one('res.partner',
460+ 'Supplier',
461+ required=True),
462+ 'product_id': fields.many2one('product.product',
463+ 'Product',
464+ required=True),
465+ 'origin': fields.char('Origin'),
466+ 'start_date': fields.date('Begin of Agreement'),
467+ 'end_date': fields.date('End of Agreement'),
468+ 'delay': fields.integer('Lead time in days'),
469+ 'quantity': fields.integer('Negociated quantity',
470+ required=True),
471+ 'framework_agreement_pricelist_ids': fields.one2many('framework.agreement.pricelist',
472+ 'framework_agreement_id',
473+ 'Price lists'),
474+ 'available_quantity': fields.function(_get_available_qty,
475+ type='integer',
476+ string='Available quantity',
477+ readonly=True,
478+ store={'framework.agreement': _store_tuple,
479+ 'purchase.order': _po_store_tuple,
480+ 'purchase.order.line': _po_line_store_tuple}),
481+ 'state': fields.function(_get_state,
482+ fnct_search=_search_state,
483+ string='state',
484+ type='selection',
485+ selection=[('draft', 'Draft'),
486+ ('future', 'Future'),
487+ ('running', 'Running'),
488+ ('consumed', 'Consumed'),
489+ ('closed', 'Closed')],
490+ readonly=True),
491+ 'company_id': fields.many2one('res.company',
492+ 'Company'),
493+ 'draft': fields.boolean('Is draft'),
494+ }
495+
496+ def _company_get(self, cr, uid, context=None):
497+ return self.pool['res.company']._company_default_get(cr, uid,
498+ 'framework.agreement',
499+ context=context)
500+
501+ def create(self, cr, uid, vals, context=None):
502+ """We want to have increment sequence only at creation
503+
504+ When set by a default in a o2m form default consume sequence.
505+ But we do not want to use no_gap sequence
506+
507+ """
508+ vals['name'] = self.pool['ir.sequence'].next_by_code(cr, uid,
509+ 'framework.agreement')
510+ return super(framework_agreement, self).create(cr, uid, vals,
511+ context=context)
512+
513+ def _check_overlap(self, cr, uid, ids, context=None):
514+ """Constraint to check that no agreements for same product/supplier overlap.
515+
516+ One agreement per product limit is checked if one_agreement_per_product
517+ is set to True on company
518+
519+ """
520+ comp_obj = self.pool['res.company']
521+ company_id = self._company_get(cr, uid, context=context)
522+ strict = comp_obj.read(cr, uid, company_id,
523+ ['one_agreement_per_product'],
524+ context=context)['one_agreement_per_product']
525+ for agreement in self.browse(cr, uid, ids, context=context):
526+ # we do not add current id in domain for readability reasons
527+ # indent is not PEP8 compliant but more readable.
528+ overlap = self.search(cr, uid,
529+ ['&',
530+ ('draft', '=', False),
531+ ('product_id', '=', agreement.product_id.id),
532+ '|',
533+ '&',
534+ ('start_date', '>=', agreement.start_date),
535+ ('start_date', '<=', agreement.end_date),
536+ '&',
537+ ('end_date', '>=', agreement.start_date),
538+ ('end_date', '<=', agreement.end_date),
539+ ])
540+ # we also look for the one that includes current offer
541+ overlap += self.search(cr, uid, [('start_date', '<=', agreement.start_date),
542+ ('end_date', '>=', agreement.end_date),
543+ ('id', '!=', agreement.id),
544+ ('product_id', '=', agreement.product_id.id)])
545+ overlap = self.browse(cr, uid,
546+ [x for x in overlap if x != agreement.id],
547+ context=context)
548+ # we ensure that there is only one agreement at time per product
549+ # if strict agreement is set on company
550+ if strict and overlap:
551+ return False
552+ # We ensure that there are not multiple agreements for same supplier at same time
553+ if any((x.supplier_id.id == agreement.supplier_id.id) for x in overlap):
554+ return False
555+ return True
556+
557+ def check_overlap(self, cr, uid, ids, context=None):
558+ """Constraint to check that no agreements for same product/supplier overlap.
559+
560+ One agreement per product limit is checked if one_agreement_per_product
561+ is set to True on company
562+
563+ """
564+ return self._check_overlap(cr, uid, ids, context=context)
565+
566+ _defaults = {'company_id': _company_get,
567+ 'draft': True}
568+
569+ _sql_constraints = [('date_priority',
570+ 'check(start_date < end_date)',
571+ 'Start/end date inversion')]
572+
573+ _constraints = [(check_overlap,
574+ "You can not have overlapping dates for same supplier and product",
575+ ('start_date', 'end_date'))]
576+
577+ def get_all_product_agreements(self, cr, uid, product_id, lookup_dt, qty=None, context=None):
578+ """Get the all the active agreement of a given product at a given date
579+
580+ :param product_id: product id of the product
581+ :param lookup_dt: date string of the lookup date
582+ :param qty: quantity that should be available if parameter is
583+ passed and qty is insuffisant no agreement would be returned
584+
585+ :returns: a list of corresponding agreements or None
586+
587+ """
588+ search_args = [('product_id', '=', product_id),
589+ ('start_date', '<=', lookup_dt),
590+ ('end_date', '>=', lookup_dt),
591+ ('draft', '=', False)]
592+ if qty:
593+ search_args.append(('available_quantity', '>=', qty))
594+ agreement_ids = self.search(cr, uid, search_args)
595+ if agreement_ids:
596+ return self.browse(cr, uid, agreement_ids, context=context)
597+ return None
598+
599+ def get_cheapest_agreement_for_qty(self, cr, uid, product_id, date, qty,
600+ currency=None, context=None):
601+ """Return the cheapest agreement that has enough available qty.
602+
603+ If not enough quantity fallback on the cheapest agreement available
604+ for quantity.
605+
606+ :param product_id:
607+ :param date:
608+ :param qty:
609+ :param currency: currency record to make price convertion
610+
611+ returns (cheapest agreement, enough qty)
612+
613+ """
614+ Cheapest = namedtuple('Cheapest', ['cheapest_agreement', 'enough'])
615+ agreements = self.get_all_product_agreements(cr, uid, product_id,
616+ date, qty, context=context)
617+ if not agreements:
618+ return Cheapest(None, None)
619+ agreements.sort(key=lambda x: x.get_price(qty, currency=currency))
620+ enough = True
621+ cheapest_agreement = None
622+ for agr in agreements:
623+ if agr.available_quantity >= qty:
624+ cheapest_agreement = agr
625+ break
626+ if not cheapest_agreement:
627+ cheapest_agreement = agreements[0]
628+ enough = False
629+ return Cheapest(cheapest_agreement, enough)
630+
631+ def get_product_agreement(self, cr, uid, product_id, supplier_id,
632+ lookup_dt, qty=None, context=None):
633+ """Get the matching agreement for a given product/supplier at date
634+ :param product_id: product id of the product
635+ :param supplier_id: supplier to look for agreement
636+ :param lookup_dt: date string of the lookup date
637+ :param qty: quantity that should be available if parameter is
638+ passed and qty is insuffisant no aggrement would be returned
639+
640+ :returns: a corresponding agreement or None
641+
642+ """
643+ search_args = [('product_id', '=', product_id),
644+ ('supplier_id', '=', supplier_id),
645+ ('start_date', '<=', lookup_dt),
646+ ('end_date', '>=', lookup_dt),
647+ ('draft', '=', False)]
648+ if qty:
649+ search_args.append(('available_quantity', '>=', qty))
650+ agreement_ids = self.search(cr, uid, search_args)
651+ if len(agreement_ids) > 1:
652+ raise except_orm(_('Many agreements found for the product with id %s'
653+ ' at date %s') % (product_id, lookup_dt),
654+ _('Please contact your ERP administrator'))
655+ if agreement_ids:
656+ agreement = self.browse(cr, uid, agreement_ids[0], context=context)
657+ return agreement
658+ return None
659+
660+ def has_currency(self, cr, uid, agr_id, currency, context=None):
661+ """Predicate that check that agreement has a given currency pricelist
662+
663+ :returns: boolean (True if a price list in given currency is present)
664+
665+ """
666+ if isinstance(agr_id, (list, tuple)):
667+ assert len(agr_id) == 1
668+ agr_id = agr_id[0]
669+ agreement = self.browse(cr, uid, agr_id, context=context)
670+ plists = agreement.framework_agreement_pricelist_ids
671+ return any(x for x in plists if x.currency_id == currency)
672+
673+ def _get_pricelist_lines(self, cr, uid, agreement,
674+ currency, context=None):
675+ plists = agreement.framework_agreement_pricelist_ids
676+ # we do not use has_agreement for performance reason
677+ # Python cookbook idiom
678+ plist = next((x for x in plists if x.currency_id == currency), None)
679+ if not plist:
680+ raise orm.except_orm(_('Missing Agreement price list'),
681+ _('Please set a price list in currency %s for agreement %s') %
682+ (currency.name, agreement.name))
683+ return plist.framework_agreement_line_ids
684+
685+ def get_price(self, cr, uid, agreement_id, qty=0,
686+ currency=None, context=None):
687+ """Return price negociated for quantity
688+
689+ :param currency: currency record
690+ :param qty: qty to lookup
691+
692+
693+ :returns: price float
694+
695+ """
696+ if isinstance(agreement_id, list):
697+ assert len(agreement_id) == 1
698+ agreement_id = agreement_id[0]
699+ current = self.browse(cr, uid, agreement_id, context=context)
700+ if not currency:
701+ comp_obj = self.pool['res.company']
702+ comp_id = self._company_get(cr, uid, context=context)
703+ currency = comp_obj.browse(cr, uid, comp_id, context=context).currency_id
704+ lines = self._get_pricelist_lines(cr, uid, current, currency,
705+ context=context)
706+ lines.sort(key=attrgetter('quantity'), reverse=True)
707+ for line in lines:
708+ if qty >= line.quantity:
709+ return line.price
710+ return lines[-1].price
711+
712+ def _get_currency(self, cr, uid, supplier_id, pricelist_id, context=None):
713+ """Helper to retrieve correct currency.
714+
715+ It will look for currency on supplied pricelist if availwichable
716+ else it will look for partner pricelist currency
717+
718+ :param supplier_id: supplier of agreement
719+ :param pricelist_id: primary price list
720+
721+ :returns: currency browse record
722+
723+ """
724+
725+ plist_obj = self.pool['product.pricelist']
726+ partner_obj = self.pool['res.partner']
727+ if pricelist_id:
728+ plist = plist_obj.browse(cr, uid, pricelist_id, context=context)
729+ return plist.currency_id
730+ partner = partner_obj.browse(cr, uid, supplier_id, context=context)
731+ if not partner.property_product_pricelist_purchase:
732+ raise orm.except_orm(_('No pricelist found'),
733+ _('Please set a pricelist on PO or supplier %s') % partner.name)
734+ return partner.property_product_pricelist_purchase.currency_id
735+
736+
737+class framework_agreement_pricelist(orm.Model):
738+ """Price list container"""
739+
740+ _name = "framework.agreement.pricelist"
741+ _rec_name = 'currency_id'
742+ _columns = {'framework_agreement_id': fields.many2one('framework.agreement',
743+ 'Agreement',
744+ required=True),
745+ 'currency_id': fields.many2one('res.currency',
746+ 'Currency',
747+ required=True),
748+ 'framework_agreement_line_ids': fields.one2many('framework.agreement.line',
749+ 'framework_agreement_pricelist_id',
750+ 'Price lines',
751+ required=True)}
752+
753+
754+class framework_agreement_line(orm.Model):
755+ """Price list line of framework agreement
756+ that contains price and qty"""
757+
758+ _name = 'framework.agreement.line'
759+ _description = 'Framework agreement line'
760+ _rec_name = "quantity"
761+ _order = "quantity"
762+
763+ _columns = {'framework_agreement_pricelist_id': fields.many2one('framework.agreement.pricelist',
764+ 'Price list',
765+ required=True),
766+ 'quantity': fields.integer('Quantity',
767+ required=True),
768+
769+ 'price': fields.float('Price', 'Negociated price',
770+ required=True,
771+ digits_compute=dp.get_precision('Product Price'))}
772+
773+
774+class FrameworkAgreementObservable(object):
775+ """Base functions for model that have to be (pseudo) observable
776+ by framework agreement using OpenERP on_change mechanism"""
777+
778+ def _currency_get(self, cr, uid, pricelist_id, context=None):
779+ return self.pool['product.pricelist'].browse(cr, uid,
780+ pricelist_id,
781+ context=context).currency_id
782+
783+ def onchange_price_obs(self, cr, uid, ids, price, agreement_id,
784+ currency=None, qty=0, context=None):
785+ """Raise a warning if a agreed price is changed on observed object"""
786+ if context is None:
787+ context = {}
788+ if not agreement_id or context.get('no_chained'):
789+ return {}
790+ agr_obj = self.pool['framework.agreement']
791+ agreement = agr_obj.browse(cr, uid, agreement_id, context=context)
792+ if agreement.get_price(qty, currency=currency) != price:
793+ msg = _("You have set the price to %s \n"
794+ " but there is a running agreement"
795+ " with price %s") % (price, agreement.get_price(qty, currency=currency))
796+ return {'warning': {'title': _('Agreement Warning!'),
797+ 'message': msg}}
798+ return {}
799+
800+ def onchange_quantity_obs(self, cr, uid, ids, qty, date,
801+ product_id, currency=None,
802+ supplier_id=None,
803+ price_field='price', context=None):
804+ """Raise a warning if agreed qty is not sufficient when changed on observed object
805+
806+ :param qty: requested quantity
807+ :param currency: currency to get price
808+ :param price field: key on which we should return price
809+
810+ :returns: on change dict
811+
812+ """
813+ res = {'value': {'framework_agreement_id': False}}
814+ agreement, status = self._get_agreement_and_qty_status(cr, uid, ids, qty, date,
815+ product_id,
816+ supplier_id=supplier_id,
817+ currency=currency,
818+ context=context)
819+ if agreement:
820+ res['value'] = {price_field: agreement.get_price(qty, currency=currency),
821+ 'framework_agreement_id': agreement.id}
822+ if status:
823+ res['warning'] = {'title': _('Agreement Warning!'),
824+ 'message': status}
825+ return res
826+
827+ def _get_agreement_and_qty_status(self, cr, uid, ids, qty, date,
828+ product_id, supplier_id,
829+ currency=None, context=None):
830+ """Lookup for agreement and return (matching_agreement, status)
831+
832+ Agreement or status can be None.
833+
834+ :param qty: requested quantity
835+ :param date: date to look for agreement
836+ :param supplier_id: supplier id who has signed an agreement
837+ :param product_id: product id to look for an agreement
838+ :param price field: key on which we should return price
839+
840+ :returns: (agreement record, status)
841+
842+ """
843+ FoundAgreement = namedtuple('FoundAgreement', ['Agreement', 'message'])
844+ agreement_obj = self.pool['framework.agreement']
845+ if supplier_id:
846+ agreement = agreement_obj.get_product_agreement(cr, uid, product_id,
847+ supplier_id, date,
848+ context=context)
849+ else:
850+ agreement, enough = agreement_obj.get_cheapest_agreement_for_qty(cr,
851+ uid,
852+ product_id,
853+ date,
854+ qty,
855+ currency=currency,
856+ context=context)
857+ if agreement is None:
858+ return FoundAgreement(None, None)
859+ msg = None
860+ if agreement.available_quantity < qty:
861+ msg = _("You have ask for a quantity of %s \n"
862+ " but there is only %s available"
863+ " for current agreement") % (qty, agreement.available_quantity)
864+ return FoundAgreement(agreement, msg)
865+
866+ def onchange_product_id_obs(self, cr, uid, ids, qty, date,
867+ supplier_id, product_id, pricelist_id=None,
868+ currency=None, price_field='price', context=None):
869+ """
870+ Lookup for agreement corresponding to product or return None.
871+
872+ It will raise a warning if not enough available qty.
873+
874+ :param qty: requested quantity
875+ :param date: date to look for agreement
876+ :param supplier_id: supplier id who has signed an agreement
877+ :param pricelist_id: if of prefered pricelist
878+ :param product_id: product id to look for an agreement
879+ :param price field: key on which we should return price
880+
881+ :returns: on change dict
882+
883+ """
884+ if context is None:
885+ context = {}
886+ res = {'value': {'framework_agreement_id': False}}
887+ if not supplier_id or not product_id:
888+ return res
889+ agreement, status = self._get_agreement_and_qty_status(cr, uid, ids, qty, date,
890+ product_id,
891+ supplier_id=supplier_id,
892+ currency=currency,
893+ context=context)
894+ # agr_obj = self.pool['framework.agreement']
895+ # currency = agr_obj._get_currency(cr, uid, supplier_id,
896+ # pricelist_id, context=context)
897+ if agreement:
898+ res['value'] = {price_field: agreement.get_price(qty, currency=currency),
899+ 'framework_agreement_id': agreement.id}
900+ if status:
901+ res['warning'] = {'title': _('Agreement Warning!'),
902+ 'message': status}
903+ if not agreement:
904+ context['no_chained'] = True
905+ return res
906+
907+ def onchange_agreement_obs(self, cr, uid, ids, agreement_id, qty, date, product_id,
908+ supplier_id=None, currency=None, price_field='price',
909+ context=None):
910+ res = {}
911+ if not agreement_id or not product_id:
912+ return res
913+ agr_obj = self.pool['framework.agreement']
914+ agreement = agr_obj.browse(cr, uid, agreement_id, context=context)
915+ if not agreement.date_valid(date, context=context):
916+ raise orm.except_orm(_('Invalid date'),
917+ _('Agreement and purchase date does not match'))
918+ if agreement.product_id.id != product_id:
919+ raise orm.except_orm(_('User Error'),
920+ _('Wrong product for choosen agreement'))
921+ if supplier_id and agreement.supplier_id.id != supplier_id:
922+ raise orm.except_orm(_('User Error'),
923+ _('Wrong supplier for choosen agreement'))
924+ res['value'] = {price_field: agreement.get_price(qty, currency=currency)}
925+ if qty and agreement.available_quantity < qty:
926+ msg = _("You have ask for a quantity of %s \n"
927+ " but there is only %s available"
928+ " for current agreement") % (qty, agreement.available_quantity)
929+ res['warning'] = {'title': _('Agreement Warning!'),
930+ 'message': msg}
931+ return res
932
933=== added file 'framework_agreement/model/pricelist.py'
934--- framework_agreement/model/pricelist.py 1970-01-01 00:00:00 +0000
935+++ framework_agreement/model/pricelist.py 2014-02-06 10:10:57 +0000
936@@ -0,0 +1,80 @@
937+# -*- coding: utf-8 -*-
938+##############################################################################
939+#
940+# Author: Nicolas Bessi
941+# Copyright 2013 Camptocamp SA
942+#
943+# This program is free software: you can redistribute it and/or modify
944+# it under the terms of the GNU Affero General Public License as
945+# published by the Free Software Foundation, either version 3 of the
946+# License, or (at your option) any later version.
947+#
948+# This program is distributed in the hope that it will be useful,
949+# but WITHOUT ANY WARRANTY; without even the implied warranty of
950+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
951+# GNU Affero General Public License for more details.
952+#
953+# You should have received a copy of the GNU Affero General Public License
954+# along with this program. If not, see <http://www.gnu.org/licenses/>.
955+#
956+##############################################################################
957+from datetime import datetime
958+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
959+from openerp.osv import orm, fields
960+
961+
962+class product_pricelist(orm.Model):
963+ """Add framework agreement behavior on pricelist"""
964+
965+ _inherit = "product.pricelist"
966+
967+ def _plist_is_agreement(self, cr, uid, pricelist_id, context=None):
968+ """Check that a price list can be subject to agreement.
969+
970+ :param pricelist_id: the price list to be validated
971+
972+ :returns: a boolean (True if agreement is applicable)
973+
974+ """
975+ p_list = self.browse(cr, uid, pricelist_id, context=context)
976+ if p_list.type == 'purchase':
977+ return True
978+ return False
979+
980+ def price_get(self, cr, uid, ids, prod_id, qty, partner=None, context=None):
981+ """Override of price retrival function in order to support framework agreement.
982+
983+ If it is a supplier price list agrreement will be taken in account
984+ and use the price of the agreement if required.
985+
986+ If there is not enough available qty on agreement, standard price will be used.
987+
988+ This is mabye a faulty design and we should use on_change override
989+
990+ """
991+ if context is None:
992+ context = {}
993+ agreement_obj = self.pool['framework.agreement']
994+ res = super(product_pricelist, self).price_get(cr, uid, ids, prod_id, qty,
995+ partner=partner, context=context)
996+ if not partner:
997+ return res
998+ for pricelist_id in res:
999+ if (pricelist_id == 'item_id' or not
1000+ self._plist_is_agreement(cr, uid, pricelist_id, context=context)):
1001+ continue
1002+ now = datetime.strptime(fields.date.today(),
1003+ DEFAULT_SERVER_DATE_FORMAT)
1004+ date = context.get('date') or context.get('date_order') or now
1005+ if context.get('from_agreement_id'):
1006+ agreement = agreement_obj.browse(cr, uid, context['from_agreement_id'],
1007+ context=context)
1008+ else:
1009+ agreement = agreement_obj.get_product_agreement(cr, uid, prod_id,
1010+ partner, date,
1011+ qty=qty, context=context)
1012+ if agreement is not None:
1013+ currency = agreement_obj._get_currency(cr, uid, partner, pricelist_id,
1014+ context=context)
1015+ res[pricelist_id] = agreement.get_price(qty, currency=currency)
1016+ return res
1017
1018=== added file 'framework_agreement/model/product.py'
1019--- framework_agreement/model/product.py 1970-01-01 00:00:00 +0000
1020+++ framework_agreement/model/product.py 2014-02-06 10:10:57 +0000
1021@@ -0,0 +1,40 @@
1022+# -*- coding: utf-8 -*-
1023+##############################################################################
1024+#
1025+# Author: Nicolas Bessi
1026+# Copyright 2013 Camptocamp SA
1027+#
1028+# This program is free software: you can redistribute it and/or modify
1029+# it under the terms of the GNU Affero General Public License as
1030+# published by the Free Software Foundation, either version 3 of the
1031+# License, or (at your option) any later version.
1032+#
1033+# This program is distributed in the hope that it will be useful,
1034+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1035+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1036+# GNU Affero General Public License for more details.
1037+#
1038+# You should have received a copy of the GNU Affero General Public License
1039+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1040+#
1041+##############################################################################
1042+from openerp.osv import orm, fields
1043+
1044+
1045+class product_product(orm.Model):
1046+ """Add relation to framework agreement"""
1047+
1048+ _inherit = "product.product"
1049+ _columns = {'framework_agreement_ids': fields.one2many('framework.agreement',
1050+ 'product_id',
1051+ 'Framework Agreements (LTA)')
1052+ }
1053+
1054+ def copy(self, cr, uid, id, default=None, context=None):
1055+ """Override of copy in order not to copy agreements"""
1056+ if not default:
1057+ default = {}
1058+ default['framework_agreement_ids'] = False
1059+ return super(product_product, self).copy(cr, uid, id,
1060+ default=default,
1061+ context=context)
1062
1063=== added file 'framework_agreement/model/purchase.py'
1064--- framework_agreement/model/purchase.py 1970-01-01 00:00:00 +0000
1065+++ framework_agreement/model/purchase.py 2014-02-06 10:10:57 +0000
1066@@ -0,0 +1,155 @@
1067+# -*- coding: utf-8 -*-
1068+##############################################################################
1069+#
1070+# Author: Nicolas Bessi
1071+# Copyright 2013 Camptocamp SA
1072+#
1073+# This program is free software: you can redistribute it and/or modify
1074+# it under the terms of the GNU Affero General Public License as
1075+# published by the Free Software Foundation, either version 3 of the
1076+# License, or (at your option) any later version.
1077+#
1078+# This program is distributed in the hope that it will be useful,
1079+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1080+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1081+# GNU Affero General Public License for more details.
1082+#
1083+# You should have received a copy of the GNU Affero General Public License
1084+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1085+#
1086+##############################################################################
1087+from openerp.osv import orm, fields
1088+from openerp.tools.translate import _
1089+from openerp.addons.framework_agreement.model.framework_agreement import FrameworkAgreementObservable
1090+
1091+
1092+class purchase_order_line(orm.Model, FrameworkAgreementObservable):
1093+ """Add on change on price to raise a warning if line is subject to
1094+ an agreement"""
1095+
1096+ _inherit = "purchase.order.line"
1097+
1098+ def _get_po_store(self, cr, uid, ids, context=None):
1099+ res = set()
1100+ po_obj = self.pool.get('purchase.order')
1101+ for row in po_obj.browse(cr, uid, ids, context=context):
1102+ res.update([x.id for x in row.order_line])
1103+ return res
1104+
1105+ _store_tuple = (_get_po_store, ['framework_agreement_id'], 20)
1106+
1107+ _columns = {'framework_agreement_id': fields.related('order_id',
1108+ 'framework_agreement_id',
1109+ type='many2one',
1110+ readonly=True,
1111+ store={'purchase.order': _store_tuple},
1112+ relation='framework.agreement',
1113+ string='Agreement')}
1114+
1115+ def onchange_price(self, cr, uid, ids, price, agreement_id, qty, pricelist_id,
1116+ product_id, context=None):
1117+ """Raise a warning if a agreed price is changed"""
1118+ if not product_id or not agreement_id:
1119+ return {}
1120+ currency = self._currency_get(cr, uid, pricelist_id, context=context)
1121+ product = self.pool['product.product'].browse(cr, uid, product_id, context=context)
1122+ if product.type == 'service':
1123+ return {}
1124+ return self.onchange_price_obs(cr, uid, ids, price, agreement_id, currency=currency,
1125+ qty=qty, context=None)
1126+
1127+ def onchange_product_id(self, cr, uid, ids, pricelist_id, product_id, qty, uom_id,
1128+ partner_id, date_order=False, fiscal_position_id=False,
1129+ date_planned=False, name=False, price_unit=False,
1130+ context=None, agreement_id=False, **kwargs):
1131+ """ We override this function to check qty change (I know...)
1132+
1133+ The price retrieval is managed by the override of product.pricelist.price_get
1134+ that is overidden to support agreement.
1135+ This is mabye a faulty design as it has a low level impact
1136+
1137+ """
1138+ # rock n'roll
1139+ if context is None:
1140+ context = {}
1141+ if agreement_id:
1142+ context['from_agreement_id'] = agreement_id
1143+ res = super(purchase_order_line, self).onchange_product_id(
1144+ cr, uid, ids, pricelist_id, product_id, qty, uom_id,
1145+ partner_id, date_order=date_order, fiscal_position_id=fiscal_position_id,
1146+ date_planned=date_planned, name=name, price_unit=price_unit, context=context, **kwargs)
1147+ if not product_id or not agreement_id:
1148+ return res
1149+ product = self.pool['product.product'].browse(cr, uid, product_id, context=context)
1150+ if product.type != 'service' and agreement_id:
1151+ agreement = self.pool['framework.agreement'].browse(cr, uid,
1152+ agreement_id,
1153+ context=context)
1154+ if agreement.product_id.id != product_id:
1155+ return {'warning': _('Product not in agreement')}
1156+ currency = self._currency_get(cr, uid, pricelist_id, context=context)
1157+ res['value']['price_unit'] = agreement.get_price(qty, currency=currency)
1158+ return res
1159+
1160+
1161+class purchase_order(orm.Model):
1162+ """Oveeride on change to raise warning"""
1163+
1164+ _inherit = "purchase.order"
1165+
1166+ _columns = {'framework_agreement_id': fields.many2one('framework.agreement',
1167+ 'Agreement')}
1168+
1169+ def onchange_agreement(self, cr, uid, ids, agreement_id, partner_id, date, context=None):
1170+ res = {}
1171+ agr_obj = self.pool['framework.agreement']
1172+ if agreement_id:
1173+ agreement = agr_obj.browse(cr, uid, agreement_id, context=context)
1174+ if not agreement.date_valid(date, context=context):
1175+ raise orm.except_orm(_('Invalid date'),
1176+ _('Agreement and purchase date does not match'))
1177+ if agreement.supplier_id.id != partner_id:
1178+ raise orm.except_orm(_('Invalid agreement'),
1179+ _('Agreement and supplier does not match'))
1180+
1181+ warning = {'title': _('Agreement Warning!'),
1182+ 'message': _('If you change the agreement of this order'
1183+ ' (and eventually the currency),'
1184+ ' existing order lines will not be updated.')}
1185+ res['warning'] = warning
1186+ return res
1187+
1188+ def onchange_pricelist(self, cr, uid, ids, pricelist_id, line_ids, context=None):
1189+ res = super(purchase_order, self).onchange_pricelist(cr, uid, ids, pricelist_id,
1190+ context=context)
1191+ if not pricelist_id or not line_ids:
1192+ return res
1193+
1194+
1195+ warning = {'title': _('Pricelist Warning!'),
1196+ 'message': _('If you change the pricelist of this order'
1197+ ' (and eventually the currency),'
1198+ ' prices of existing order lines will not be updated.')}
1199+ res['warning'] = warning
1200+ return res
1201+
1202+ def _date_valid(self, cr, uid, agreement_id, date, context=None):
1203+ """predicate that check that date of invoice is in agreement"""
1204+ agr_model = self.pool['framework.agreement']
1205+ return agr_model.date_valid(cr, uid, agreement_id, date, context=context)
1206+
1207+ def onchange_date(self, cr, uid, ids, agreement_id, date, context=None):
1208+ """Check that date is in agreement"""
1209+ if agreement_id and not self._date_valid(cr, uid, agreement_id, date, context=context):
1210+ raise orm.except_orm(_('Invalid date'),
1211+ _('Agreement and purchase date does not match'))
1212+ return {}
1213+
1214+ # no context in original def...
1215+ def onchange_partner_id(self, cr, uid, ids, partner_id, agreement_id):
1216+ """Override to ensure that partner can not be changed if agreement"""
1217+ res = super(purchase_order, self).onchange_partner_id(cr, uid, ids, partner_id)
1218+ if agreement_id:
1219+ raise orm.except_orm(_('You can not change supplier'),
1220+ _('PO is linked to an agreement'))
1221+ return res
1222
1223=== added directory 'framework_agreement/security'
1224=== added file 'framework_agreement/security/ir.model.access.csv'
1225--- framework_agreement/security/ir.model.access.csv 1970-01-01 00:00:00 +0000
1226+++ framework_agreement/security/ir.model.access.csv 2014-02-06 10:10:57 +0000
1227@@ -0,0 +1,5 @@
1228+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1229+access_agreement,framework.agreement.user,model_framework_agreement,purchase.group_purchase_user,1,0,0,0
1230+access_agreement,framework.agreement.user,model_framework_agreement,purchase.group_purchase_manager,1,1,1,1
1231+access_framework_agreement_pricelist,access_framework_agreement_pricelist,model_framework_agreement_pricelist,purchase.group_purchase_manager,1,1,1,1
1232+access_framework_agreement_line,access_framework_agreement_line,model_framework_agreement_line,purchase.group_purchase_manager,1,1,1,1
1233\ No newline at end of file
1234
1235=== added file 'framework_agreement/security/multicompany.xml'
1236--- framework_agreement/security/multicompany.xml 1970-01-01 00:00:00 +0000
1237+++ framework_agreement/security/multicompany.xml 2014-02-06 10:10:57 +0000
1238@@ -0,0 +1,11 @@
1239+<?xml version="1.0" encoding="utf-8"?>
1240+<openerp>
1241+ <data noupdate="1">
1242+ <record model="ir.rule" id="framework_agreement_mc_rule">
1243+ <field name="name">Framework Agreement company rule</field>
1244+ <field name="model_id" ref="model_framework_agreement"/>
1245+ <field name="global" eval="True"/>
1246+ <field name="domain_force">['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])]</field>
1247+ </record>
1248+ </data>
1249+</openerp>
1250
1251=== added directory 'framework_agreement/tests'
1252=== added file 'framework_agreement/tests/__init__.py'
1253--- framework_agreement/tests/__init__.py 1970-01-01 00:00:00 +0000
1254+++ framework_agreement/tests/__init__.py 2014-02-06 10:10:57 +0000
1255@@ -0,0 +1,30 @@
1256+# -*- coding: utf-8 -*-
1257+##############################################################################
1258+#
1259+# Author: Nicolas Bessi
1260+# Copyright 2013 Camptocamp SA
1261+#
1262+# This program is free software: you can redistribute it and/or modify
1263+# it under the terms of the GNU Affero General Public License as
1264+# published by the Free Software Foundation, either version 3 of the
1265+# License, or (at your option) any later version.
1266+#
1267+# This program is distributed in the hope that it will be useful,
1268+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1269+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1270+# GNU Affero General Public License for more details.
1271+#
1272+# You should have received a copy of the GNU Affero General Public License
1273+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1274+#
1275+##############################################################################
1276+from . import common
1277+from . import test_framework_agreement_dates_and_constraints
1278+from . import test_framework_agreement_consumed_qty
1279+from . import test_framework_agreement_on_change
1280+from . import test_framework_agreement_price_list
1281+
1282+checks = [test_framework_agreement_dates_and_constraints,
1283+ test_framework_agreement_consumed_qty,
1284+ test_framework_agreement_on_change,
1285+ test_framework_agreement_price_list]
1286
1287=== added file 'framework_agreement/tests/common.py'
1288--- framework_agreement/tests/common.py 1970-01-01 00:00:00 +0000
1289+++ framework_agreement/tests/common.py 2014-02-06 10:10:57 +0000
1290@@ -0,0 +1,97 @@
1291+# -*- coding: utf-8 -*-
1292+##############################################################################
1293+#
1294+# Author: Nicolas Bessi
1295+# Copyright 2013 Camptocamp SA
1296+#
1297+# This program is free software: you can redistribute it and/or modify
1298+# it under the terms of the GNU Affero General Public License as
1299+# published by the Free Software Foundation, either version 3 of the
1300+# License, or (at your option) any later version.
1301+#
1302+# This program is distributed in the hope that it will be useful,
1303+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1304+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1305+# GNU Affero General Public License for more details.
1306+#
1307+# You should have received a copy of the GNU Affero General Public License
1308+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1309+#
1310+##############################################################################
1311+from datetime import datetime, timedelta
1312+from openerp.osv import fields
1313+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
1314+
1315+
1316+class BaseAgreementTestMixin(object):
1317+ """Class that contain common behavior for all agreement related unit test classes.
1318+
1319+ We use Mixin because we want to have those behaviors on the various
1320+ unit test subclasses provided by OpenERP in test common.
1321+
1322+ """
1323+
1324+ def commonsetUp(self):
1325+ cr, uid = self.cr, self.uid
1326+ self.agreement_model = self.registry('framework.agreement')
1327+ self.agreement_pl_model = self.registry('framework.agreement.pricelist')
1328+ self.agreement_line_model = self.registry('framework.agreement.line')
1329+ self.now = datetime.strptime(fields.date.today(),
1330+ DEFAULT_SERVER_DATE_FORMAT)
1331+ self.product_id = self.registry('product.product').create(cr, uid,
1332+ {'name': 'test_1',
1333+ 'type': 'product',
1334+ 'list_price': 10.00})
1335+ self.supplier_id = self.registry('res.partner').create(cr, uid, {'name': 'toto',
1336+ 'supplier': 'True'})
1337+
1338+ def _map_agreement_to_po(self, agreement, delta_days):
1339+ """Map agreement to dict to be used by PO create"""
1340+ supplier = agreement.supplier_id
1341+ add = self.browse_ref('base.res_partner_3')
1342+ term = supplier.property_supplier_payment_term
1343+ term = term.id if term else False
1344+ start_date = datetime.strptime(agreement.start_date, DEFAULT_SERVER_DATE_FORMAT)
1345+ date = start_date + timedelta(days=delta_days)
1346+ data = {}
1347+ data['partner_id'] = supplier.id
1348+ data['pricelist_id'] = supplier.property_product_pricelist_purchase.id
1349+ data['dest_address_id'] = add.id
1350+ data['location_id'] = add.property_stock_customer.id
1351+ data['payment_term_id'] = term
1352+ data['origin'] = agreement.name
1353+ data['date_order'] = date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1354+ data['name'] = agreement.name
1355+ data['framework_agreement_id'] = agreement.id
1356+ return data
1357+
1358+ def _map_agreement_to_po_line(self, agreement, qty, order_id):
1359+ """Map agreement to dict to be used by PO line create"""
1360+ data = {}
1361+ supplier = agreement.supplier_id
1362+ data['product_qty'] = qty
1363+ data['product_id'] = agreement.product_id.id
1364+ data['product_uom'] = agreement.product_id.uom_id.id
1365+ currency = supplier.property_product_pricelist_purchase.currency_id
1366+ data['price_unit'] = agreement.get_price(qty, currency=currency)
1367+ data['name'] = agreement.product_id.name
1368+ data['order_id'] = order_id
1369+ data['date_planned'] = self.now
1370+ return data
1371+
1372+ def make_po_from_agreement(self, agreement, qty=0, delta_days=1):
1373+ """Create a purchase order from an agreement
1374+
1375+ :param agreement: origin agreement browse record
1376+ :param qty: qty to be used on po line
1377+ :delta days: set date of po to agreement start date + delta
1378+
1379+ :returns: purchase order browse record
1380+
1381+ """
1382+ cr, uid = self.cr, self.uid
1383+ po_model = self.registry('purchase.order')
1384+ po_line_model = self.registry('purchase.order.line')
1385+ po_id = po_model.create(cr, uid, self._map_agreement_to_po(agreement, delta_days))
1386+ po_line_model.create(cr, uid, self._map_agreement_to_po_line(agreement, qty, po_id))
1387+ return po_model.browse(cr, uid, po_id)
1388
1389=== added file 'framework_agreement/tests/test_framework_agreement_consumed_qty.py'
1390--- framework_agreement/tests/test_framework_agreement_consumed_qty.py 1970-01-01 00:00:00 +0000
1391+++ framework_agreement/tests/test_framework_agreement_consumed_qty.py 2014-02-06 10:10:57 +0000
1392@@ -0,0 +1,74 @@
1393+# -*- coding: utf-8 -*-
1394+##############################################################################
1395+#
1396+# Author: Nicolas Bessi
1397+# Copyright 2013 Camptocamp SA
1398+#
1399+# This program is free software: you can redistribute it and/or modify
1400+# it under the terms of the GNU Affero General Public License as
1401+# published by the Free Software Foundation, either version 3 of the
1402+# License, or (at your option) any later version.
1403+#
1404+# This program is distributed in the hope that it will be useful,
1405+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1406+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1407+# GNU Affero General Public License for more details.
1408+#
1409+# You should have received a copy of the GNU Affero General Public License
1410+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1411+#
1412+##############################################################################
1413+from datetime import timedelta
1414+from openerp import netsvc
1415+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
1416+import openerp.tests.common as test_common
1417+from .common import BaseAgreementTestMixin
1418+from ..model.framework_agreement import AGR_PO_STATE
1419+
1420+
1421+class TestAvailabeQty(test_common.TransactionCase, BaseAgreementTestMixin):
1422+ """Test the function fields available_quantity"""
1423+
1424+ def setUp(self):
1425+ """ Create a default agreement"""
1426+ super(TestAvailabeQty, self).setUp()
1427+ self.commonsetUp()
1428+ cr, uid = self.cr, self.uid
1429+ start_date = self.now + timedelta(days=10)
1430+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1431+ end_date = self.now + timedelta(days=20)
1432+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1433+
1434+ agr_id = self.agreement_model.create(cr, uid,
1435+ {'supplier_id': self.supplier_id,
1436+ 'product_id': self.product_id,
1437+ 'start_date': start_date,
1438+ 'end_date': end_date,
1439+ 'price': 77,
1440+ 'delay': 5,
1441+ 'quantity': 200})
1442+ pl_id = self.agreement_pl_model.create(cr, uid,
1443+ {'framework_agreement_id': agr_id,
1444+ 'currency_id': self.ref('base.EUR')})
1445+
1446+ self.agreement_line_model.create(cr, uid,
1447+ {'framework_agreement_pricelist_id': pl_id,
1448+ 'quantity': 0,
1449+ 'price': 77.0})
1450+ self.agreement = self.agreement_model.browse(cr, uid, agr_id)
1451+ self.agreement.open_agreement()
1452+
1453+ def test_00_noting_consumed(self):
1454+ """Test non consumption"""
1455+ self.assertEqual(self.agreement.available_quantity, 200)
1456+
1457+ def test_01_150_consumed(self):
1458+ """ test consumption of 150 units"""
1459+ cr, uid = self.cr, self.uid
1460+ po = self.make_po_from_agreement(self.agreement, qty=150, delta_days=5)
1461+ wf_service = netsvc.LocalService("workflow")
1462+ wf_service.trg_validate(uid, 'purchase.order', po.id, 'purchase_confirm', cr)
1463+ po.refresh()
1464+ self.assertIn(po.state, AGR_PO_STATE)
1465+ self.agreement.refresh()
1466+ self.assertEqual(self.agreement.available_quantity, 50)
1467
1468=== added file 'framework_agreement/tests/test_framework_agreement_dates_and_constraints.py'
1469--- framework_agreement/tests/test_framework_agreement_dates_and_constraints.py 1970-01-01 00:00:00 +0000
1470+++ framework_agreement/tests/test_framework_agreement_dates_and_constraints.py 2014-02-06 10:10:57 +0000
1471@@ -0,0 +1,135 @@
1472+# -*- coding: utf-8 -*-
1473+##############################################################################
1474+#
1475+# Author: Nicolas Bessi
1476+# Copyright 2013 Camptocamp SA
1477+#
1478+# This program is free software: you can redistribute it and/or modify
1479+# it under the terms of the GNU Affero General Public License as
1480+# published by the Free Software Foundation, either version 3 of the
1481+# License, or (at your option) any later version.
1482+#
1483+# This program is distributed in the hope that it will be useful,
1484+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1485+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1486+# GNU Affero General Public License for more details.
1487+#
1488+# You should have received a copy of the GNU Affero General Public License
1489+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1490+#
1491+##############################################################################
1492+from datetime import timedelta
1493+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
1494+import openerp.tests.common as test_common
1495+from .common import BaseAgreementTestMixin
1496+
1497+
1498+class TestAgreementState(test_common.TransactionCase, BaseAgreementTestMixin):
1499+
1500+ def setUp(self):
1501+ super(TestAgreementState, self).setUp()
1502+ self.commonsetUp()
1503+
1504+ def test_00_future(self):
1505+ """Test state of a future agreement"""
1506+ cr, uid = self.cr, self.uid
1507+ start_date = self.now + timedelta(days=10)
1508+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1509+ end_date = self.now + timedelta(days=20)
1510+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1511+
1512+ agr_id = self.agreement_model.create(cr, uid,
1513+ {'supplier_id': self.supplier_id,
1514+ 'product_id': self.product_id,
1515+ 'start_date': start_date,
1516+ 'end_date': end_date,
1517+ 'delay': 5,
1518+ 'quantity': 20})
1519+
1520+ agreement = self.agreement_model.browse(cr, uid, agr_id)
1521+ agreement.open_agreement()
1522+ self.assertEqual(agreement.state, 'future')
1523+
1524+ def test_01_past(self):
1525+ """Test state of a past agreement"""
1526+ cr, uid = self.cr, self.uid
1527+ start_date = self.now - timedelta(days=20)
1528+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1529+ end_date = self.now - timedelta(days=10)
1530+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1531+
1532+ agr_id = self.agreement_model.create(cr, uid,
1533+ {'supplier_id': self.supplier_id,
1534+ 'product_id': self.product_id,
1535+ 'start_date': start_date,
1536+ 'end_date': end_date,
1537+ 'delay': 5,
1538+ 'quantity': 20})
1539+ agreement = self.agreement_model.browse(cr, uid, agr_id)
1540+ agreement.open_agreement()
1541+ self.assertEqual(agreement.state, 'closed')
1542+
1543+ def test_02_running(self):
1544+ """Test state of a running agreement"""
1545+ cr, uid = self.cr, self.uid
1546+ start_date = self.now - timedelta(days=2)
1547+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1548+ end_date = self.now + timedelta(days=2)
1549+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1550+
1551+ agr_id = self.agreement_model.create(cr, uid,
1552+ {'supplier_id': self.supplier_id,
1553+ 'product_id': self.product_id,
1554+ 'start_date': start_date,
1555+ 'end_date': end_date,
1556+ 'delay': 5,
1557+ 'quantity': 20})
1558+ agreement = self.agreement_model.browse(cr, uid, agr_id)
1559+ agreement.open_agreement()
1560+ self.assertEqual(agreement.state, 'running')
1561+
1562+ def test_03_date_orderconstraint(self):
1563+ """Test that date order is checked"""
1564+ cr, uid = self.cr, self.uid
1565+ start_date = self.now - timedelta(days=40)
1566+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1567+ end_date = self.now + timedelta(days=30)
1568+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1569+ with self.assertRaises(Exception) as constraint:
1570+ self.agreement_model.create(cr, uid,
1571+ {'supplier_id': self.supplier_id,
1572+ 'product_id': self.product_id,
1573+ 'start_date': end_date,
1574+ 'end_date': start_date,
1575+ 'draft': False,
1576+ 'delay': 5,
1577+ 'quantity': 20})
1578+
1579+ def test_04_test_overlapp(self):
1580+ """Test overlapping agreement for same supplier constraint"""
1581+ cr, uid = self.cr, self.uid
1582+ start_date = self.now - timedelta(days=10)
1583+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1584+ end_date = self.now + timedelta(days=10)
1585+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1586+ self.agreement_model.create(cr, uid,
1587+ {'supplier_id': self.supplier_id,
1588+ 'product_id': self.product_id,
1589+ 'start_date': start_date,
1590+ 'end_date': end_date,
1591+ 'draft': False,
1592+ 'delay': 5,
1593+ 'quantity': 20})
1594+ start_date = self.now - timedelta(days=2)
1595+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1596+ end_date = self.now + timedelta(days=2)
1597+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1598+ with self.assertRaises(Exception) as constraint:
1599+ self.agreement_model.create(cr, uid,
1600+ {'supplier_id': self.supplier_id,
1601+ 'product_id': self.product_id,
1602+ 'start_date': start_date,
1603+ 'end_date': end_date,
1604+ 'draft': False,
1605+ 'delay': 5,
1606+ 'quantity': 20})
1607
1608=== added file 'framework_agreement/tests/test_framework_agreement_on_change.py'
1609--- framework_agreement/tests/test_framework_agreement_on_change.py 1970-01-01 00:00:00 +0000
1610+++ framework_agreement/tests/test_framework_agreement_on_change.py 2014-02-06 10:10:57 +0000
1611@@ -0,0 +1,166 @@
1612+# -*- coding: utf-8 -*-
1613+##############################################################################
1614+#
1615+# Author: Nicolas Bessi
1616+# Copyright 2013 Camptocamp SA
1617+#
1618+# This program is free software: you can redistribute it and/or modify
1619+# it under the terms of the GNU Affero General Public License as
1620+# published by the Free Software Foundation, either version 3 of the
1621+# License, or (at your option) any later version.
1622+#
1623+# This program is distributed in the hope that it will be useful,
1624+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1625+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1626+# GNU Affero General Public License for more details.
1627+#
1628+# You should have received a copy of the GNU Affero General Public License
1629+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1630+#
1631+##############################################################################
1632+from datetime import timedelta
1633+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
1634+import openerp.tests.common as test_common
1635+from .common import BaseAgreementTestMixin
1636+from ..model.framework_agreement import FrameworkAgreementObservable
1637+
1638+
1639+class TestAgreementOnChange(test_common.TransactionCase, BaseAgreementTestMixin):
1640+ """Test observer on change and purchase order on chnage"""
1641+
1642+ def setUp(self):
1643+ """ Create a default agreement
1644+ with 3 price line
1645+ qty 0 price 70
1646+ qty 200 price 60
1647+ """
1648+ super(TestAgreementOnChange, self).setUp()
1649+ self.commonsetUp()
1650+ cr, uid = self.cr, self.uid
1651+ start_date = self.now + timedelta(days=10)
1652+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1653+ end_date = self.now + timedelta(days=20)
1654+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1655+ agr_id = self.agreement_model.create(cr, uid,
1656+ {'supplier_id': self.supplier_id,
1657+ 'product_id': self.product_id,
1658+ 'start_date': start_date,
1659+ 'end_date': end_date,
1660+ 'delay': 5,
1661+ 'draft': False,
1662+ 'quantity': 1500})
1663+ pl_id = self.agreement_pl_model.create(cr, uid,
1664+ {'framework_agreement_id': agr_id,
1665+ 'currency_id': self.ref('base.EUR')})
1666+ self.agreement_line_model.create(cr, uid,
1667+ {'framework_agreement_pricelist_id': pl_id,
1668+ 'quantity': 0,
1669+ 'price': 70})
1670+ self.agreement_line_model.create(cr, uid,
1671+ {'framework_agreement_pricelist_id': pl_id,
1672+ 'quantity': 200,
1673+ 'price': 60})
1674+ self.agreement = self.agreement_model.browse(cr, uid, agr_id)
1675+ self.po_line_model = self.registry('purchase.order.line')
1676+ self.assertTrue(issubclass(type(self.po_line_model),
1677+ FrameworkAgreementObservable))
1678+
1679+ def test_00_observe_price_change(self):
1680+ """Ensure that on change price observer raise correct warning
1681+
1682+ Warning must be rose if there is a running price agreement
1683+
1684+ """
1685+ cr, uid = self.cr, self.uid
1686+ res = self.po_line_model.onchange_price_obs(cr, uid, False, 20.0,
1687+ self.agreement.id,
1688+ currency=self.browse_ref('base.EUR'),
1689+ qty=100)
1690+ self.assertTrue(res.get('warning'))
1691+
1692+ def test_01_onchange_quantity_obs(self):
1693+ """Ensure that on change quantity will raise warning or return price"""
1694+ cr, uid = self.cr, self.uid
1695+ res = self.po_line_model.onchange_quantity_obs(cr, uid, False, 200.0,
1696+ self.agreement.start_date,
1697+ self.agreement.product_id.id,
1698+ supplier_id=self.agreement.supplier_id.id,
1699+ currency=self.browse_ref('base.EUR'))
1700+ self.assertFalse(res.get('warning'))
1701+ self.assertEqual(res.get('value', {}).get('price'), 60)
1702+ # test there is a warning if agreement has not enought quantity
1703+ res = self.po_line_model.onchange_quantity_obs(cr, uid, False, 20000.0,
1704+ self.agreement.start_date,
1705+ self.agreement.product_id.id,
1706+ supplier_id=self.agreement.supplier_id.id,
1707+ currency=self.browse_ref('base.EUR'))
1708+ self.assertTrue(res.get('warning'))
1709+
1710+ res = self.po_line_model.onchange_quantity_obs(cr, uid, False, 20000.0,
1711+ self.now.strftime(DEFAULT_SERVER_DATE_FORMAT),
1712+ self.agreement.product_id.id,
1713+ supplier_id=self.agreement.supplier_id.id,
1714+ currency=self.browse_ref('base.EUR'))
1715+ self.assertFalse(res.get('warning'))
1716+
1717+ def test_02_onchange_product_obs(self):
1718+ """Check that change of product has correct behavior"""
1719+ cr, uid = self.cr, self.uid
1720+ res = self.po_line_model.onchange_product_id_obs(cr, uid, False, 180.0,
1721+ self.agreement.start_date,
1722+ self.agreement.supplier_id.id,
1723+ self.agreement.product_id.id)
1724+ self.assertFalse(res.get('warning'))
1725+ self.assertEqual(res.get('value', {}).get('price'), 70)
1726+
1727+ res = self.po_line_model.onchange_product_id_obs(cr, uid, False, 20000.0,
1728+ self.agreement.start_date,
1729+ self.agreement.supplier_id.id,
1730+ self.agreement.product_id.id)
1731+ self.assertTrue(res.get('warning'))
1732+ self.assertEqual(res.get('value', {}).get('price'), 60)
1733+
1734+ res = self.po_line_model.onchange_product_id_obs(cr, uid, False, 20000.0,
1735+ self.now.strftime(DEFAULT_SERVER_DATE_FORMAT),
1736+ self.agreement.supplier_id.id,
1737+ self.agreement.product_id.id)
1738+ self.assertFalse(res.get('warning'))
1739+
1740+ # we do the test on non agreement product
1741+
1742+ res = self.po_line_model.onchange_product_id_obs(cr, uid, False, 20000.0,
1743+ self.agreement.start_date,
1744+ self.ref('product.product_product_33'),
1745+ self.agreement.product_id.id)
1746+ self.assertEqual(res, {'value': {'framework_agreement_id': False}})
1747+
1748+ def test_03_price_observer_bindings(self):
1749+ """Check that change of price has correct behavior"""
1750+ cr, uid = self.cr, self.uid
1751+ plist = self.agreement.supplier_id.property_product_pricelist_purchase
1752+ res = self.po_line_model.onchange_price(cr, uid, False, 20.0,
1753+ self.agreement.id,
1754+ 200,
1755+ plist.id,
1756+ self.agreement.product_id.id)
1757+ self.assertTrue(res.get('warning'))
1758+
1759+ def test_04_product_observer_bindings(self):
1760+ """Check that change of product has correct behavior"""
1761+ cr, uid = self.cr, self.uid
1762+ pl = self.agreement.supplier_id.property_product_pricelist_purchase.id,
1763+
1764+ res = self.po_line_model.onchange_product_id(cr, uid, False,
1765+ pl,
1766+ self.agreement.product_id.id,
1767+ 200,
1768+ self.agreement.product_id.uom_id.id,
1769+ self.agreement.supplier_id.id,
1770+ date_order=self.agreement.start_date[0:10],
1771+ fiscal_position_id=False,
1772+ date_planned=False,
1773+ name=False,
1774+ price_unit=False,
1775+ context={},
1776+ agreement_id=self.agreement.id)
1777+ self.assertFalse(res.get('warning'))
1778
1779=== added file 'framework_agreement/tests/test_framework_agreement_price_list.py'
1780--- framework_agreement/tests/test_framework_agreement_price_list.py 1970-01-01 00:00:00 +0000
1781+++ framework_agreement/tests/test_framework_agreement_price_list.py 2014-02-06 10:10:57 +0000
1782@@ -0,0 +1,73 @@
1783+from datetime import timedelta
1784+from openerp.tools import DEFAULT_SERVER_DATE_FORMAT
1785+from openerp.osv import orm
1786+import openerp.tests.common as test_common
1787+from .common import BaseAgreementTestMixin
1788+
1789+
1790+class TestAgreementPriceList(test_common.TransactionCase, BaseAgreementTestMixin):
1791+ """Test observer on change and purchase order on chnage"""
1792+
1793+ def setUp(self):
1794+ """ Create a default agreement
1795+ with 3 price line
1796+ qty 0 price 70
1797+ qty 200 price 60
1798+ qty 500 price 50
1799+ qty 1000 price 45
1800+ """
1801+ super(TestAgreementPriceList, self).setUp()
1802+ self.commonsetUp()
1803+ cr, uid = self.cr, self.uid
1804+ start_date = self.now + timedelta(days=10)
1805+ start_date = start_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1806+ end_date = self.now + timedelta(days=20)
1807+ end_date = end_date.strftime(DEFAULT_SERVER_DATE_FORMAT)
1808+ agr_id = self.agreement_model.create(cr, uid,
1809+ {'supplier_id': self.supplier_id,
1810+ 'product_id': self.product_id,
1811+ 'start_date': start_date,
1812+ 'end_date': end_date,
1813+ 'delay': 5,
1814+ 'draft': False,
1815+ 'quantity': 1500})
1816+
1817+ pl_id = self.agreement_pl_model.create(cr, uid,
1818+ {'framework_agreement_id': agr_id,
1819+ 'currency_id': self.ref('base.EUR')})
1820+
1821+ self.agreement_line_model.create(cr, uid,
1822+ {'framework_agreement_pricelist_id': pl_id,
1823+ 'quantity': 0,
1824+ 'price': 70.0})
1825+ self.agreement_line_model.create(cr, uid,
1826+ {'framework_agreement_pricelist_id': pl_id,
1827+ 'quantity': 200,
1828+ 'price': 60.0})
1829+ self.agreement_line_model.create(cr, uid,
1830+ {'framework_agreement_pricelist_id': pl_id,
1831+ 'quantity': 500,
1832+ 'price': 50.0})
1833+ self.agreement_line_model.create(cr, uid,
1834+ {'framework_agreement_pricelist_id': pl_id,
1835+ 'quantity': 1000,
1836+ 'price': 45.0})
1837+ self.agreement = self.agreement_model.browse(cr, uid, agr_id)
1838+
1839+ def test_00_test_qty(self):
1840+ """Test if barem retrieval is correct"""
1841+ self.assertEqual(self.agreement.get_price(0, currency=self.browse_ref('base.EUR')), 70.0)
1842+ self.assertEqual(self.agreement.get_price(100, currency=self.browse_ref('base.EUR')), 70.0)
1843+ self.assertEqual(self.agreement.get_price(200, currency=self.browse_ref('base.EUR')), 60.0)
1844+ self.assertEqual(self.agreement.get_price(210, currency=self.browse_ref('base.EUR')), 60.0)
1845+ self.assertEqual(self.agreement.get_price(500, currency=self.browse_ref('base.EUR')), 50.0)
1846+ self.assertEqual(self.agreement.get_price(800, currency=self.browse_ref('base.EUR')), 50.0)
1847+ self.assertEqual(self.agreement.get_price(999, currency=self.browse_ref('base.EUR')), 50.0)
1848+ self.assertEqual(self.agreement.get_price(1000, currency=self.browse_ref('base.EUR')), 45.0)
1849+ self.assertEqual(self.agreement.get_price(10000, currency=self.browse_ref('base.EUR')), 45.0)
1850+ self.assertEqual(self.agreement.get_price(-10, currency=self.browse_ref('base.EUR')), 70.0)
1851+
1852+ def test_01_failed_wrong_currency(self):
1853+ """Tests that wrong currency raise an exception"""
1854+ with self.assertRaises(orm.except_orm) as error:
1855+ self.agreement.get_price(0, currency=self.browse_ref('base.USD'))
1856
1857=== added file 'framework_agreement/utils.py'
1858--- framework_agreement/utils.py 1970-01-01 00:00:00 +0000
1859+++ framework_agreement/utils.py 2014-02-06 10:10:57 +0000
1860@@ -0,0 +1,29 @@
1861+# -*- coding: utf-8 -*-
1862+##############################################################################
1863+#
1864+# Author: Nicolas Bessi
1865+# Copyright 2013 Camptocamp SA
1866+#
1867+# This program is free software: you can redistribute it and/or modify
1868+# it under the terms of the GNU Affero General Public License as
1869+# published by the Free Software Foundation, either version 3 of the
1870+# License, or (at your option) any later version.
1871+#
1872+# This program is distributed in the hope that it will be useful,
1873+# but WITHOUT ANY WARRANTY; without even the implied warranty of
1874+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
1875+# GNU Affero General Public License for more details.
1876+#
1877+# You should have received a copy of the GNU Affero General Public License
1878+# along with this program. If not, see <http://www.gnu.org/licenses/>.
1879+#
1880+##############################################################################
1881+def id_boilerplate(fun):
1882+ """Ensure that id agrument passed to on change is not a list"""
1883+ def wrapper(*args, **kwargs):
1884+ if isinstance(args[3], (list, tuple)):
1885+ args = list(args)
1886+ args[3] = args[3][0] if args[3] else False
1887+ args = tuple(args)
1888+ return fun(*args, **kwargs)
1889+ return wrapper
1890
1891=== added directory 'framework_agreement/view'
1892=== added file 'framework_agreement/view/company_view.xml'
1893--- framework_agreement/view/company_view.xml 1970-01-01 00:00:00 +0000
1894+++ framework_agreement/view/company_view.xml 2014-02-06 10:10:57 +0000
1895@@ -0,0 +1,17 @@
1896+<?xml version="1.0" encoding="utf-8"?>
1897+<openerp>
1898+ <data>
1899+ <record id="add agreement setting on company" model="ir.ui.view">
1900+ <field name="name">add agreement setting on company</field>
1901+ <field name="model">res.company</field>
1902+ <field name="priority">19</field>
1903+ <field name="inherit_id" ref="base.view_company_form"/>
1904+ <field name="arch" type="xml">
1905+ <xpath expr="//group[@name='logistics_grp']" position="inside">
1906+ <field name="one_agreement_per_product"/>
1907+ </xpath>
1908+ </field>
1909+ </record>
1910+
1911+ </data>
1912+</openerp>
1913
1914=== added file 'framework_agreement/view/framework_agreement_view.xml'
1915--- framework_agreement/view/framework_agreement_view.xml 1970-01-01 00:00:00 +0000
1916+++ framework_agreement/view/framework_agreement_view.xml 2014-02-06 10:10:57 +0000
1917@@ -0,0 +1,118 @@
1918+<?xml version="1.0" encoding="utf-8"?>
1919+<openerp>
1920+ <data>
1921+ <record id="framework_agreement_list_view" model="ir.ui.view">
1922+ <field name="name">framework agreement list view</field>
1923+ <field name="model">framework.agreement</field>
1924+ <field name="arch" type="xml">
1925+ <tree version="7.0" string="Framework Agreement (LTA)"> <!-- editable="bottom" -->
1926+ <field name="name" />
1927+ <field name="company_id" groups="base.group_multi_company" widget="selection"/>
1928+ <field name="product_id"/>
1929+ <field name="supplier_id"/>
1930+ <field name="delay"/>
1931+ <field name="quantity"/>
1932+ <field name="available_quantity"/>
1933+ <field name="start_date"/>
1934+ <field name="end_date"/>
1935+ <field name="state"/>
1936+ </tree>
1937+ </field>
1938+ </record>
1939+
1940+ <record id="framework_agreement_form_view" model="ir.ui.view">
1941+ <field name="name">framework agreement form</field>
1942+ <field name="model">framework.agreement</field>
1943+ <field name="arch" type="xml">
1944+ <form version="7.0" string="Framework Agreement">
1945+ <header>
1946+ <button name="open_agreement"
1947+ context="{}"
1948+ string="Open Agreement"
1949+ type="object"
1950+ attrs="{'invisible': ['|', ('draft', '=', False), '|', ('start_date', '=', False), '|', ('end_date', '=', False), '|', ('framework_agreement_pricelist_ids', '=', False)]}"/>
1951+ <field name="state"
1952+ widget="statusbar"
1953+ nolabel="1"
1954+ statusbar_visible="draft,future,running,consumed,closed"
1955+ statusbar_colors='{"draft":"blue","future": "blue", "closed": "blue", "running": "green", "consumed": "red"}'/>
1956+ </header>
1957+ <sheet>
1958+ <group>
1959+ <field name="name"/>
1960+ <field name="origin"/>
1961+ <field name="draft" invisible="1"/>
1962+ </group>
1963+ <group>
1964+ <group>
1965+ <field name="company_id" groups="base.group_multi_company" widget="selection"/>
1966+ <field name="supplier_id" context="{'default_supplier': True, 'default_customer': False}"/>
1967+ <field name="product_id"/>
1968+ </group>
1969+ <group>
1970+ <field name="delay"/>
1971+ <field name="quantity"/>
1972+ <field name="available_quantity"/>
1973+ </group>
1974+ </group>
1975+
1976+ <group string="Dates">
1977+ <field name="start_date"
1978+ required="1"/>
1979+ <field name="end_date"
1980+ required="1"/>
1981+ </group>
1982+ <notebook>
1983+ <page string="Negociated price lists" colspan="4">
1984+ <field name="framework_agreement_pricelist_ids"
1985+ required="1">
1986+ <tree type="7.0" string="Price list">
1987+ <field name="currency_id"/>
1988+ </tree>
1989+ <form type="7.0" string="Price list">
1990+ <group>
1991+ <field name="currency_id"/>
1992+ </group>
1993+ <newline/>
1994+ <notebook>
1995+ <page string="Price lines" colspan="4">
1996+ <field name="framework_agreement_line_ids"
1997+ nolabel="1">
1998+ <tree type="7.0"
1999+ string="Price line"
2000+ editable="top">
2001+ <field name="quantity"/>
2002+ <field name="price"/>
2003+ </tree>
2004+ </field>
2005+ </page>
2006+ </notebook>
2007+ </form>
2008+ </field>
2009+ </page>
2010+ </notebook>
2011+ </sheet>
2012+ </form>
2013+ </field>
2014+ </record>
2015+
2016+
2017+ <record model="ir.actions.act_window" id="action_framework_agreement">
2018+ <field name="name">Framework Agreement</field>
2019+ <field name="type">ir.actions.act_window</field>
2020+ <field name="res_model">framework.agreement</field>
2021+ <field name="domain"></field>
2022+ <field name="view_type">form</field>
2023+ <field name="view_mode">tree,form</field>
2024+ <field name="view_id" ref="framework_agreement_list_view"/>
2025+ </record>
2026+
2027+
2028+ <menuitem
2029+ name="Framework Agreement"
2030+ parent="purchase.menu_purchase_config_pricelist"
2031+ action="action_framework_agreement"
2032+ id="action_framework_agreement_menu"/>
2033+
2034+ </data>
2035+</openerp>
2036
2037=== added file 'framework_agreement/view/product_view.xml'
2038--- framework_agreement/view/product_view.xml 1970-01-01 00:00:00 +0000
2039+++ framework_agreement/view/product_view.xml 2014-02-06 10:10:57 +0000
2040@@ -0,0 +1,86 @@
2041+<?xml version="1.0" encoding="utf-8"?>
2042+<openerp>
2043+ <data>
2044+ <record id="agreement in product view" model="ir.ui.view">
2045+ <field name="name">agreement in product view</field>
2046+ <field name="model">product.product</field>
2047+ <field name="inherit_id" ref="product.product_normal_form_view"/>
2048+ <field name="arch" type="xml">
2049+ <group string="Purchase" position="after">
2050+ <group string="Framework agreements (LTA)" colspan="4">
2051+ <field name="framework_agreement_ids" nolabel="1">
2052+ <form version="7.0" string="Framework Agreement">
2053+ <header>
2054+ <button name="open_agreement"
2055+ context="{}"
2056+ string="Open Agreement"
2057+ type="object"
2058+ attrs="{'invisible': ['|', ('draft', '=', False), ('start_date', '=', False), ('end_date', '=', False), ('framework_agreement_pricelist_ids', '=', False)]}"/>
2059+ <field name="state"
2060+ widget="statusbar"
2061+ nolabel="1"
2062+ statusbar_visible="draft,future,running,consumed,closed"
2063+ statusbar_colors='{"draft":"blue","future": "blue", "closed": "blue", "running": "green", "consumed": "red"}'/>
2064+ </header>
2065+ <sheet>
2066+ <group>
2067+ <field name="name"/>
2068+ <field name="draft" invisible="1"/>
2069+ <field name="origin"/>
2070+ </group>
2071+ <group>
2072+ <group>
2073+ <field name="company_id" groups="base.group_multi_company" widget="selection"/>
2074+ <field name="supplier_id" context="{'default_supplier': True, 'default_customer': False}"/>
2075+ </group>
2076+ <group>
2077+ <field name="delay"/>
2078+ <field name="quantity"/>
2079+ <field name="available_quantity"/>
2080+ </group>
2081+ </group>
2082+
2083+ <group string="Dates">
2084+ <field name="start_date"
2085+ required="1"/>
2086+ <field name="end_date"
2087+ required="1"/>
2088+ </group>
2089+ <notebook>
2090+ <page string="Negociated price lists" colspan="4">
2091+ <field name="framework_agreement_pricelist_ids"
2092+ attrs="{'required': [('draft', '=', False)]}">
2093+ <tree type="7.0" string="Price list">
2094+ <field name="currency_id"/>
2095+ </tree>
2096+ <form type="7.0" string="Price list">
2097+ <group>
2098+ <field name="currency_id"/>
2099+ </group>
2100+ <newline/>
2101+ <notebook>
2102+ <page string="Price lines" colspan="4">
2103+ <field name="framework_agreement_line_ids"
2104+ nolabel="1">
2105+ <tree type="7.0"
2106+ string="Price line"
2107+ editable="top">
2108+ <field name="quantity"/>
2109+ <field name="price"/>
2110+ </tree>
2111+ </field>
2112+ </page>
2113+ </notebook>
2114+ </form>
2115+ </field>
2116+ </page>
2117+ </notebook>
2118+ </sheet>
2119+ </form>
2120+ </field>
2121+ </group>
2122+ </group>
2123+ </field>
2124+ </record>
2125+ </data>
2126+</openerp>
2127
2128=== added file 'framework_agreement/view/purchase_view.xml'
2129--- framework_agreement/view/purchase_view.xml 1970-01-01 00:00:00 +0000
2130+++ framework_agreement/view/purchase_view.xml 2014-02-06 10:10:57 +0000
2131@@ -0,0 +1,51 @@
2132+<?xml version="1.0" encoding="utf-8"?>
2133+<openerp>
2134+ <data>
2135+ <record id="add_onchange_on_pruchase_order_form" model="ir.ui.view">
2136+ <field name="name">add onchange on pruchase form</field>
2137+ <field name="model">purchase.order</field>
2138+ <field name="inherit_id" ref="purchase.purchase_order_form" />
2139+ <field name="arch" type="xml">
2140+ <field name="price_unit" position="attributes">
2141+ <attribute name="on_change">onchange_price(price_unit, parent.framework_agreement_id, product_qty, parent.pricelist_id, product_id)</attribute>
2142+ </field>
2143+ <field name="pricelist_id" position="after">
2144+ <field name="framework_agreement_id"
2145+ domain="[('draft', '=', False)]"
2146+ on_change="onchange_agreement(framework_agreement_id, partner_id, date_order)"/>
2147+ </field>
2148+ <field name="pricelist_id"
2149+ position="attributes">
2150+ <attribute name="on_change">onchange_pricelist(pricelist_id, order_line)</attribute>
2151+ </field>
2152+ <field name="date_order"
2153+ position="attributes">
2154+ <attribute name="on_change">onchange_date(framework_agreement_id, date_order)</attribute>
2155+ </field>
2156+ <field name="partner_id"
2157+ position="attributes">
2158+ <attribute name="on_change">onchange_partner_id(partner_id, framework_agreement_id)</attribute>
2159+ </field>
2160+ <field name="product_id"
2161+ position="attributes">
2162+ <attribute name="on_change">onchange_product_id(parent.pricelist_id,product_id,product_qty,product_uom,parent.partner_id,parent.date_order,parent.fiscal_position,date_planned,name,price_unit,context,parent.framework_agreement_id)</attribute>
2163+ </field>
2164+ <field name="product_qty"
2165+ position="attributes">
2166+ <attribute name="on_change">onchange_product_id(parent.pricelist_id,product_id,product_qty,product_uom,parent.partner_id,parent.date_order,parent.fiscal_position,date_planned,name,price_unit,context,parent.framework_agreement_id)</attribute>
2167+ </field>
2168+ </field>
2169+ </record>
2170+
2171+ <record id="add_onchange_on_pruchase_order_line_form_standalone" model="ir.ui.view">
2172+ <field name="name">add onchange on pruchase order line form standalone</field>
2173+ <field name="model">purchase.order.line</field>
2174+ <field name="inherit_id" ref="purchase.purchase_order_line_form" />
2175+ <field name="arch" type="xml">
2176+ <field name="price_unit" position="attributes">
2177+ <attribute name="on_change">on_change="onchange_price(price_unit, parent.framework_agreement_id, product_qty, parent.pricelist_id)</attribute>
2178+ </field>
2179+ </field>
2180+ </record>
2181+ </data>
2182+</openerp>

Subscribers

People subscribed via source and target branches