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