[MERGE] forward port of branch 7.0 up to 78a29b3
[odoo/odoo.git] / addons / account_followup / account_followup.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from openerp.osv import fields, osv
23 from lxml import etree
24
25 from openerp.tools.translate import _
26
27 class followup(osv.osv):
28     _name = 'account_followup.followup'
29     _description = 'Account Follow-up'
30     _rec_name = 'name'
31     _columns = {
32         'followup_line': fields.one2many('account_followup.followup.line', 'followup_id', 'Follow-up'),
33         'company_id': fields.many2one('res.company', 'Company', required=True),
34         'name': fields.related('company_id', 'name', string = "Name"),
35     }
36     _defaults = {
37         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'account_followup.followup', context=c),
38     }
39     _sql_constraints = [('company_uniq', 'unique(company_id)', 'Only one follow-up per company is allowed')] 
40
41
42 class followup_line(osv.osv):
43
44     def _get_default_template(self, cr, uid, ids, context=None):
45         try:
46             return self.pool.get('ir.model.data').get_object_reference(cr, uid, 'account_followup', 'email_template_account_followup_default')[1]
47         except ValueError:
48             return False
49
50     _name = 'account_followup.followup.line'
51     _description = 'Follow-up Criteria'
52     _columns = {
53         'name': fields.char('Follow-Up Action', size=64, required=True),
54         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of follow-up lines."),
55         'delay': fields.integer('Due Days', help="The number of days after the due date of the invoice to wait before sending the reminder.  Could be negative if you want to send a polite alert beforehand.", required=True),
56         'followup_id': fields.many2one('account_followup.followup', 'Follow Ups', required=True, ondelete="cascade"),
57         'description': fields.text('Printed Message', translate=True),
58         'send_email':fields.boolean('Send an Email', help="When processing, it will send an email"),
59         'send_letter':fields.boolean('Send a Letter', help="When processing, it will print a letter"),
60         'manual_action':fields.boolean('Manual Action', help="When processing, it will set the manual action to be taken for that customer. "),
61         'manual_action_note':fields.text('Action To Do', placeholder="e.g. Give a phone call, check with others , ..."),
62         'manual_action_responsible_id':fields.many2one('res.users', 'Assign a Responsible', ondelete='set null'),
63         'email_template_id':fields.many2one('email.template', 'Email Template', ondelete='set null'),
64     }
65     _order = 'delay'
66     _sql_constraints = [('days_uniq', 'unique(followup_id, delay)', 'Days of the follow-up levels must be different')]
67     _defaults = {
68         'send_email': True,
69         'send_letter': True,
70         'manual_action':False,
71         'description': """
72         Dear %(partner_name)s,
73
74 Exception made if there was a mistake of ours, it seems that the following amount stays unpaid. Please, take appropriate measures in order to carry out this payment in the next 8 days.
75
76 Would your payment have been carried out after this mail was sent, please ignore this message. Do not hesitate to contact our accounting department.
77
78 Best Regards,
79 """,
80     'email_template_id': _get_default_template,
81     }
82
83
84     def _check_description(self, cr, uid, ids, context=None):
85         for line in self.browse(cr, uid, ids, context=context):
86             if line.description:
87                 try:
88                     line.description % {'partner_name': '', 'date':'', 'user_signature': '', 'company_name': ''}
89                 except:
90                     return False
91         return True
92
93     _constraints = [
94         (_check_description, 'Your description is invalid, use the right legend or %% if you want to use the percent character.', ['description']),
95     ]
96
97
98 class account_move_line(osv.osv):
99
100     def _get_result(self, cr, uid, ids, name, arg, context=None):
101         res = {}
102         for aml in self.browse(cr, uid, ids, context=context):
103             res[aml.id] = aml.debit - aml.credit
104         return res
105
106     _inherit = 'account.move.line'
107     _columns = {
108         'followup_line_id': fields.many2one('account_followup.followup.line', 'Follow-up Level', 
109                                         ondelete='restrict'), #restrict deletion of the followup line
110         'followup_date': fields.date('Latest Follow-up', select=True),
111         'result':fields.function(_get_result, type='float', method=True, 
112                                 string="Balance") #'balance' field is not the same
113     }
114
115
116 class res_partner(osv.osv):
117
118     def fields_view_get(self, cr, uid, view_id=None, view_type=None, context=None, toolbar=False, submenu=False):
119         res = super(res_partner, self).fields_view_get(cr, uid, view_id=view_id, view_type=view_type, context=context,
120                                                        toolbar=toolbar, submenu=submenu)
121         context = context or {}
122         if view_type == 'form' and context.get('Followupfirst'):
123             doc = etree.XML(res['arch'], parser=None, base_url=None)
124             first_node = doc.xpath("//page[@name='followup_tab']")
125             root = first_node[0].getparent()
126             root.insert(0, first_node[0])
127             res['arch'] = etree.tostring(doc, encoding="utf-8")
128         return res
129
130     def _get_latest(self, cr, uid, ids, names, arg, context=None, company_id=None):
131         res={}
132         if company_id == None:
133             company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id
134         else:
135             company = self.pool.get('res.company').browse(cr, uid, company_id, context=context)
136         for partner in self.browse(cr, uid, ids, context=context):
137             amls = partner.unreconciled_aml_ids
138             latest_date = False
139             latest_level = False
140             latest_days = False
141             latest_level_without_lit = False
142             latest_days_without_lit = False
143             for aml in amls:
144                 if (aml.company_id == company) and (aml.followup_line_id != False) and (not latest_days or latest_days < aml.followup_line_id.delay):
145                     latest_days = aml.followup_line_id.delay
146                     latest_level = aml.followup_line_id.id
147                 if (aml.company_id == company) and (not latest_date or latest_date < aml.followup_date):
148                     latest_date = aml.followup_date
149                 if (aml.company_id == company) and (aml.blocked == False) and (aml.followup_line_id != False and 
150                             (not latest_days_without_lit or latest_days_without_lit < aml.followup_line_id.delay)):
151                     latest_days_without_lit =  aml.followup_line_id.delay
152                     latest_level_without_lit = aml.followup_line_id.id
153             res[partner.id] = {'latest_followup_date': latest_date,
154                                'latest_followup_level_id': latest_level,
155                                'latest_followup_level_id_without_lit': latest_level_without_lit}
156         return res
157
158     def do_partner_manual_action(self, cr, uid, partner_ids, context=None): 
159         #partner_ids -> res.partner
160         for partner in self.browse(cr, uid, partner_ids, context=context):
161             #Check action: check if the action was not empty, if not add
162             action_text= ""
163             if partner.payment_next_action:
164                 action_text = (partner.payment_next_action or '') + "\n" + (partner.latest_followup_level_id_without_lit.manual_action_note or '')
165             else:
166                 action_text = partner.latest_followup_level_id_without_lit.manual_action_note or ''
167
168             #Check date: only change when it did not exist already
169             action_date = partner.payment_next_action_date or fields.date.context_today(self, cr, uid, context=context)
170
171             # Check responsible: if partner has not got a responsible already, take from follow-up
172             responsible_id = False
173             if partner.payment_responsible_id:
174                 responsible_id = partner.payment_responsible_id.id
175             else:
176                 p = partner.latest_followup_level_id_without_lit.manual_action_responsible_id
177                 responsible_id = p and p.id or False
178             self.write(cr, uid, [partner.id], {'payment_next_action_date': action_date,
179                                         'payment_next_action': action_text,
180                                         'payment_responsible_id': responsible_id})
181
182     def do_partner_print(self, cr, uid, wizard_partner_ids, data, context=None):
183         #wizard_partner_ids are ids from special view, not from res.partner
184         if not wizard_partner_ids:
185             return {}
186         data['partner_ids'] = wizard_partner_ids
187         datas = {
188              'ids': wizard_partner_ids,
189              'model': 'account_followup.followup',
190              'form': data
191         }
192         return {
193             'type': 'ir.actions.report.xml',
194             'report_name': 'account_followup.followup.print',
195             'datas': datas,
196             }
197
198     def do_partner_mail(self, cr, uid, partner_ids, context=None):
199         if context is None:
200             context = {}
201         ctx = context.copy()
202         ctx['followup'] = True
203         #partner_ids are res.partner ids
204         # If not defined by latest follow-up level, it will be the default template if it can find it
205         mtp = self.pool.get('email.template')
206         unknown_mails = 0
207         for partner in self.browse(cr, uid, partner_ids, context=ctx):
208             if partner.email and partner.email.strip():
209                 level = partner.latest_followup_level_id_without_lit
210                 if level and level.send_email and level.email_template_id and level.email_template_id.id:
211                     mtp.send_mail(cr, uid, level.email_template_id.id, partner.id, context=ctx)
212                 else:
213                     mail_template_id = self.pool.get('ir.model.data').get_object_reference(cr, uid, 
214                                                     'account_followup', 'email_template_account_followup_default')
215                     mtp.send_mail(cr, uid, mail_template_id[1], partner.id, context=ctx)
216             else:
217                 unknown_mails = unknown_mails + 1
218                 action_text = _("Email not sent because of email address of partner not filled in")
219                 if partner.payment_next_action_date:
220                     payment_action_date = min(fields.date.context_today(self, cr, uid, context=ctx), partner.payment_next_action_date)
221                 else:
222                     payment_action_date = fields.date.context_today(self, cr, uid, context=ctx)
223                 if partner.payment_next_action:
224                     payment_next_action = partner.payment_next_action + " \n " + action_text
225                 else:
226                     payment_next_action = action_text
227                 self.write(cr, uid, [partner.id], {'payment_next_action_date': payment_action_date,
228                                                    'payment_next_action': payment_next_action}, context=ctx)
229         return unknown_mails
230
231     def get_followup_table_html(self, cr, uid, ids, context=None):
232         """ Build the html tables to be included in emails send to partners,
233             when reminding them their overdue invoices.
234             :param ids: [id] of the partner for whom we are building the tables
235             :rtype: string
236         """
237         from report import account_followup_print
238
239         assert len(ids) == 1
240         if context is None:
241             context = {}
242         partner = self.browse(cr, uid, ids[0], context=context)
243         #copy the context to not change global context. Overwrite it because _() looks for the lang in local variable 'context'.
244         #Set the language to use = the partner language
245         context = dict(context, lang=partner.lang)
246         followup_table = ''
247         if partner.unreconciled_aml_ids:
248             company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id
249             current_date = fields.date.context_today(self, cr, uid, context=context)
250             rml_parse = account_followup_print.report_rappel(cr, uid, "followup_rml_parser")
251             final_res = rml_parse._lines_get_with_partner(partner, company.id)
252
253             for currency_dict in final_res:
254                 currency = currency_dict.get('line', [{'currency_id': company.currency_id}])[0]['currency_id']
255                 followup_table += '''
256                 <table border="2" width=100%%>
257                 <tr>
258                     <td>''' + _("Invoice Date") + '''</td>
259                     <td>''' + _("Description") + '''</td>
260                     <td>''' + _("Reference") + '''</td>
261                     <td>''' + _("Due Date") + '''</td>
262                     <td>''' + _("Amount") + " (%s)" % (currency.symbol) + '''</td>
263                     <td>''' + _("Lit.") + '''</td>
264                 </tr>
265                 ''' 
266                 total = 0
267                 for aml in currency_dict['line']:
268                     block = aml['blocked'] and 'X' or ' '
269                     total += aml['balance']
270                     strbegin = "<TD>"
271                     strend = "</TD>"
272                     date = aml['date_maturity'] or aml['date']
273                     if date <= current_date and aml['balance'] > 0:
274                         strbegin = "<TD><B>"
275                         strend = "</B></TD>"
276                     followup_table +="<TR>" + strbegin + str(aml['date']) + strend + strbegin + aml['name'] + strend + strbegin + (aml['ref'] or '') + strend + strbegin + str(date) + strend + strbegin + str(aml['balance']) + strend + strbegin + block + strend + "</TR>"
277                 total = rml_parse.formatLang(total, dp='Account', currency_obj=currency)
278                 followup_table += '''<tr> </tr>
279                                 </table>
280                                 <center>''' + _("Amount due") + ''' : %s </center>''' % (total)
281         return followup_table
282
283     def write(self, cr, uid, ids, vals, context=None):
284         if vals.get("payment_responsible_id", False):
285             for part in self.browse(cr, uid, ids, context=context):
286                 if part.payment_responsible_id <> vals["payment_responsible_id"]:
287                     #Find partner_id of user put as responsible
288                     responsible_partner_id = self.pool.get("res.users").browse(cr, uid, vals['payment_responsible_id'], context=context).partner_id.id
289                     self.pool.get("mail.thread").message_post(cr, uid, 0, 
290                                       body = _("You became responsible to do the next action for the payment follow-up of") + " <b><a href='#id=" + str(part.id) + "&view_type=form&model=res.partner'> " + part.name + " </a></b>",
291                                       type = 'comment',
292                                       subtype = "mail.mt_comment", context = context,
293                                       model = 'res.partner', res_id = part.id, 
294                                       partner_ids = [responsible_partner_id])
295         return super(res_partner, self).write(cr, uid, ids, vals, context=context)
296     
297     def copy(self, cr, uid, record_id, default=None, context=None):
298         if default is None:
299             default = {}
300
301         default.update({'unreconciled_aml_ids': []})
302         return super(res_partner, self).copy(cr, uid, record_id, default, context) 
303
304     def action_done(self, cr, uid, ids, context=None):
305         return self.write(cr, uid, ids, {'payment_next_action_date': False, 'payment_next_action':'', 'payment_responsible_id': False}, context=context)
306
307     def do_button_print(self, cr, uid, ids, context=None):
308         assert(len(ids) == 1)
309         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
310         #search if the partner has accounting entries to print. If not, it may not be present in the
311         #psql view the report is based on, so we need to stop the user here.
312         if not self.pool.get('account.move.line').search(cr, uid, [
313                                                                    ('partner_id', '=', ids[0]),
314                                                                    ('account_id.type', '=', 'receivable'),
315                                                                    ('reconcile_id', '=', False),
316                                                                    ('state', '!=', 'draft'),
317                                                                    ('company_id', '=', company_id),
318                                                                   ], context=context):
319             raise osv.except_osv(_('Error!'),_("The partner does not have any accounting entries to print in the overdue report for the current company."))
320         self.message_post(cr, uid, [ids[0]], body=_('Printed overdue payments report'), context=context)
321         #build the id of this partner in the psql view. Could be replaced by a search with [('company_id', '=', company_id),('partner_id', '=', ids[0])]
322         wizard_partner_ids = [ids[0] * 10000 + company_id]
323         followup_ids = self.pool.get('account_followup.followup').search(cr, uid, [('company_id', '=', company_id)], context=context)
324         if not followup_ids:
325             raise osv.except_osv(_('Error!'),_("There is no followup plan defined for the current company."))
326         data = {
327             'date': fields.date.today(),
328             'followup_id': followup_ids[0],
329         }
330         #call the print overdue report on this partner
331         return self.do_partner_print(cr, uid, wizard_partner_ids, data, context=context)
332
333     def _get_amounts_and_date(self, cr, uid, ids, name, arg, context=None):
334         '''
335         Function that computes values for the followup functional fields. Note that 'payment_amount_due'
336         is similar to 'credit' field on res.partner except it filters on user's company.
337         '''
338         res = {}
339         company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id
340         current_date = fields.date.context_today(self, cr, uid, context=context)
341         for partner in self.browse(cr, uid, ids, context=context):
342             worst_due_date = False
343             amount_due = amount_overdue = 0.0
344             for aml in partner.unreconciled_aml_ids:
345                 if (aml.company_id == company):
346                     date_maturity = aml.date_maturity or aml.date
347                     if not worst_due_date or date_maturity < worst_due_date:
348                         worst_due_date = date_maturity
349                     amount_due += aml.result
350                     if (date_maturity <= current_date):
351                         amount_overdue += aml.result
352             res[partner.id] = {'payment_amount_due': amount_due, 
353                                'payment_amount_overdue': amount_overdue, 
354                                'payment_earliest_due_date': worst_due_date}
355         return res
356
357     def _get_followup_overdue_query(self, cr, uid, args, overdue_only=False, context=None):
358         '''
359         This function is used to build the query and arguments to use when making a search on functional fields
360             * payment_amount_due
361             * payment_amount_overdue
362         Basically, the query is exactly the same except that for overdue there is an extra clause in the WHERE.
363
364         :param args: arguments given to the search in the usual domain notation (list of tuples)
365         :param overdue_only: option to add the extra argument to filter on overdue accounting entries or not
366         :returns: a tuple with
367             * the query to execute as first element
368             * the arguments for the execution of this query
369         :rtype: (string, [])
370         '''
371         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
372         having_where_clause = ' AND '.join(map(lambda x: '(SUM(bal2) %s %%s)' % (x[1]), args))
373         having_values = [x[2] for x in args]
374         query = self.pool.get('account.move.line')._query_get(cr, uid, context=context)
375         overdue_only_str = overdue_only and 'AND date_maturity <= NOW()' or ''
376         return ('''SELECT pid AS partner_id, SUM(bal2) FROM
377                     (SELECT CASE WHEN bal IS NOT NULL THEN bal
378                     ELSE 0.0 END AS bal2, p.id as pid FROM
379                     (SELECT (debit-credit) AS bal, partner_id
380                     FROM account_move_line l
381                     WHERE account_id IN
382                             (SELECT id FROM account_account
383                             WHERE type=\'receivable\' AND active)
384                     ''' + overdue_only_str + '''
385                     AND reconcile_id IS NULL
386                     AND company_id = %s
387                     AND ''' + query + ''') AS l
388                     RIGHT JOIN res_partner p
389                     ON p.id = partner_id ) AS pl
390                     GROUP BY pid HAVING ''' + having_where_clause, [company_id] + having_values)
391
392     def _payment_overdue_search(self, cr, uid, obj, name, args, context=None):
393         if not args:
394             return []
395         query, query_args = self._get_followup_overdue_query(cr, uid, args, overdue_only=True, context=context)
396         cr.execute(query, query_args)
397         res = cr.fetchall()
398         if not res:
399             return [('id','=','0')]
400         return [('id','in', [x[0] for x in res])]
401
402     def _payment_earliest_date_search(self, cr, uid, obj, name, args, context=None):
403         if not args:
404             return []
405         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
406         having_where_clause = ' AND '.join(map(lambda x: '(MIN(l.date_maturity) %s %%s)' % (x[1]), args))
407         having_values = [x[2] for x in args]
408         query = self.pool.get('account.move.line')._query_get(cr, uid, context=context)
409         cr.execute('SELECT partner_id FROM account_move_line l '\
410                     'WHERE account_id IN '\
411                         '(SELECT id FROM account_account '\
412                         'WHERE type=\'receivable\' AND active) '\
413                     'AND l.company_id = %s '
414                     'AND reconcile_id IS NULL '\
415                     'AND '+query+' '\
416                     'AND partner_id IS NOT NULL '\
417                     'GROUP BY partner_id HAVING '+ having_where_clause,
418                      [company_id] + having_values)
419         res = cr.fetchall()
420         if not res:
421             return [('id','=','0')]
422         return [('id','in', [x[0] for x in res])]
423
424     def _payment_due_search(self, cr, uid, obj, name, args, context=None):
425         if not args:
426             return []
427         query, query_args = self._get_followup_overdue_query(cr, uid, args, overdue_only=False, context=context)
428         cr.execute(query, query_args)
429         res = cr.fetchall()
430         if not res:
431             return [('id','=','0')]
432         return [('id','in', [x[0] for x in res])]
433
434     def _get_partners(self, cr, uid, ids, context=None):
435         #this function search for the partners linked to all account.move.line 'ids' that have been changed
436         partners = set()
437         for aml in self.browse(cr, uid, ids, context=context):
438             if aml.partner_id:
439                 partners.add(aml.partner_id.id)
440         return list(partners)
441
442     _inherit = "res.partner"
443     _columns = {
444         'payment_responsible_id':fields.many2one('res.users', ondelete='set null', string='Follow-up Responsible', 
445                                                  help="Optionally you can assign a user to this field, which will make him responsible for the action.", 
446                                                  track_visibility="onchange"), 
447         'payment_note':fields.text('Customer Payment Promise', help="Payment Note", track_visibility="onchange"),
448         'payment_next_action':fields.text('Next Action', 
449                                     help="This is the next action to be taken.  It will automatically be set when the partner gets a follow-up level that requires a manual action. ", 
450                                     track_visibility="onchange"), 
451         'payment_next_action_date':fields.date('Next Action Date',
452                                     help="This is when the manual follow-up is needed. " \
453                                     "The date will be set to the current date when the partner gets a follow-up level that requires a manual action. "\
454                                     "Can be practical to set manually e.g. to see if he keeps his promises."),
455         'unreconciled_aml_ids':fields.one2many('account.move.line', 'partner_id', domain=['&', ('reconcile_id', '=', False), '&', 
456                             ('account_id.active','=', True), '&', ('account_id.type', '=', 'receivable'), ('state', '!=', 'draft')]), 
457         'latest_followup_date':fields.function(_get_latest, method=True, type='date', string="Latest Follow-up Date", 
458                             help="Latest date that the follow-up level of the partner was changed", 
459                             store=False, multi="latest"), 
460         'latest_followup_level_id':fields.function(_get_latest, method=True, 
461             type='many2one', relation='account_followup.followup.line', string="Latest Follow-up Level", 
462             help="The maximum follow-up level", 
463             store={
464                 'res.partner': (lambda self, cr, uid, ids, c: ids,[],10),
465                 'account.move.line': (_get_partners, ['followup_line_id'], 10),
466             }, 
467             multi="latest"), 
468         'latest_followup_level_id_without_lit':fields.function(_get_latest, method=True, 
469             type='many2one', relation='account_followup.followup.line', string="Latest Follow-up Level without litigation", 
470             help="The maximum follow-up level without taking into account the account move lines with litigation", 
471             store={
472                 'res.partner': (lambda self, cr, uid, ids, c: ids,[],10),
473                 'account.move.line': (_get_partners, ['followup_line_id'], 10),
474             }, 
475             multi="latest"),
476         'payment_amount_due':fields.function(_get_amounts_and_date, 
477                                                  type='float', string="Amount Due",
478                                                  store = False, multi="followup", 
479                                                  fnct_search=_payment_due_search),
480         'payment_amount_overdue':fields.function(_get_amounts_and_date,
481                                                  type='float', string="Amount Overdue",
482                                                  store = False, multi="followup", 
483                                                  fnct_search = _payment_overdue_search),
484         'payment_earliest_due_date':fields.function(_get_amounts_and_date,
485                                                     type='date',
486                                                     string = "Worst Due Date",
487                                                     multi="followup",
488                                                     fnct_search=_payment_earliest_date_search),
489         }
490
491 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: