diff --git a/0 b/0 new file mode 100644 index 00000000000..e69de29bb2d diff --git a/addons/account/account_analytic_line.py b/addons/account/account_analytic_line.py new file mode 100644 index 00000000000..2f7d4b1a2d9 --- /dev/null +++ b/addons/account/account_analytic_line.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# OpenERP, Open Source Management Solution +# Copyright (C) 2004-2010 Tiny SPRL (). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +# +############################################################################## + +from openerp.osv import fields +from openerp.osv import osv +from openerp.tools.translate import _ +from openerp.exceptions import UserError + +class account_analytic_line(osv.osv): + _inherit = 'account.analytic.line' + _description = 'Analytic Line' + _columns = { + 'product_uom_id': fields.many2one('product.uom', 'Unit of Measure'), + 'product_id': fields.many2one('product.product', 'Product'), + 'general_account_id': fields.many2one('account.account', 'Financial Account', required=True, ondelete='restrict'), + 'move_id': fields.many2one('account.move.line', 'Move Line', ondelete='cascade', select=True), + 'journal_id': fields.many2one('account.analytic.journal', 'Analytic Journal', required=True, ondelete='restrict', select=True), + 'code': fields.char('Code', size=8), + 'ref': fields.char('Ref.'), + 'currency_id': fields.related('move_id', 'currency_id', type='many2one', relation='res.currency', string='Account Currency', store=True, help="The related account currency if not equal to the company one.", readonly=True), + 'amount_currency': fields.related('move_id', 'amount_currency', type='float', string='Amount Currency', store=True, help="The amount expressed in the related account currency if not equal to the company one.", readonly=True), + } + + _defaults = { + 'company_id': lambda self,cr,uid,c: self.pool.get('res.company')._company_default_get(cr, uid, 'account.analytic.line', context=c), + } + _order = 'date desc' + + def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False): + if context is None: + context = {} + if context.get('from_date',False): + args.append(['date', '>=', context['from_date']]) + if context.get('to_date',False): + args.append(['date','<=', context['to_date']]) + return super(account_analytic_line, self).search(cr, uid, args, offset, limit, + order, context=context, count=count) + + def _check_company(self, cr, uid, ids, context=None): + lines = self.browse(cr, uid, ids, context=context) + for l in lines: + if l.move_id and not l.account_id.company_id.id == l.move_id.account_id.company_id.id: + return False + return True + + # Compute the cost based on the price type define into company + # property_valuation_price_type property + def on_change_unit_amount(self, cr, uid, id, prod_id, quantity, company_id, + unit=False, journal_id=False, context=None): + if context==None: + context={} + if not journal_id: + j_ids = self.pool.get('account.analytic.journal').search(cr, uid, [('type','=','purchase')]) + journal_id = j_ids and j_ids[0] or False + if not journal_id or not prod_id: + return {} + product_obj = self.pool.get('product.product') + analytic_journal_obj =self.pool.get('account.analytic.journal') + product_price_type_obj = self.pool.get('product.price.type') + product_uom_obj = self.pool.get('product.uom') + j_id = analytic_journal_obj.browse(cr, uid, journal_id, context=context) + prod = product_obj.browse(cr, uid, prod_id, context=context) + result = 0.0 + if prod_id: + unit_obj = False + if unit: + unit_obj = product_uom_obj.browse(cr, uid, unit, context=context) + if not unit_obj or prod.uom_id.category_id.id != unit_obj.category_id.id: + unit = prod.uom_id.id + if j_id.type == 'purchase': + if not unit_obj or prod.uom_po_id.category_id.id != unit_obj.category_id.id: + unit = prod.uom_po_id.id + if j_id.type <> 'sale': + a = prod.property_account_expense.id + if not a: + a = prod.categ_id.property_account_expense_categ.id + if not a: + raise UserError( + _('There is no expense account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (prod.name, prod.id,)) + else: + a = prod.property_account_income.id + if not a: + a = prod.categ_id.property_account_income_categ.id + if not a: + raise UserError( + _('There is no income account defined ' \ + 'for this product: "%s" (id:%d).') % \ + (prod.name, prod_id,)) + + flag = False + # Compute based on pricetype + product_price_type_ids = product_price_type_obj.search(cr, uid, [('field','=','standard_price')], context=context) + pricetype = product_price_type_obj.browse(cr, uid, product_price_type_ids, context=context)[0] + if journal_id: + journal = analytic_journal_obj.browse(cr, uid, journal_id, context=context) + if journal.type == 'sale': + product_price_type_ids = product_price_type_obj.search(cr, uid, [('field','=','list_price')], context=context) + if product_price_type_ids: + pricetype = product_price_type_obj.browse(cr, uid, product_price_type_ids, context=context)[0] + # Take the company currency as the reference one + if pricetype.field == 'list_price': + flag = True + ctx = context.copy() + if unit: + # price_get() will respect a 'uom' in its context, in order + # to return a default price for those units + ctx['uom'] = unit + amount_unit = prod.price_get(pricetype.field, context=ctx)[prod.id] + prec = self.pool.get('decimal.precision').precision_get(cr, uid, 'Account') + amount = amount_unit * quantity or 0.0 + result = round(amount, prec) + if not flag: + result *= -1 + return {'value': { + 'amount': result, + 'general_account_id': a, + 'product_uom_id': unit + } + } + + def view_header_get(self, cr, user, view_id, view_type, context=None): + if context is None: + context = {} + if context.get('account_id', False): + # account_id in context may also be pointing to an account.account.id + cr.execute('select name from account_analytic_account where id=%s', (context['account_id'],)) + res = cr.fetchone() + if res: + res = _('Entries: ')+ (res[0] or '') + return res + return False + + diff --git a/addons/analytic/models/analytic.py b/addons/analytic/models/analytic.py index 411476b1823..346d1d00877 100644 --- a/addons/analytic/models/analytic.py +++ b/addons/analytic/models/analytic.py @@ -156,6 +156,11 @@ class account_analytic_account(osv.osv): result[rec.id] = rec.currency_id.id return result + def _get_contract_type_selection(self, cr, uid, context=None): + return [('regular', 'Regular'), ('prepaid', 'Prepaid Support Hours')] + + _contract_type_selection = lambda self, *args, **kwargs: self._get_contract_type_selection(*args, **kwargs) + _columns = { 'name': fields.char('Account/Contract Name', required=True, track_visibility='onchange'), 'complete_name': fields.function(_get_full_name, type='char', string='Full Name'), @@ -178,7 +183,7 @@ class account_analytic_account(osv.osv): 'quantity_max': fields.float('Prepaid Service Units', help='Sets the higher limit of time to work on the contract, based on the timesheet. (for instance, number of hours in a limited support contract.)'), 'partner_id': fields.many2one('res.partner', 'Customer'), 'user_id': fields.many2one('res.users', 'Project Manager', track_visibility='onchange'), - 'manager_id': fields.many2one('res.users', 'Account Manager', track_visibility='onchange'), + 'manager_id': fields.many2one('res.users', 'Sales Rep', track_visibility='onchange'), 'date_start': fields.date('Start Date'), 'date': fields.date('Expiration Date', select=True, track_visibility='onchange'), 'company_id': fields.many2one('res.company', 'Company', required=False), #not required because we want to allow different companies to use the same chart of account, except for leaf accounts. @@ -188,6 +193,7 @@ class account_analytic_account(osv.osv): store = { 'res.company': (_get_analytic_account, ['currency_id'], 10), }, string='Currency', type='many2one', relation='res.currency'), + 'contract_type': fields.selection(_contract_type_selection, 'Type of Contract', required=True), } def create(self, cr, uid, vals, context=None): @@ -217,6 +223,7 @@ class account_analytic_account(osv.osv): res['value']['quantity_max'] = template.quantity_max res['value']['parent_id'] = template.parent_id and template.parent_id.id or False res['value']['description'] = template.description + res['value']['contract_type'] = template.contract_type return res def on_change_partner_id(self, cr, uid, ids,partner_id, name, context=None): @@ -249,6 +256,7 @@ class account_analytic_account(osv.osv): 'manager_id': lambda self, cr, uid, ctx: ctx.get('manager_id', False), 'date_start': lambda *a: time.strftime('%Y-%m-%d'), 'currency_id': _get_default_currency, + 'contract_type': 'regular', } def check_recursion(self, cr, uid, ids, context=None, parent=None): @@ -335,6 +343,7 @@ class account_analytic_line(osv.osv): 'user_id': fields.many2one('res.users', 'User'), 'company_id': fields.related('account_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True), 'journal_id': fields.many2one('account.analytic.journal', 'Analytic Journal', required=True, ondelete='restrict', select=True), + 'partner_id': fields.related('account_id', 'partner_id', type='many2one', relation='res.partner', string='Partner', store=True), } @@ -364,7 +373,6 @@ class account_analytic_line(osv.osv): (_check_no_view, 'You cannot create analytic line on view account.', ['account_id']), ] - class account_analytic_journal(osv.osv): _name = 'account.analytic.journal' _description = 'Analytic Journal' @@ -384,3 +392,12 @@ class account_analytic_journal(osv.osv): 'type': 'general', 'company_id': lambda self, cr, uid, c=None: self.pool.get('res.users').browse(cr, uid, uid, c).company_id.id, } + +class res_partner(osv.osv): + """ Inherits partner and adds contract information in the partner form """ + _inherit = 'res.partner' + + _columns = { + 'contract_ids': fields.one2many('account.analytic.account', \ + 'partner_id', 'Contracts', readonly=True), + } diff --git a/addons/analytic/views/analytic_view.xml b/addons/analytic/views/analytic_view.xml index 22620c0d7c3..a848b3d0a24 100644 --- a/addons/analytic/views/analytic_view.xml +++ b/addons/analytic/views/analytic_view.xml @@ -6,7 +6,7 @@ analytic.analytic.account.form account.analytic.account -
+
@@ -20,39 +20,28 @@ - - - + + + - - - + + + + + + - - -

- Once the end date of the contract is - passed or the maximum number of service - units (e.g. support contract) is - reached, the account manager is notified - by email to renew the contract with the - customer. -

- -
- - -
+ +
diff --git a/addons/analytic_user_function/analytic_user_function_view.xml b/addons/analytic_user_function/analytic_user_function_view.xml index ff434cdd437..00743547e80 100644 --- a/addons/analytic_user_function/analytic_user_function_view.xml +++ b/addons/analytic_user_function/analytic_user_function_view.xml @@ -39,7 +39,7 @@ -
+

Define a specific service (e.g. Senior Consultant) diff --git a/addons/hr_timesheet_invoice/hr_timesheet_invoice.py b/addons/hr_timesheet_invoice/hr_timesheet_invoice.py index ef33bb2938f..51aa1e75408 100644 --- a/addons/hr_timesheet_invoice/hr_timesheet_invoice.py +++ b/addons/hr_timesheet_invoice/hr_timesheet_invoice.py @@ -33,7 +33,8 @@ class account_analytic_account(osv.osv): } _defaults = { - 'pricelist_id': lambda self, cr, uid, c: self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'product.list0') + 'pricelist_id': lambda self, cr, uid, c: self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'product.list0'), + 'to_invoice': lambda self, cr, uid, c: self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, 'hr_timesheet_invoice.timesheet_invoice_factor1') } def on_change_partner_id(self, cr, uid, ids, partner_id, name, context=None): diff --git a/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml b/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml index 77b3b25e14f..8ad54199fcc 100644 --- a/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml +++ b/addons/hr_timesheet_invoice/hr_timesheet_invoice_view.xml @@ -7,12 +7,6 @@ 30 - - - - - -

+ ]]> + + + + + email_payment_reminder + ${(object.manager_id.email or object.company_id.email or 'noreply@localhost')|safe} + Payment reminder for subscription ${object.code} + ${ctx['email_to'] or ''} + + + + +

Hello ${object.partner_id.name},

+ +

+ % if ctx.get('payment_method') and ctx.get('total_amount'): + We were unable to process a payment for your subscription using your payment method + ${ctx['payment_method']} + for ${ctx['total_amount']} ${ctx.get('currency')}. + % if ctx.get('error'): + Your bank or credit institution gave the following details about the issue:

${ctx['error']}
. + %endif + % else: + We were unable to process a payment for your subscription because we have no payment method recorded for you. + % endif +

+ +

+ Your subscription ${ctx.get('code')} is still valid but will be suspended + on ${ctx.get('date_close')} unless the payment succeeds in the mean time (we will retry once every day). + Please double-check that you have sufficient funds. +

+ +
+

If you have any question, do not hesitate to contact us.

+

Thank you for choosing ${object.company_id.name or 'us'}!

+
+
+
+

+ ${object.company_id.name}

+
+
+ + % if object.company_id.street: + ${object.company_id.street}
+ % endif + % if object.company_id.street2: + ${object.company_id.street2}
+ % endif + % if object.company_id.city or object.company_id.zip: + ${object.company_id.zip} ${object.company_id.city}
+ % endif + % if object.company_id.country_id: + ${object.company_id.state_id and ('%s, ' % object.company_id.state_id.name) or ''} ${object.company_id.country_id.name or ''}
+ % endif +
+ % if object.company_id.phone: +
+ Phone:  ${object.company_id.phone} +
+ % endif + % if object.company_id.website: + + % endif +

+
+
+ ]]> +
+ + + + email_payment_success + ${(object.user_id.email or object.company_id.email or 'noreply@localhost')|safe} + Invoice for subscription ${ctx.get('code')} + ${ctx['email_to'] or ''} + + + Invoice_${ctx.get('code')}_${ctx.get('previous_date')} + + + +

Hello ${object.partner_id.name},

+ +

+ Your subscription (${ctx.get('code')}) has just been renewed + %if ctx.get('total_amount') and ctx.get('payment_method'): + via a payment of ${ctx['total_amount']} ${ctx['currency']} charged on ${ctx['payment_method']} + . + %endif +

+ +

You will find your invoice attached.

+ + %if ctx.get('next_date'): +

+ Your next invoice will be on ${ctx.get('next_date')}. +

+ %endif + +
+

If you have any question, do not hesitate to contact us.

+

Thank you for choosing ${object.company_id.name or 'us'}!

+
+
+
+

+ ${object.company_id.name}

+
+
+ + % if object.company_id.street: + ${object.company_id.street}
+ % endif + % if object.company_id.street2: + ${object.company_id.street2}
+ % endif + % if object.company_id.city or object.company_id.zip: + ${object.company_id.zip} ${object.company_id.city}
+ % endif + % if object.company_id.country_id: + ${object.company_id.state_id and ('%s, ' % object.company_id.state_id.name) or ''} ${object.company_id.country_id.name or ''}
+ % endif +
+ % if object.company_id.phone: +
+ Phone:  ${object.company_id.phone} +
+ % endif + % if object.company_id.website: + + % endif +

+
+
+ ]]> +
+ + + + + Contract is too expensive + + + Contract does not meet my requirements + + + I don't use it + + + Other + + \ No newline at end of file diff --git a/addons/sale_contract/demo/sale_contract_demo.xml b/addons/sale_contract/demo/sale_contract_demo.xml index 1f9ce31fe61..63d576bd078 100644 --- a/addons/sale_contract/demo/sale_contract_demo.xml +++ b/addons/sale_contract/demo/sale_contract_demo.xml @@ -9,6 +9,7 @@ 100000 + regular @@ -23,6 +24,7 @@ 50000 + regular @@ -36,6 +38,7 @@ 100 + regular diff --git a/addons/sale_contract/models/__init__.py b/addons/sale_contract/models/__init__.py index 616ffabaed9..427c45f78f9 100644 --- a/addons/sale_contract/models/__init__.py +++ b/addons/sale_contract/models/__init__.py @@ -1,4 +1,3 @@ import sale_contract import sale import res_config - diff --git a/addons/sale_contract/models/sale.py b/addons/sale_contract/models/sale.py index 0174749b875..a48d3ba7f02 100644 --- a/addons/sale_contract/models/sale.py +++ b/addons/sale_contract/models/sale.py @@ -1,4 +1,9 @@ +# -*- coding: utf-8 -*- +from dateutil.relativedelta import relativedelta +import datetime + from openerp import models, fields, api +from openerp.tools.translate import _ class product_template(models.Model): @@ -15,23 +20,66 @@ class product_template(models.Model): "the recurring invoices automatically") +class sale_order(models.Model): + _name = "sale.order" + _inherit = "sale.order" + + update_contract = fields.Boolean("Update Contract", help="If set, the associated contract will be overwritten by this sale order (every recurring line of the contract not in this sale order will be deleted).") + + @api.multi + def action_button_confirm(self): + for order in self: + if order.project_id and order.update_contract: + to_remove = [(2, line.id, 0) for line in order.project_id.recurring_invoice_line_ids] + order.project_id.sudo().write({'recurring_invoice_line_ids': to_remove, 'description': order.note}) + order.project_id.sudo().increment_period() + return super(sale_order, self).action_button_confirm() + + @api.model + def _prepare_invoice(self, order, lines): + invoice_vals = super(sale_order, self)._prepare_invoice(order, lines) + if order.project_id and order.update_contract: + next_date = datetime.datetime.strptime(order.project_id.recurring_next_date, "%Y-%m-%d") + periods = {'daily': 'days', 'weekly': 'weeks', 'monthly': 'months', 'yearly': 'years'} + invoicing_period = relativedelta(**{periods[order.project_id.recurring_rule_type]: order.project_id.recurring_interval}) + previous_date = next_date - invoicing_period + + invoice_vals['comment'] = _("This invoice covers the following period: %s - %s") % (previous_date.date(), (next_date - relativedelta(days=1)).date()) + + return invoice_vals + + class sale_order_line(models.Model): + _name = "sale.order.line" _inherit = "sale.order.line" - @api.one + @api.multi def button_confirm(self): - if self.product_id.recurring_invoice and self.order_id.project_id: - invoice_line_ids = [((0, 0, { - 'product_id': self.product_id.id, - 'analytic_account_id': self.order_id.project_id.id, - 'name': self.name, - 'quantity': self.product_uom_qty, - 'uom_id': self.product_uom.id, - 'price_unit': self.price_unit, - 'price_subtotal': self.price_subtotal - }))] - analytic_values = {'recurring_invoices': True, 'recurring_invoice_line_ids': invoice_line_ids} - if not self.order_id.project_id.partner_id: - analytic_values['partner_id'] = self.order_id.partner_id.id - self.order_id.project_id.write(analytic_values) + for order_line in self: + if order_line.product_id.recurring_invoice and order_line.order_id.project_id: + recurring_line_id = False + if order_line.product_id in [line.product_id for line in order_line.order_id.project_id.recurring_invoice_line_ids]: + for line in order_line.order_id.project_id.recurring_invoice_line_ids: + if line.product_id == order_line.product_id and line.uom_id == order_line.product_uom: + recurring_line_id = line.id + quantity = line.sold_quantity + break + if recurring_line_id: + invoice_line_ids = [((1, recurring_line_id, { + 'sold_quantity': quantity + order_line.product_uom_qty, + }))] + else: + invoice_line_ids = [((0, 0, { + 'product_id': order_line.product_id.id, + 'analytic_account_id': order_line.order_id.project_id.id, + 'name': order_line.name, + 'sold_quantity': order_line.product_uom_qty, + 'uom_id': order_line.product_uom.id, + 'price_unit': order_line.price_unit, + 'discount': order_line.discount if order_line.order_id.update_contract else False, + }))] + analytic_values = {'recurring_invoice_line_ids': invoice_line_ids} + if not order_line.order_id.project_id.partner_id: + analytic_values['partner_id'] = order_line.order_id.partner_id.id + order_line.order_id.project_id.sudo().write(analytic_values) return super(sale_order_line, self).button_confirm() \ No newline at end of file diff --git a/addons/sale_contract/models/sale_contract.py b/addons/sale_contract/models/sale_contract.py index cd0a6a77e32..6fa1ddf9114 100644 --- a/addons/sale_contract/models/sale_contract.py +++ b/addons/sale_contract/models/sale_contract.py @@ -5,6 +5,7 @@ import datetime import logging import time +from openerp import SUPERUSER_ID from openerp.osv import osv, fields import openerp.tools from openerp.tools.translate import _ @@ -14,68 +15,7 @@ from openerp.addons.decimal_precision import decimal_precision as dp _logger = logging.getLogger(__name__) -class account_analytic_invoice_line(osv.osv): - _name = "account.analytic.invoice.line" - - def _amount_line(self, cr, uid, ids, prop, unknow_none, unknow_dict, context=None): - res = {} - for line in self.browse(cr, uid, ids, context=context): - res[line.id] = line.quantity * line.price_unit - if line.analytic_account_id.pricelist_id: - cur = line.analytic_account_id.pricelist_id.currency_id - res[line.id] = self.pool.get('res.currency').round(cr, uid, cur, res[line.id]) - return res - - _columns = { - 'product_id': fields.many2one('product.product','Product',required=True), - 'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', ondelete='cascade'), - 'name': fields.text('Description', required=True), - 'quantity': fields.float('Quantity', required=True), - 'uom_id': fields.many2one('product.uom', 'Unit of Measure',required=True), - 'price_unit': fields.float('Unit Price', required=True), - 'price_subtotal': fields.function(_amount_line, string='Sub Total', type="float",digits=0), - } - _defaults = { - 'quantity' : 1, - } - - def product_id_change(self, cr, uid, ids, product, uom_id, qty=0, name='', partner_id=False, price_unit=False, pricelist_id=False, company_id=None, context=None): - context = context or {} - uom_obj = self.pool.get('product.uom') - company_id = company_id or False - local_context = dict(context, company_id=company_id, force_company=company_id, pricelist=pricelist_id) - - if not product: - return {'value': {'price_unit': 0.0}, 'domain':{'product_uom':[]}} - if partner_id: - part = self.pool.get('res.partner').browse(cr, uid, partner_id, context=local_context) - if part.lang: - local_context.update({'lang': part.lang}) - - result = {} - res = self.pool.get('product.product').browse(cr, uid, product, context=local_context) - price = False - if price_unit is not False: - price = price_unit - elif pricelist_id: - price = res.price - if price is False: - price = res.list_price - if not name: - name = self.pool.get('product.product').name_get(cr, uid, [res.id], context=local_context)[0][1] - if res.description_sale: - name += '\n'+res.description_sale - - result.update({'name': name or False,'uom_id': uom_id or res.uom_id.id or False, 'price_unit': price}) - - res_final = {'value':result} - if result['uom_id'] != res.uom_id.id: - selected_uom = uom_obj.browse(cr, uid, result['uom_id'], context=local_context) - new_price = uom_obj._compute_price(cr, uid, res.uom_id.id, res_final['value']['price_unit'], result['uom_id']) - res_final['value']['price_unit'] = new_price - return res_final - - +# Regular class account_analytic_account(osv.osv): _name = "account.analytic.account" _inherit = "account.analytic.account" @@ -287,23 +227,6 @@ class account_analytic_account(osv.osv): res_final = res return res_final - def _remaining_hours_calc(self, cr, uid, ids, name, arg, context=None): - res = {} - for account in self.browse(cr, uid, ids, context=context): - if account.quantity_max != 0: - res[account.id] = account.quantity_max - account.hours_quantity - else: - res[account.id] = 0.0 - for id in ids: - res[id] = round(res.get(id, 0.0),2) - return res - - def _remaining_hours_to_invoice_calc(self, cr, uid, ids, name, arg, context=None): - res = {} - for account in self.browse(cr, uid, ids, context=context): - res[account.id] = max(account.hours_qtt_est - account.timesheet_ca_invoiced, account.ca_to_invoice) - return res - def _hours_qtt_invoiced_calc(self, cr, uid, ids, name, arg, context=None): res = {} for account in self.browse(cr, uid, ids, context=context): @@ -389,21 +312,6 @@ class account_analytic_account(osv.osv): res[id] = round(res.get(id, 0.0),2) return res - def _is_overdue_quantity(self, cr, uid, ids, fieldnames, args, context=None): - result = dict.fromkeys(ids, 0) - for record in self.browse(cr, uid, ids, context=context): - if record.quantity_max > 0.0: - result[record.id] = int(record.hours_quantity > record.quantity_max) - else: - result[record.id] = 0 - return result - - def _get_analytic_account(self, cr, uid, ids, context=None): - result = set() - for line in self.pool.get('account.analytic.line').browse(cr, uid, ids, context=context): - result.add(line.account_id.id) - return list(result) - def _get_total_estimation(self, account): tot_est = 0.0 if account.fix_price_invoices: @@ -446,11 +354,6 @@ class account_analytic_account(osv.osv): return res _columns = { - 'is_overdue_quantity' : fields.function(_is_overdue_quantity, method=True, type='boolean', string='Overdue Quantity', - store={ - 'account.analytic.line' : (_get_analytic_account, None, 20), - 'account.analytic.account': (lambda self, cr, uid, ids, c=None: ids, ['quantity_max'], 10), - }), 'ca_invoiced': fields.function(_ca_invoiced_calc, type='float', string='Invoiced Amount', help="Total customer invoiced amount for this account.", digits=0), @@ -475,10 +378,6 @@ class account_analytic_account(osv.osv): help="Number of time (hours/days) (from journal of type 'general') that can be invoiced if you invoice based on analytic account."), 'hours_qtt_invoiced': fields.function(_hours_qtt_invoiced_calc, type='float', string='Invoiced Time', help="Number of time (hours/days) that can be invoiced plus those that already have been invoiced."), - 'remaining_hours': fields.function(_remaining_hours_calc, type='float', string='Remaining Time', - help="Computed using the formula: Maximum Time - Total Worked Time"), - 'remaining_hours_to_invoice': fields.function(_remaining_hours_to_invoice_calc, type='float', string='Remaining Time', - help="Computed using the formula: Expected on timesheets - Total invoiced on timesheets"), 'fix_price_to_invoice': fields.function(_fix_price_to_invoice_calc, type='float', string='Remaining Time', help="Sum of quotations for this contract."), 'timesheet_ca_invoiced': fields.function(_timesheet_ca_invoiced_calc, type='float', string='Remaining Time', @@ -506,22 +405,6 @@ class account_analytic_account(osv.osv): 'invoiced_total' : fields.function(_sum_of_fields, type="float",multi="sum_of_all", string="Total Invoiced"), 'remaining_total' : fields.function(_sum_of_fields, type="float",multi="sum_of_all", string="Total Remaining", help="Expectation of remaining income for this contract. Computed as the sum of remaining subtotals which, in turn, are computed as the maximum between '(Estimation - Invoiced)' and 'To Invoice' amounts"), 'toinvoice_total' : fields.function(_sum_of_fields, type="float",multi="sum_of_all", string="Total to Invoice", help=" Sum of everything that could be invoiced for this contract."), - 'recurring_invoice_line_ids': fields.one2many('account.analytic.invoice.line', 'analytic_account_id', 'Invoice Lines', copy=True), - 'recurring_invoices' : fields.boolean('Generate recurring invoices automatically'), - 'recurring_rule_type': fields.selection([ - ('daily', 'Day(s)'), - ('weekly', 'Week(s)'), - ('monthly', 'Month(s)'), - ('yearly', 'Year(s)'), - ], 'Recurrency', help="Invoice automatically repeat at specified interval"), - 'recurring_interval': fields.integer('Repeat Every', help="Repeat every (Days/Week/Month/Year)"), - 'recurring_next_date': fields.date('Date of Next Invoice'), - } - - _defaults = { - 'recurring_interval': 1, - 'recurring_next_date': lambda *a: time.strftime('%Y-%m-%d'), - 'recurring_rule_type':'monthly' } def open_sale_order_lines(self,cr,uid,ids,context=None): @@ -550,41 +433,6 @@ class account_analytic_account(osv.osv): return self.name_get(cr, uid, [rec_id], context)[0] return super(account_analytic_account, self).name_create(cr, uid, name, context=context) - def on_change_template(self, cr, uid, ids, template_id, date_start=False, context=None): - if not template_id: - return {} - res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, date_start=date_start, context=context) - - template = self.browse(cr, uid, template_id, context=context) - - if not ids: - res['value']['fix_price_invoices'] = template.fix_price_invoices - res['value']['amount_max'] = template.amount_max - if not ids: - res['value']['invoice_on_timesheets'] = template.invoice_on_timesheets - res['value']['hours_qtt_est'] = template.hours_qtt_est - - if template.to_invoice.id: - res['value']['to_invoice'] = template.to_invoice.id - if template.pricelist_id.id: - res['value']['pricelist_id'] = template.pricelist_id.id - if not ids: - invoice_line_ids = [] - for x in template.recurring_invoice_line_ids: - invoice_line_ids.append((0, 0, { - 'product_id': x.product_id.id, - 'uom_id': x.uom_id.id, - 'name': x.name, - 'quantity': x.quantity, - 'price_unit': x.price_unit, - 'analytic_account_id': x.analytic_account_id and x.analytic_account_id.id or False, - })) - res['value']['recurring_invoices'] = template.recurring_invoices - res['value']['recurring_interval'] = template.recurring_interval - res['value']['recurring_rule_type'] = template.recurring_rule_type - res['value']['recurring_invoice_line_ids'] = invoice_line_ids - return res - def onchange_recurring_invoices(self, cr, uid, ids, recurring_invoices, date_start=False, context=None): value = {} if date_start and recurring_invoices: @@ -617,7 +465,10 @@ class account_analytic_account(osv.osv): fill_remind("old", [('state', 'in', ['pending'])]) # Expires now - fill_remind("new", [('state', 'in', ['draft', 'open']), '|', '&', ('date', '!=', False), ('date', '<=', time.strftime('%Y-%m-%d')), ('is_overdue_quantity', '=', True)], True) + fill_remind("new", [('state', 'in', ['draft', 'open']), + '|', + '&', ('date', '!=', False), ('date', '<=', time.strftime('%Y-%m-%d')), + '&', ('is_overdue_quantity', '=', True), ('contract_type', '=', 'prepaid')], True) # Expires in less than 30 days fill_remind("future", [('state', 'in', ['draft', 'open']), ('date', '!=', False), ('date', '<', (datetime.datetime.now() + datetime.timedelta(30)).strftime("%Y-%m-%d"))]) @@ -645,6 +496,142 @@ class account_analytic_account(osv.osv): 'res_model': 'account.analytic.line', } + +# Prepaid +class account_analytic_account(osv.osv): + _name = "account.analytic.account" + _inherit = "account.analytic.account" + + def _get_analytic_account(self, cr, uid, ids, context=None): + result = set() + for line in self.pool.get('account.analytic.line').browse(cr, uid, ids, context=context): + result.add(line.account_id.id) + return list(result) + + def _is_overdue_quantity(self, cr, uid, ids, fieldnames, args, context=None): + result = dict.fromkeys(ids, 0) + for record in self.browse(cr, uid, ids, context=context): + if record.quantity_max > 0.0: + result[record.id] = int(record.hours_quantity > record.quantity_max) + else: + result[record.id] = 0 + return result + + def _remaining_hours_calc(self, cr, uid, ids, name, arg, context=None): + res = {} + for account in self.browse(cr, uid, ids, context=context): + if account.quantity_max != 0: + res[account.id] = account.quantity_max - account.hours_quantity + else: + res[account.id] = 0.0 + for id in ids: + res[id] = round(res.get(id, 0.0), 2) + return res + + def _remaining_hours_to_invoice_calc(self, cr, uid, ids, name, arg, context=None): + res = {} + for account in self.browse(cr, uid, ids, context=context): + res[account.id] = max(account.hours_qtt_est - account.timesheet_ca_invoiced, account.ca_to_invoice) + return res + + _columns = { + 'is_overdue_quantity': fields.function(_is_overdue_quantity, method=True, type='boolean', string='Overdue Quantity', + store={ + 'account.analytic.line': (_get_analytic_account, None, 20), + 'account.analytic.account': (lambda self, cr, uid, ids, c=None: ids, ['quantity_max'], 10), + }), + 'remaining_hours': fields.function(_remaining_hours_calc, type='float', string='Remaining Time', + help="Computed using the formula: Maximum Time - Total Worked Time"), + 'remaining_hours_to_invoice': fields.function(_remaining_hours_to_invoice_calc, type='float', string='Remaining Time', + help="Computed using the formula: Expected on timesheets - Total invoiced on timesheets"), + } + + +# Recurring +class account_analytic_account(osv.osv): + _name = "account.analytic.account" + _inherit = "account.analytic.account" + + def _get_recurring_line_ids(self, cr, uid, ids, context=None): + result = [] + for line in self.pool.get('account.analytic.invoice.line').browse(cr, uid, ids, context=context): + result.append(line.analytic_account_id.id) + return result + + def _get_contract_type_selection(self, cr, uid, context=None): + select = super(account_analytic_account, self)._get_contract_type_selection(cr, uid, context=context) + select.append(('subscription', 'Susbcription')) + return select + + def _get_recurring_price(self, cr, uid, ids, fieldnames, args, context=None): + result = dict.fromkeys(ids, 0.0) + for account in self.browse(cr, uid, ids, context=context): + result[account.id] = sum(line.price_subtotal for line in account.recurring_invoice_line_ids) + return result + + _columns = { + 'recurring_invoice_line_ids': fields.one2many('account.analytic.invoice.line', 'analytic_account_id', 'Invoice Lines', copy=True), + 'recurring_rule_type': fields.selection([('daily', 'Day(s)'), ('weekly', 'Week(s)'), ('monthly', 'Month(s)'), ('yearly', 'Year(s)'), ], 'Recurrency', help="Invoice automatically repeat at specified interval"), + 'recurring_interval': fields.integer('Repeat Every', help="Repeat every (Days/Week/Month/Year)"), + 'recurring_next_date': fields.date('Date of Next Invoice'), + 'recurring_total': fields.function(_get_recurring_price, string="Recurring Price", type="float", store={ + 'account.analytic.account': (lambda s, cr, uid, ids, c={}: ids, ['recurring_invoice_line_ids'], 5), + 'account.analytic.invoice.line': (_get_recurring_line_ids, ['product_id', 'quantity', 'actual_quantity', 'sold_quantity', 'uom_id', 'price_unit', 'discount', 'price_subtotal'], 5), + }, track_visibility='onchange'), + # Fields that only matters on template + 'plan_description': fields.html(string='Plan Description', help="Describe this contract in a few lines",), + 'user_selectable': fields.boolean(string='Allow Online Order', help="""Leave this unchecked if you don't want this contract template to be available to the customer in the frontend (for a free trial, for example)"""), + 'close_reason_id': fields.many2one("account.analytic.close.reason", "Close Reason") + } + + _defaults = { + 'recurring_interval': 1, + 'recurring_next_date': lambda *a: time.strftime('%Y-%m-%d'), + 'recurring_rule_type': 'monthly', + 'user_selectable': True, + 'contract_type': 'regular', + } + + def on_change_template(self, cr, uid, ids, template_id, date_start=False, context=None): + if not template_id: + return {} + res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, date_start=date_start, context=context) + + template = self.browse(cr, uid, template_id, context=context) + + if not ids: + res['value']['fix_price_invoices'] = template.fix_price_invoices + res['value']['amount_max'] = template.amount_max + if not ids: + res['value']['invoice_on_timesheets'] = template.invoice_on_timesheets + res['value']['hours_qtt_est'] = template.hours_qtt_est + + if template.to_invoice.id: + res['value']['to_invoice'] = template.to_invoice.id + if template.pricelist_id.id: + res['value']['pricelist_id'] = template.pricelist_id.id + if not ids: + invoice_line_ids = [] + for x in template.recurring_invoice_line_ids: + invoice_line_ids.append((0, 0, { + 'product_id': x.product_id.id, + 'uom_id': x.uom_id.id, + 'name': x.name, + 'quantity': x.quantity, + 'price_unit': x.price_unit, + 'analytic_account_id': x.analytic_account_id and x.analytic_account_id.id or False, + })) + res['value']['recurring_interval'] = template.recurring_interval + res['value']['recurring_rule_type'] = template.recurring_rule_type + res['value']['recurring_invoice_line_ids'] = invoice_line_ids + if template.contract_type == 'subscription': + res['value']['date'] = False + elif template.recurring_rule_type and template.recurring_interval: + periods = {'daily': 'days', 'weekly': 'weeks', 'monthly': 'months', 'yearly': 'years'} + contract_period = relativedelta(**{periods[template.recurring_rule_type]: template.recurring_interval}) + res['value']['date'] = datetime.datetime.strftime(datetime.date.today() + contract_period, openerp.tools.DEFAULT_SERVER_DATE_FORMAT) + return res + def _prepare_invoice_data(self, cr, uid, contract, context=None): context = context or {} @@ -661,6 +648,11 @@ class account_analytic_account(osv.osv): raise UserError(_('Please define a sale journal for the company "%s".') % (contract.company_id.name or '', )) partner_payment_term = partner.property_payment_term_id and partner.property_payment_term_id.id or False + + next_date = datetime.datetime.strptime(contract.recurring_next_date, "%Y-%m-%d") + periods = {'daily': 'days', 'weekly': 'weeks', 'monthly': 'months', 'yearly': 'years'} + invoicing_period = relativedelta(**{periods[contract.recurring_rule_type]: contract.recurring_interval}) + new_date = next_date + invoicing_period currency_id = False if contract.pricelist_id: @@ -681,6 +673,7 @@ class account_analytic_account(osv.osv): 'fiscal_position_id': fpos_id, 'payment_term_id': partner_payment_term, 'company_id': contract.company_id.id or False, + 'comment': _("This invoice covers the following period: %s - %s") % (next_date.date(), new_date.date()), } return invoice @@ -699,6 +692,7 @@ class account_analytic_account(osv.osv): 'account_id': account_id, 'account_analytic_id': line.analytic_account_id.id, 'price_unit': line.price_unit or 0.0, + 'discount': line.discount, 'quantity': line.quantity, 'uos_id': line.uom_id.id or False, 'product_id': line.product_id.id or False, @@ -722,7 +716,7 @@ class account_analytic_account(osv.osv): invoice['invoice_line_ids'] = self._prepare_invoice_lines(cr, uid, contract, invoice['fiscal_position_id'], context=context) return invoice - def recurring_create_invoice(self, cr, uid, ids, context=None): + def recurring_invoice(self, cr, uid, ids, context=None): return self._recurring_create_invoice(cr, uid, ids, context=context) def _cron_recurring_create_invoice(self, cr, uid, context=None): @@ -735,7 +729,7 @@ class account_analytic_account(osv.osv): if ids: contract_ids = ids else: - contract_ids = self.search(cr, uid, [('recurring_next_date','<=', current_date), ('state','=', 'open'), ('recurring_invoices','=', True), ('type', '=', 'contract')]) + contract_ids = self.search(cr, uid, [('recurring_next_date','<=', current_date), ('state','=', 'open'), ('type', '=', 'contract'), ('contract_type', '=', 'subscription')]) if contract_ids: cr.execute('SELECT company_id, array_agg(id) as ids FROM account_analytic_account WHERE id IN %s GROUP BY company_id', (tuple(contract_ids),)) for company_id, ids in cr.fetchall(): @@ -763,3 +757,151 @@ class account_analytic_account(osv.osv): else: raise return invoice_ids + + def _prepare_renewal_order_values(self, cr, uid, ids, context=None): + res = dict() + for contract in self.browse(cr, uid, ids, context=context): + order_lines = [] + order_seq_id = self.pool['ir.sequence'].search(cr, uid, [('code', '=', 'sale.order')], context=context) + order_seq = self.pool['ir.sequence'].browse(cr, uid, order_seq_id, context=context) + for line in contract.recurring_invoice_line_ids: + order_lines.append((0, 0, { + 'product_id': line.product_id.id, + 'name': line.product_id.name_template, + 'description': line.name, + 'product_uom': line.uom_id.id, + 'product_uom_qty': line.quantity, + 'price_unit': line.price_unit, + 'discount': line.discount, + })) + res[contract.id] = { + 'name': order_seq.next_by_id() + ' - Renewal', + 'pricelist_id': contract.pricelist_id.id, + 'partner_id': contract.partner_id.id, + 'currency_id': contract.pricelist_id.currency_id.id, + 'order_line': order_lines, + 'project_id': contract.id, + 'update_contract': True, + 'note': contract.description, + 'user_id': contract.manager_id.id, + } + return res + + def prepare_renewal_order(self, cr, uid, ids, context=None): + values = self._prepare_renewal_order_values(cr, uid, ids, context=context) + for contract in self.browse(cr, uid, ids, context=context): + order_id = self.pool['sale.order'].create(cr, uid, values[contract.id], context=context) + return { + "type": "ir.actions.act_window", + "res_model": "sale.order", + "views": [[False, "form"]], + "res_id": order_id, + } + + def increment_period(self, cr, uid, ids, context=None): + for account in self.browse(cr, uid, ids, context=context): + next_date = datetime.datetime.strptime(account.recurring_next_date, "%Y-%m-%d") + periods = {'daily': 'days', 'weekly': 'weeks', 'monthly': 'months', 'yearly': 'years'} + invoicing_period = relativedelta(**{periods[account.recurring_rule_type]: account.recurring_interval}) + new_date = next_date + invoicing_period + self.write(cr, uid, account.id, {'recurring_next_date': new_date}, context=context) + + +class account_analytic_invoice_line(osv.osv): + _name = "account.analytic.invoice.line" + + def _amount_line(self, cr, uid, ids, prop, unknow_none, unknow_dict, context=None): + res = {} + for line in self.browse(cr, uid, ids, context=context): + res[line.id] = line.quantity * line.price_unit * (100.0 - line.discount) / 100.0 + if line.analytic_account_id.pricelist_id: + cur = line.analytic_account_id.pricelist_id.currency_id + res[line.id] = self.pool.get('res.currency').round(cr, uid, cur, res[line.id]) + return res + + def _compute_quantity(self, cr, uid, ids, name, arg, context=None): + res = {} + for line in self.browse(cr, uid, ids, context=context): + res[line.id] = max(line.sold_quantity, line.actual_quantity) + return res + + def _set_quantity(self, cr, uid, ids, name, value, args=None, context=None): + for line in self.browse(cr, uid, ids, context=context): + self.write(cr, uid, line.id, {'actual_quantity': value}, context=context) + + _columns = { + 'product_id': fields.many2one('product.product', 'Product', required=True), + 'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account'), + 'name': fields.text('Description', required=True), + 'quantity': fields.function(_compute_quantity, string='Quantity', + store=True, + help="Max between actual and sold quantities; this quantity will be invoiced"), + 'actual_quantity': fields.float('Actual Quantity', help="Quantity actually used by the customer"), + 'sold_quantity': fields.float('Sold Quantity', help="Quantity sold to the customer", required=True), + 'uom_id': fields.many2one('product.uom', 'Unit of Measure', required=True), + 'price_unit': fields.float('Unit Price', required=True), + 'discount': fields.float('Discount (%)', digits_compute=dp.get_precision('Discount')), + 'price_subtotal': fields.function(_amount_line, string='Sub Total', type="float", digits_compute=dp.get_precision('Account')), + } + _defaults = { + 'sold_quantity': 1, + 'actual_quantity': 0, + } + + def product_id_change(self, cr, uid, ids, product, uom_id, qty=0, name='', partner_id=False, price_unit=False, pricelist_id=False, company_id=None, context=None): + context = context or {} + uom_obj = self.pool.get('product.uom') + company_id = company_id or False + local_context = dict(context, company_id=company_id, force_company=company_id, pricelist=pricelist_id) + + if not product: + return {'value': {'price_unit': 0.0}, 'domain':{'product_uom':[]}} + if partner_id: + part = self.pool.get('res.partner').browse(cr, uid, partner_id, context=local_context) + if part.lang: + local_context.update({'lang': part.lang}) + + result = {} + res = self.pool.get('product.product').browse(cr, uid, product, context=local_context) + price = False + if price_unit is not False: + price = price_unit + elif pricelist_id: + price = res.price + if price is False: + price = res.list_price + if not name: + name = self.pool.get('product.product').name_get(cr, uid, [res.id], context=local_context)[0][1] + if res.description_sale: + name += '\n'+res.description_sale + + result.update({'name': name or False,'uom_id': uom_id or res.uom_id.id or False, 'price_unit': price}) + + res_final = {'value':result} + if result['uom_id'] != res.uom_id.id: + selected_uom = uom_obj.browse(cr, uid, result['uom_id'], context=local_context) + new_price = uom_obj._compute_price(cr, uid, res.uom_id.id, res_final['value']['price_unit'], result['uom_id']) + res_final['value']['price_unit'] = new_price + + if not uom_id: + res_final['domain'] = {'uom_id': [('category_id', '=', res.uom_id.category_id.id)]} + return res_final + + def product_uom_change(self, cr, uid, ids, product, uom_id, qty=0, name='', partner_id=False, pricelist_id=False, context=None): + context = context or {} + if not uom_id: + return {'value': {'price_unit': 0.0, 'uom_id': uom_id or False}} + return self.product_id_change(cr, uid, ids, product, uom_id=uom_id, qty=qty, name=name, partner_id=partner_id, pricelist_id=pricelist_id, context=context) + +class account_analytic_close_reason(osv.osv): + _name = "account.analytic.close.reason" + _order = "sequence, id" + + _columns = { + 'name': fields.char('Name', required=True), + 'sequence': fields.integer('Sequence') + } + + _defaults = { + 'sequence': 10 + } diff --git a/addons/sale_contract/report/__init__.py b/addons/sale_contract/report/__init__.py index 6f892af3ae4..a37c52fe09a 100644 --- a/addons/sale_contract/report/__init__.py +++ b/addons/sale_contract/report/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- -import account_analytic_analysis_summary \ No newline at end of file +import account_analytic_analysis_summary +import sale_contract_report diff --git a/addons/sale_contract/report/sale_contract_report.py b/addons/sale_contract/report/sale_contract_report.py new file mode 100644 index 00000000000..2adf0257055 --- /dev/null +++ b/addons/sale_contract/report/sale_contract_report.py @@ -0,0 +1,94 @@ +from openerp import tools +from openerp import fields, models + + +class sale_contract_report(models.Model): + _name = "sale.contract.report" + _description = "Subscription Statistics" + _auto = False + + date_start = fields.Date('Date Start', readonly=True) + date_end = fields.Date('Date End', readonly=True) + product_id = fields.Many2one('product.product', 'Product', readonly=True) + product_uom = fields.Many2one('product.uom', 'Unit of Measure', readonly=True) + recurring_price = fields.Float('Recurring price(per period)', readonly=True) + partner_id = fields.Many2one('res.partner', 'Customer', readonly=True) + manager_id = fields.Many2one('res.users', 'Sales Rep', readonly=True) + company_id = fields.Many2one('res.company', 'Company', readonly=True) + state = fields.Selection([('template', 'Template'), + ('draft', 'New'), + ('open', 'In Progress'), + ('pending', 'To Renew'), + ('close', 'Closed'), + ('cancelled', 'Cancelled')], readonly=True) + categ_id = fields.Many2one('product.category', 'Product Category', readonly=True) + pricelist_id = fields.Many2one('product.pricelist', 'Pricelist', readonly=True) + parent_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True) + template_id = fields.Many2one('account.analytic.account', 'Subscription Template', readonly=True) + product_tmpl_id = fields.Many2one('product.template', 'Product Template', readonly=True) + country_id = fields.Many2one('res.country', 'Country', readonly=True) + commercial_partner_id = fields.Many2one('res.partner', 'Comercial Partner', readonly=True) + + def _select(self): + select_str = """ + SELECT min(l.id) as id, + l.product_id as product_id, + l.uom_id as product_uom, + l.analytic_account_id, + (l.price_unit * l.quantity) as recurring_price, + a.date_start as date_start, + a.date as date_end, + a.partner_id as partner_id, + a.manager_id as manager_id, + a.company_id as company_id, + a.state, + a.template_id as template_id, + t.categ_id as categ_id, + a.pricelist_id as pricelist_id, + a.parent_id as parent_id, + p.product_tmpl_id, + partner.country_id as country_id, + partner.commercial_partner_id as commercial_partner_id + """ + return select_str + + def _from(self): + from_str = """ + account_analytic_invoice_line l + join account_analytic_account a on (l.analytic_account_id=a.id) and (a.contract_type='subscription') and (a.type!='template') + join res_partner partner on a.partner_id = partner.id + left join product_product p on (l.product_id=p.id) + left join product_template t on (p.product_tmpl_id=t.id) + left join product_uom u on (u.id=l.uom_id) + """ + return from_str + + def _group_by(self): + group_by_str = """ + GROUP BY l.product_id, + l.uom_id, + t.categ_id, + l.analytic_account_id, + a.date_start, + a.date, + a.partner_id, + a.manager_id, + recurring_price, + a.company_id, + a.state, + a.template_id, + a.pricelist_id, + a.parent_id, + p.product_tmpl_id, + partner.country_id, + partner.commercial_partner_id + """ + return group_by_str + + def init(self, cr): + tools.drop_view_if_exists(cr, self._table) + cr.execute("""CREATE or REPLACE VIEW %s as ( + %s + FROM ( %s ) + %s + )""" % (self._table, self._select(), self._from(), self._group_by())) \ No newline at end of file diff --git a/addons/sale_contract/report/sale_contract_report_view.xml b/addons/sale_contract/report/sale_contract_report_view.xml new file mode 100644 index 00000000000..81954f01862 --- /dev/null +++ b/addons/sale_contract/report/sale_contract_report_view.xml @@ -0,0 +1,34 @@ + + + + subscription.report.pivot + sale.contract.report + + + + + + + + + + + subscription.report.graph + sale.contract.report + + + + + + + + + + Subscription Analysis + sale.contract.report + form + pivot,graph + + + + \ No newline at end of file diff --git a/addons/sale_contract/security/account_analytic_analysis_security.xml b/addons/sale_contract/security/account_analytic_analysis_security.xml index 1dc3c3d4973..5c7946f4a2d 100644 --- a/addons/sale_contract/security/account_analytic_analysis_security.xml +++ b/addons/sale_contract/security/account_analytic_analysis_security.xml @@ -7,7 +7,7 @@ - Mandatory use of templates in contracts + Mandatory use of contract templates the field template of the analytic accounts and contracts will be required. @@ -17,5 +17,45 @@ + + + + + + See Contracts + + The user will have read access to contracts. + + + + Manage Contracts + + The user will have write access to contracts. + + + + + + + Subscription Analysis multi-company + + + ['|',('company_id','=',False),('company_id','child_of',[user.company_id.id])] + + + + Personal Subscription Analysis + + ['|',('user_id','=',user.id),('user_id','=',False)] + + + + + All Orders Analysis + + [(1,'=',1)] + + + diff --git a/addons/sale_contract/security/ir.model.access.csv b/addons/sale_contract/security/ir.model.access.csv index 8147affa379..a6337202b75 100644 --- a/addons/sale_contract/security/ir.model.access.csv +++ b/addons/sale_contract/security/ir.model.access.csv @@ -4,4 +4,12 @@ access_account_analytic_analysis_summary_month_sale,account_analytic_analysis.su access_account_analytic_analysis_summary_user,account_analytic_analysis.summary.user,model_account_analytic_analysis_summary_user,account.group_account_manager,1,0,0,0 access_account_analytic_analysis_summary_month,account_analytic_analysis.summary.month,model_account_analytic_analysis_summary_month,account.group_account_manager,1,0,0,0 access_account_analytic_invoice_line,account_analytic_invoice_line.user,model_account_analytic_invoice_line,account.group_account_user,1,1,1,1 -access_account_analytic_invoice_line,account_analytic_invoice_line.user,model_account_analytic_invoice_line,base.group_sale_salesman,1,1,1,1 +access_sale_contract_report_salesman,sale.contract.report,model_sale_contract_report,base.group_sale_salesman,1,0,0,0 +access_sale_contract_report_manager,sale.contract.report,model_sale_contract_report,base.group_sale_manager,1,1,1,1 +sale.access_account_analytic_account_salesman,account_analytic_account salesman,analytic.model_account_analytic_account,base.group_sale_salesman,0,0,0,0 +access_account_analytic_account_manager,account_analytic_account contract manager,analytic.model_account_analytic_account,sale_contract.group_sale_contract_manager,1,1,1,1 +access_account_analytic_invoice_line_manager,account_analytic_invoice_line contract manager,model_account_analytic_invoice_line,sale_contract.group_sale_contract_manager,1,1,1,1 +access_account_analytic_close_reason_manager,account.analytic.close.reason,model_account_analytic_close_reason,sale_contract.group_sale_contract_manager,1,1,1,1 +access_account_analytic_account_view,account_analytic_account view,analytic.model_account_analytic_account,sale_contract.group_sale_contract_view,1,0,0,0 +access_account_analytic_invoice_line_view,account_analytic_invoice_line view,model_account_analytic_invoice_line,sale_contract.group_sale_contract_view,1,0,0,0 +access_account_analytic_close_reason_view,account.analytic.close.reason,model_account_analytic_close_reason,sale_contract.group_sale_contract_view,1,0,0,0 diff --git a/addons/sale_contract/tests/__init__.py b/addons/sale_contract/tests/__init__.py new file mode 100644 index 00000000000..d985fecb374 --- /dev/null +++ b/addons/sale_contract/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +import common_sale_contract +import test_sale_contract diff --git a/addons/sale_contract/tests/common_sale_contract.py b/addons/sale_contract/tests/common_sale_contract.py new file mode 100644 index 00000000000..bcc9e801bf1 --- /dev/null +++ b/addons/sale_contract/tests/common_sale_contract.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from openerp.tests import common + + +class TestContractCommon(common.TransactionCase): + + def setUp(self): + super(TestContractCommon, self).setUp() + Contract = self.env['account.analytic.account'] + SaleOrder = self.env['sale.order'] + Product = self.env['product.product'] + ProductTmpl = self.env['product.template'] + + # Test products + self.product_tmpl = ProductTmpl.create({ + 'name': 'TestProduct', + 'type': 'service', + 'recurring_invoice': True, + }) + self.product = Product.create({ + 'product_tmpl_id': self.product_tmpl.id, + 'price': 50.0 + }) + + # Test user + TestUsersEnv = self.env['res.users'].with_context({'no_reset_password': True}) + group_portal_id = self.ref('base.group_portal') + self.user_portal = TestUsersEnv.create({ + 'name': 'Beatrice Portal', + 'login': 'Beatrice', + 'alias_name': 'beatrice', + 'email': 'beatrice.employee@example.com', + 'groups_id': [(6, 0, [group_portal_id])] + }) + + # Test Contract + self.contract_tmpl = Contract.create({ + 'name': 'TestContractTemplate', + 'type': 'template', + 'contract_type': 'subscription', + 'recurring_invoice_line_ids': [(0, 0, {'product_id': self.product.id, 'name': 'TestRecurringLine', 'price_unit': self.product.price, 'uom_id': self.product_tmpl.uom_id.id})] + }) + self.contract = Contract.create({ + 'name': 'TestContract', + 'type': 'contract', + 'state': 'open', + 'partner_id': self.user_portal.partner_id.id, + }) + self.sale_order = SaleOrder.create({ + 'name': 'TestSO', + 'project_id': self.contract.id, + 'partner_id': self.user_portal.partner_id.id, + 'order_line': [(0, 0, {'product_id': self.product.id, 'name': 'TestLine'})] + }) diff --git a/addons/sale_contract/tests/test_sale_contract.py b/addons/sale_contract/tests/test_sale_contract.py new file mode 100644 index 00000000000..04cef7cc083 --- /dev/null +++ b/addons/sale_contract/tests/test_sale_contract.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +from openerp.addons.sale_contract.tests.common_sale_contract import TestContractCommon +from openerp.tools import mute_logger + + +class TestContract(TestContractCommon): + + @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models') + def test_template(self): + """ Test behaviour of on_change_template """ + Contract = self.env['account.analytic.account'] + + # on_change_template on existing record (present in the db) + self.contract.write(self.contract.on_change_template(template_id=self.contract_tmpl.id)['value']) + self.assertEqual(self.contract_tmpl.contract_type, self.contract.contract_type, 'sale_contract: contract_type not copied when changing template') + self.assertTrue(len(self.contract.recurring_invoice_line_ids.ids) == 0, 'sale_contract: recurring_invoice_line_ids copied on existing account.analytic.account record') + + # on_change_template on cached record (NOT present in the db) + temp = Contract.new({'name': 'CachedContract', + 'type': 'contract', + 'state': 'open', + 'partner_id': self.user_portal.partner_id.id + }) + temp.update(temp.on_change_template(template_id=self.contract_tmpl.id)['value']) + self.assertEqual(self.contract_tmpl.contract_type, temp.contract_type, 'sale_contract: contract_type not copied when changing template') + self.assertTrue(temp.recurring_invoice_line_ids.name, 'sale_contract: recurring_invoice_line_ids not copied on new cached account.analytic.account record') + + @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models') + def test_sale_order(self): + """ Test sale order line copying for recurring products on confirm""" + self.sale_order.action_button_confirm() + self.assertTrue(len(self.contract.recurring_invoice_line_ids.ids) == 1, 'sale_contract: recurring_invoice_line_ids not created when confirming sale_order with recurring_product') + + @mute_logger('openerp.addons.base.ir.ir_model', 'openerp.models') + def test_invoice(self): + """ Test invoice generation""" + self.contract.write(self.contract.on_change_template(template_id=self.contract_tmpl.id)['value']) + self.contract.write({'recurring_invoice_line_ids': [(0, 0, {'product_id': self.product.id, 'name': 'TestRecurringLine', 'price_unit': 31415.9, 'uom_id': self.product_tmpl.uom_id.id})]}) + invoice_id = self.contract._recurring_create_invoice() + invoice = self.env['account.invoice'].browse(invoice_id) + self.assertEqual(invoice.amount_untaxed, 31415.9, 'sale_contract: recurring invoice generation problem') + + def test_renewal(self): + """ Test contract renewal """ + res = self.contract.prepare_renewal_order() + renewal_so_id = res['res_id'] + renewal_so = self.env['sale.order'].browse(renewal_so_id) + self.assertTrue(renewal_so.update_contract, 'sale_contract: renewal quotation generation is wrong') + self.contract.write({'recurring_invoice_line_ids': [(0, 0, {'product_id': self.product.id, 'name': 'TestRecurringLine', 'price_unit': 50, 'uom_id': self.product.uom_id.id})]}) + renewal_so.write({'order_line': [(0, 0, {'product_id': self.product.id, 'name': 'TestRenewalLine'})]}) + renewal_so.action_button_confirm() + lines = [line.name for line in self.contract.recurring_invoice_line_ids] + self.assertTrue('TestRecurringLine' not in lines, 'sale_contract: old line still present after renewal quotation confirmation') + self.assertTrue('TestRenewalLine' in lines, 'sale_contract: new line not present after renewal quotation confirmation') diff --git a/addons/sale_contract/views/sale_contract_view.xml b/addons/sale_contract/views/sale_contract_view.xml index cfd002256e7..d55d7a00769 100644 --- a/addons/sale_contract/views/sale_contract_view.xml +++ b/addons/sale_contract/views/sale_contract_view.xml @@ -21,162 +21,190 @@ account.analytic.account.invoice.form.inherit account.analytic.account - + - -