[IMP] event,event_sale: refactoring; remove crappy 9999 hardcoded values; remove...
[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'] + 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 action_done(self, cr, uid, ids, context=None):
298         return self.write(cr, uid, ids, {'payment_next_action_date': False, 'payment_next_action':'', 'payment_responsible_id': False}, context=context)
299
300     def do_button_print(self, cr, uid, ids, context=None):
301         assert(len(ids) == 1)
302         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
303         #search if the partner has accounting entries to print. If not, it may not be present in the
304         #psql view the report is based on, so we need to stop the user here.
305         if not self.pool.get('account.move.line').search(cr, uid, [
306                                                                    ('partner_id', '=', ids[0]),
307                                                                    ('account_id.type', '=', 'receivable'),
308                                                                    ('reconcile_id', '=', False),
309                                                                    ('state', '!=', 'draft'),
310                                                                    ('company_id', '=', company_id),
311                                                                   ], context=context):
312             raise osv.except_osv(_('Error!'),_("The partner does not have any accounting entries to print in the overdue report for the current company."))
313         self.message_post(cr, uid, [ids[0]], body=_('Printed overdue payments report'), context=context)
314         #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])]
315         wizard_partner_ids = [ids[0] * 10000 + company_id]
316         followup_ids = self.pool.get('account_followup.followup').search(cr, uid, [('company_id', '=', company_id)], context=context)
317         if not followup_ids:
318             raise osv.except_osv(_('Error!'),_("There is no followup plan defined for the current company."))
319         data = {
320             'date': fields.date.today(),
321             'followup_id': followup_ids[0],
322         }
323         #call the print overdue report on this partner
324         return self.do_partner_print(cr, uid, wizard_partner_ids, data, context=context)
325
326     def _get_amounts_and_date(self, cr, uid, ids, name, arg, context=None):
327         '''
328         Function that computes values for the followup functional fields. Note that 'payment_amount_due'
329         is similar to 'credit' field on res.partner except it filters on user's company.
330         '''
331         res = {}
332         company = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id
333         current_date = fields.date.context_today(self, cr, uid, context=context)
334         for partner in self.browse(cr, uid, ids, context=context):
335             worst_due_date = False
336             amount_due = amount_overdue = 0.0
337             for aml in partner.unreconciled_aml_ids:
338                 if (aml.company_id == company):
339                     date_maturity = aml.date_maturity or aml.date
340                     if not worst_due_date or date_maturity < worst_due_date:
341                         worst_due_date = date_maturity
342                     amount_due += aml.result
343                     if (date_maturity <= current_date):
344                         amount_overdue += aml.result
345             res[partner.id] = {'payment_amount_due': amount_due, 
346                                'payment_amount_overdue': amount_overdue, 
347                                'payment_earliest_due_date': worst_due_date}
348         return res
349
350     def _get_followup_overdue_query(self, cr, uid, args, overdue_only=False, context=None):
351         '''
352         This function is used to build the query and arguments to use when making a search on functional fields
353             * payment_amount_due
354             * payment_amount_overdue
355         Basically, the query is exactly the same except that for overdue there is an extra clause in the WHERE.
356
357         :param args: arguments given to the search in the usual domain notation (list of tuples)
358         :param overdue_only: option to add the extra argument to filter on overdue accounting entries or not
359         :returns: a tuple with
360             * the query to execute as first element
361             * the arguments for the execution of this query
362         :rtype: (string, [])
363         '''
364         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
365         having_where_clause = ' AND '.join(map(lambda x: '(SUM(bal2) %s %%s)' % (x[1]), args))
366         having_values = [x[2] for x in args]
367         query = self.pool.get('account.move.line')._query_get(cr, uid, context=context)
368         overdue_only_str = overdue_only and 'AND date_maturity <= NOW()' or ''
369         return ('''SELECT pid AS partner_id, SUM(bal2) FROM
370                     (SELECT CASE WHEN bal IS NOT NULL THEN bal
371                     ELSE 0.0 END AS bal2, p.id as pid FROM
372                     (SELECT (debit-credit) AS bal, partner_id
373                     FROM account_move_line l
374                     WHERE account_id IN
375                             (SELECT id FROM account_account
376                             WHERE type=\'receivable\' AND active)
377                     ''' + overdue_only_str + '''
378                     AND reconcile_id IS NULL
379                     AND company_id = %s
380                     AND ''' + query + ''') AS l
381                     RIGHT JOIN res_partner p
382                     ON p.id = partner_id ) AS pl
383                     GROUP BY pid HAVING ''' + having_where_clause, [company_id] + having_values)
384
385     def _payment_overdue_search(self, cr, uid, obj, name, args, context=None):
386         if not args:
387             return []
388         query, query_args = self._get_followup_overdue_query(cr, uid, args, overdue_only=True, context=context)
389         cr.execute(query, query_args)
390         res = cr.fetchall()
391         if not res:
392             return [('id','=','0')]
393         return [('id','in', [x[0] for x in res])]
394
395     def _payment_earliest_date_search(self, cr, uid, obj, name, args, context=None):
396         if not args:
397             return []
398         company_id = self.pool.get('res.users').browse(cr, uid, uid, context=context).company_id.id
399         having_where_clause = ' AND '.join(map(lambda x: '(MIN(l.date_maturity) %s %%s)' % (x[1]), args))
400         having_values = [x[2] for x in args]
401         query = self.pool.get('account.move.line')._query_get(cr, uid, context=context)
402         cr.execute('SELECT partner_id FROM account_move_line l '\
403                     'WHERE account_id IN '\
404                         '(SELECT id FROM account_account '\
405                         'WHERE type=\'receivable\' AND active) '\
406                     'AND l.company_id = %s '
407                     'AND reconcile_id IS NULL '\
408                     'AND '+query+' '\
409                     'AND partner_id IS NOT NULL '\
410                     'GROUP BY partner_id HAVING '+ having_where_clause,
411                      [company_id] + having_values)
412         res = cr.fetchall()
413         if not res:
414             return [('id','=','0')]
415         return [('id','in', [x[0] for x in res])]
416
417     def _payment_due_search(self, cr, uid, obj, name, args, context=None):
418         if not args:
419             return []
420         query, query_args = self._get_followup_overdue_query(cr, uid, args, overdue_only=False, context=context)
421         cr.execute(query, query_args)
422         res = cr.fetchall()
423         if not res:
424             return [('id','=','0')]
425         return [('id','in', [x[0] for x in res])]
426
427     def _get_partners(self, cr, uid, ids, context=None):
428         #this function search for the partners linked to all account.move.line 'ids' that have been changed
429         partners = set()
430         for aml in self.browse(cr, uid, ids, context=context):
431             if aml.partner_id:
432                 partners.add(aml.partner_id.id)
433         return list(partners)
434
435     _inherit = "res.partner"
436     _columns = {
437         'payment_responsible_id':fields.many2one('res.users', ondelete='set null', string='Follow-up Responsible', 
438                                                  help="Optionally you can assign a user to this field, which will make him responsible for the action.", 
439                                                  track_visibility="onchange"), 
440         'payment_note':fields.text('Customer Payment Promise', help="Payment Note", track_visibility="onchange"),
441         'payment_next_action':fields.text('Next Action', 
442                                     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. ", 
443                                     track_visibility="onchange"), 
444         'payment_next_action_date':fields.date('Next Action Date',
445                                     help="This is when the manual follow-up is needed. " \
446                                     "The date will be set to the current date when the partner gets a follow-up level that requires a manual action. "\
447                                     "Can be practical to set manually e.g. to see if he keeps his promises."),
448         'unreconciled_aml_ids':fields.one2many('account.move.line', 'partner_id', domain=['&', ('reconcile_id', '=', False), '&', 
449                             ('account_id.active','=', True), '&', ('account_id.type', '=', 'receivable'), ('state', '!=', 'draft')]), 
450         'latest_followup_date':fields.function(_get_latest, method=True, type='date', string="Latest Follow-up Date", 
451                             help="Latest date that the follow-up level of the partner was changed", 
452                             store=False, multi="latest"), 
453         'latest_followup_level_id':fields.function(_get_latest, method=True, 
454             type='many2one', relation='account_followup.followup.line', string="Latest Follow-up Level", 
455             help="The maximum follow-up level", 
456             store={
457                 'res.partner': (lambda self, cr, uid, ids, c: ids,[],10),
458                 'account.move.line': (_get_partners, ['followup_line_id'], 10),
459             }, 
460             multi="latest"), 
461         'latest_followup_level_id_without_lit':fields.function(_get_latest, method=True, 
462             type='many2one', relation='account_followup.followup.line', string="Latest Follow-up Level without litigation", 
463             help="The maximum follow-up level without taking into account the account move lines with litigation", 
464             store={
465                 'res.partner': (lambda self, cr, uid, ids, c: ids,[],10),
466                 'account.move.line': (_get_partners, ['followup_line_id'], 10),
467             }, 
468             multi="latest"),
469         'payment_amount_due':fields.function(_get_amounts_and_date, 
470                                                  type='float', string="Amount Due",
471                                                  store = False, multi="followup", 
472                                                  fnct_search=_payment_due_search),
473         'payment_amount_overdue':fields.function(_get_amounts_and_date,
474                                                  type='float', string="Amount Overdue",
475                                                  store = False, multi="followup", 
476                                                  fnct_search = _payment_overdue_search),
477         'payment_earliest_due_date':fields.function(_get_amounts_and_date,
478                                                     type='date',
479                                                     string = "Worst Due Date",
480                                                     multi="followup",
481                                                     fnct_search=_payment_earliest_date_search),
482         }
483
484 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: