diff --git a/addons/account/models/account.py b/addons/account/models/account.py
index da2896af..5ec4c3de 100644
--- a/addons/account/models/account.py
+++ b/addons/account/models/account.py
@@ -391,7 +391,7 @@ class AccountJournal(models.Model):
belongs_to_company = fields.Boolean('Belong to the user\'s current company', compute="_belong_to_company", search="_search_company_journals",)
# Bank journals fields
- bank_account_id = fields.Many2one('res.partner.bank', string="Bank Account", ondelete='restrict', copy=False, domain="[('partner_id','=', company_id)]")
+ bank_account_id = fields.Many2one('res.partner.bank', string="Bank Account", ondelete='restrict', copy=False)
bank_statements_source = fields.Selection([('undefined', 'Undefined Yet'),('manual', 'Record Manually')], string='Bank Feeds', default='undefined')
bank_acc_number = fields.Char(related='bank_account_id.acc_number')
bank_id = fields.Many2one('res.bank', related='bank_account_id.bank_id')
@@ -433,7 +433,7 @@ class AccountJournal(models.Model):
for journal in self:
if journal.sequence_id and journal.sequence_number_next:
sequence = journal.sequence_id._get_current_sequence()
- sequence.number_next = journal.sequence_number_next
+ sequence.sudo().number_next = journal.sequence_number_next
@api.multi
# do not depend on 'refund_sequence_id.date_range_ids', because
@@ -512,9 +512,16 @@ class AccountJournal(models.Model):
@api.multi
def write(self, vals):
for journal in self:
+ company = journal.company_id
if ('company_id' in vals and journal.company_id.id != vals['company_id']):
if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1):
raise UserError(_('This journal already contains items, therefore you cannot modify its company.'))
+ company = self.env['res.company'].browse(vals['company_id'])
+ if self.bank_account_id.company_id and self.bank_account_id.company_id != company:
+ self.bank_account_id.write({
+ 'company_id': company.id,
+ 'partner_id': company.partner_id.id,
+ })
if ('code' in vals and journal.code != vals['code']):
if self.env['account.move'].search([('journal_id', 'in', self.ids)], limit=1):
raise UserError(_('This journal already contains items, therefore you cannot modify its short name.'))
@@ -528,8 +535,16 @@ class AccountJournal(models.Model):
self.default_debit_account_id.currency_id = vals['currency_id']
if not 'default_credit_account_id' in vals and self.default_credit_account_id:
self.default_credit_account_id.currency_id = vals['currency_id']
- if 'bank_account_id' in vals and not vals.get('bank_account_id'):
- raise UserError(_('You cannot empty the bank account once set.'))
+ if self.bank_account_id:
+ self.bank_account_id.currency_id = vals['currency_id']
+ if 'bank_account_id' in vals:
+ if not vals.get('bank_account_id'):
+ raise UserError(_('You cannot empty the bank account once set.'))
+ else:
+ bank_account = self.env['res.partner.bank'].browse(vals['bank_account_id'])
+ if bank_account.partner_id != company.partner_id:
+ raise UserError(_("The partners of the journal's company and the related bank account mismatch."))
+
result = super(AccountJournal, self).write(vals)
# Create the bank_account_id if necessary
diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py
index acd22989..a9ab1ab1 100644
--- a/addons/account/models/account_bank_statement.py
+++ b/addons/account/models/account_bank_statement.py
@@ -341,7 +341,7 @@ class AccountBankStatement(models.Model):
def link_bank_to_partner(self):
for statement in self:
for st_line in statement.line_ids:
- if st_line.bank_account_id and st_line.partner_id and st_line.bank_account_id.partner_id != st_line.partner_id:
+ if st_line.bank_account_id and st_line.partner_id and not st_line.bank_account_id.partner_id:
st_line.bank_account_id.partner_id = st_line.partner_id
@@ -938,7 +938,7 @@ class AccountBankStatementLine(models.Model):
'currency_id': currency.id,
'amount': abs(total),
'communication': self._get_communication(payment_methods[0] if payment_methods else False),
- 'name': self.statement_id.name,
+ 'name': self.statement_id.name or _("Bank Statement %s") % self.date,
})
# Complete dicts to create both counterpart move lines and write-offs
diff --git a/addons/account/models/account_invoice.py b/addons/account/models/account_invoice.py
index 5da26b99..0d146d5e 100644
--- a/addons/account/models/account_invoice.py
+++ b/addons/account/models/account_invoice.py
@@ -221,7 +221,7 @@ class AccountInvoice(models.Model):
@api.depends('move_id.line_ids.amount_residual')
def _compute_payments(self):
payment_lines = set()
- for line in self.move_id.line_ids:
+ for line in self.move_id.line_ids.filtered(lambda l: l.account_id.id == self.account_id.id):
payment_lines.update(line.mapped('matched_credit_ids.credit_move_id.id'))
payment_lines.update(line.mapped('matched_debit_ids.debit_move_id.id'))
self.payment_move_line_ids = self.env['account.move.line'].browse(list(payment_lines))
@@ -347,7 +347,7 @@ class AccountInvoice(models.Model):
payment_move_line_ids = fields.Many2many('account.move.line', string='Payment Move Lines', compute='_compute_payments', store=True)
user_id = fields.Many2one('res.users', string='Salesperson', track_visibility='onchange',
readonly=True, states={'draft': [('readonly', False)]},
- default=lambda self: self.env.user)
+ default=lambda self: self.env.user, copy=False)
fiscal_position_id = fields.Many2one('account.fiscal.position', string='Fiscal Position', oldname='fiscal_position',
readonly=True, states={'draft': [('readonly', False)]})
commercial_partner_id = fields.Many2one('res.partner', string='Commercial Entity', compute_sudo=True,
@@ -1195,25 +1195,7 @@ class AccountInvoice(models.Model):
@api.model
def line_get_convert(self, line, part):
- return {
- 'date_maturity': line.get('date_maturity', False),
- 'partner_id': part,
- 'name': line['name'],
- 'debit': line['price'] > 0 and line['price'],
- 'credit': line['price'] < 0 and -line['price'],
- 'account_id': line['account_id'],
- 'analytic_line_ids': line.get('analytic_line_ids', []),
- 'amount_currency': line['price'] > 0 and abs(line.get('amount_currency', False)) or -abs(line.get('amount_currency', False)),
- 'currency_id': line.get('currency_id', False),
- 'quantity': line.get('quantity', 1.00),
- 'product_id': line.get('product_id', False),
- 'product_uom_id': line.get('uom_id', False),
- 'analytic_account_id': line.get('account_analytic_id', False),
- 'invoice_id': line.get('invoice_id', False),
- 'tax_ids': line.get('tax_ids', False),
- 'tax_line_id': line.get('tax_line_id', False),
- 'analytic_tag_ids': line.get('analytic_tag_ids', False),
- }
+ return self.env['product.product']._convert_prepared_anglosaxon_line(line, part)
@api.multi
def action_cancel(self):
@@ -1343,6 +1325,7 @@ class AccountInvoice(models.Model):
values['state'] = 'draft'
values['number'] = False
values['origin'] = invoice.number
+ values['payment_term_id'] = False
values['refund_invoice_id'] = invoice.id
if date:
@@ -1735,6 +1718,7 @@ class AccountPaymentTerm(models.Model):
def compute(self, value, date_ref=False):
date_ref = date_ref or fields.Date.today()
amount = value
+ sign = value < 0 and -1 or 1
result = []
if self.env.context.get('currency_id'):
currency = self.env['res.currency'].browse(self.env.context['currency_id'])
@@ -1742,7 +1726,7 @@ class AccountPaymentTerm(models.Model):
currency = self.env.user.company_id.currency_id
for line in self.line_ids:
if line.value == 'fixed':
- amt = currency.round(line.value_amount)
+ amt = sign * currency.round(line.value_amount)
elif line.value == 'percent':
amt = currency.round(value * (line.value_amount / 100.0))
elif line.value == 'balance':
diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py
index ebad4b50..600c162d 100644
--- a/addons/account/models/account_journal_dashboard.py
+++ b/addons/account/models/account_journal_dashboard.py
@@ -224,7 +224,7 @@ class account_journal(models.Model):
data as its first element, and the arguments dictionary to use to run
it as its second.
"""
- return ("""SELECT state, amount_total, currency_id AS currency
+ return ("""SELECT state, amount_total, currency_id AS currency, type
FROM account_invoice
WHERE journal_id = %(journal_id)s AND state = 'open';""", {'journal_id':self.id})
@@ -234,7 +234,7 @@ class account_journal(models.Model):
gather the bills in draft state data, and the arguments
dictionary to use to run it as its second.
"""
- return ("""SELECT state, amount_total, currency_id AS currency
+ return ("""SELECT state, amount_total, currency_id AS currency, type
FROM account_invoice
WHERE journal_id = %(journal_id)s AND state = 'draft';""", {'journal_id':self.id})
@@ -247,7 +247,9 @@ class account_journal(models.Model):
for result in results_dict:
cur = self.env['res.currency'].browse(result.get('currency'))
rslt_count += 1
- rslt_sum += cur.compute(result.get('amount_total'), target_currency)
+
+ type_factor = result.get('type') in ('in_refund', 'out_refund') and -1 or 1
+ rslt_sum += type_factor * cur.compute(result.get('amount_total'), target_currency)
return (rslt_count, rslt_sum)
@api.multi
@@ -352,6 +354,8 @@ class account_journal(models.Model):
})
[action] = self.env.ref('account.%s' % action_name).read()
+ if not self.env.context.get('use_domain'):
+ ctx['search_default_journal_id'] = self.id
action['context'] = ctx
action['domain'] = self._context.get('use_domain', [])
account_invoice_filter = self.env.ref('account.view_account_invoice_filter', False)
diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py
index f2f618d6..6779ba18 100644
--- a/addons/account/models/account_move.py
+++ b/addons/account/models/account_move.py
@@ -107,7 +107,7 @@ class AccountMove(models.Model):
default=lambda self: self.env.user.company_id)
matched_percentage = fields.Float('Percentage Matched', compute='_compute_matched_percentage', digits=0, store=True, readonly=True, help="Technical field used in cash basis method")
# Dummy Account field to search on account.move by account_id
- dummy_account_id = fields.Many2one('account.account', related='line_ids.account_id', string='Account', store=False)
+ dummy_account_id = fields.Many2one('account.account', related='line_ids.account_id', string='Account', store=False, readonly=True)
tax_cash_basis_rec_id = fields.Many2one(
'account.partial.reconcile',
string='Tax Cash Basis Entry of',
@@ -291,7 +291,7 @@ class AccountMoveLine(models.Model):
if not cr.fetchone():
cr.execute('CREATE INDEX account_move_line_partner_id_ref_idx ON account_move_line (partner_id, ref)')
- @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'account_id.currency_id', 'move_id.state')
+ @api.depends('debit', 'credit', 'amount_currency', 'currency_id', 'matched_debit_ids', 'matched_credit_ids', 'matched_debit_ids.amount', 'matched_credit_ids.amount', 'move_id.state')
def _amount_residual(self):
""" Computes the residual amount of a move line from a reconciliable account in the company currency and the line's currency.
This amount will be 0 for fully reconciled lines or lines from a non-reconciliable account, the original line amount
@@ -525,7 +525,7 @@ class AccountMoveLine(models.Model):
raise ValidationError(_("You cannot create journal items with a secondary currency without filling both 'currency' and 'amount currency' field."))
@api.multi
- @api.constrains('amount_currency')
+ @api.constrains('amount_currency', 'debit', 'credit')
def _check_currency_amount(self):
for line in self:
if line.amount_currency:
@@ -936,7 +936,9 @@ class AccountMoveLine(models.Model):
def _get_pair_to_reconcile(self):
#field is either 'amount_residual' or 'amount_residual_currency' (if the reconciled account has a secondary currency set)
- field = self[0].account_id.currency_id and 'amount_residual_currency' or 'amount_residual'
+ company_currency_id = self[0].account_id.company_id.currency_id
+ account_curreny_id = self[0].account_id.currency_id
+ field = (account_curreny_id and company_currency_id != account_curreny_id) and 'amount_residual_currency' or 'amount_residual'
#reconciliation on bank accounts are special cases as we don't want to set them as reconciliable
#but we still want to reconcile entries that are reversed together in order to clear those lines
#in the bank reconciliation report.
@@ -952,7 +954,7 @@ class AccountMoveLine(models.Model):
elif self._context.get('skip_full_reconcile_check') == 'amount_currency_only':
field = 'amount_residual_currency'
#target the pair of move in self that are the oldest
- sorted_moves = sorted(self, key=lambda a: a.date)
+ sorted_moves = sorted(self, key=lambda a: a.date_maturity or a.date)
debit = credit = False
for aml in sorted_moves:
if credit and debit:
@@ -974,8 +976,9 @@ class AccountMoveLine(models.Model):
#there is no more pair to reconcile so return what move_line are left
if not sm_credit_move or not sm_debit_move:
return self
-
- field = self[0].account_id.currency_id and 'amount_residual_currency' or 'amount_residual'
+ company_currency_id = self[0].account_id.company_id.currency_id
+ account_curreny_id = self[0].account_id.currency_id
+ field = (account_curreny_id and company_currency_id != account_curreny_id) and 'amount_residual_currency' or 'amount_residual'
if not sm_debit_move.debit and not sm_debit_move.credit:
#both debit and credit field are 0, consider the amount_residual_currency field because it's an exchange difference entry
field = 'amount_residual_currency'
@@ -1198,6 +1201,11 @@ class AccountMoveLine(models.Model):
account_move_line.payment_id.write({'invoice_ids': [(3, invoice.id, None)]})
rec_move_ids += account_move_line.matched_debit_ids
rec_move_ids += account_move_line.matched_credit_ids
+ if self.env.context.get('invoice_id'):
+ current_invoice = self.env['account.invoice'].browse(self.env.context['invoice_id'])
+ rec_move_ids = rec_move_ids.filtered(
+ lambda r: (r.debit_move_id + r.credit_move_id) & current_invoice.move_id.line_ids
+ )
return rec_move_ids.unlink()
####################################################
@@ -1361,7 +1369,7 @@ class AccountMoveLine(models.Model):
record.payment_id.state = 'reconciled'
result = super(AccountMoveLine, self).write(vals)
- if self._context.get('check_move_validity', True):
+ if self._context.get('check_move_validity', True) and any(key in vals for key in ('account_id', 'journal_id', 'date', 'move_id', 'debit', 'credit')):
move_ids = set()
for line in self:
if line.move_id.id not in move_ids:
@@ -1381,7 +1389,7 @@ class AccountMoveLine(models.Model):
raise UserError(_('You cannot do this modification on a reconciled entry. You can just change some non legal fields or you must unreconcile first.\n%s.') % err_msg)
if line.move_id.id not in move_ids:
move_ids.add(line.move_id.id)
- self.env['account.move'].browse(list(move_ids))._check_lock_date()
+ self.env['account.move'].browse(list(move_ids))._check_lock_date()
return True
####################################################
diff --git a/addons/account/models/account_payment.py b/addons/account/models/account_payment.py
index 989ee375..ae879cce 100644
--- a/addons/account/models/account_payment.py
+++ b/addons/account/models/account_payment.py
@@ -102,7 +102,7 @@ class account_abstract_payment(models.AbstractModel):
class account_register_payments(models.TransientModel):
_name = "account.register.payments"
- _inherit = ['account.abstract.payment']
+ _inherit = 'account.abstract.payment'
_description = "Register payments on multiple invoices"
invoice_ids = fields.Many2many('account.invoice', string='Invoices', copy=False)
@@ -332,7 +332,7 @@ class account_payment(models.Model):
def _compute_journal_domain_and_types(self):
journal_type = ['bank', 'cash']
domain = []
- if self.currency_id.is_zero(self.amount):
+ if self.currency_id.is_zero(self.amount) and self.has_invoices:
# In case of payment with 0 amount, allow to select a journal of type 'general' like
# 'Miscellaneous Operations' and set this journal by default.
journal_type = ['general']
@@ -340,7 +340,7 @@ class account_payment(models.Model):
else:
if self.payment_type == 'inbound':
domain.append(('at_least_one_inbound', '=', True))
- else:
+ elif self.payment_type == 'outbound':
domain.append(('at_least_one_outbound', '=', True))
return {'domain': domain, 'journal_types': set(journal_type)}
@@ -349,9 +349,16 @@ class account_payment(models.Model):
jrnl_filters = self._compute_journal_domain_and_types()
journal_types = jrnl_filters['journal_types']
domain_on_types = [('type', 'in', list(journal_types))]
- if self.journal_id.type not in journal_types:
- self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1)
- return {'domain': {'journal_id': jrnl_filters['domain'] + domain_on_types}}
+
+ journal_domain = jrnl_filters['domain'] + domain_on_types
+ default_journal_id = self.env.context.get('default_journal_id')
+ if not default_journal_id:
+ if self.journal_id.type not in journal_types:
+ self.journal_id = self.env['account.journal'].search(domain_on_types, limit=1)
+ else:
+ journal_domain = journal_domain.append(('id', '=', default_journal_id))
+
+ return {'domain': {'journal_id': journal_domain}}
@api.one
@api.depends('invoice_ids', 'payment_type', 'partner_type', 'partner_id')
@@ -382,6 +389,8 @@ class account_payment(models.Model):
self.partner_type = 'customer'
elif self.payment_type == 'outbound':
self.partner_type = 'supplier'
+ else:
+ self.partner_type = False
# Set payment method domain
res = self._onchange_journal()
if not res.get('domain', {}):
@@ -522,7 +531,7 @@ class account_payment(models.Model):
if any(len(record.invoice_ids) != 1 for record in self):
# For multiple invoices, there is account.register.payments wizard
raise UserError(_("This method should only be called to process a single invoice's payment."))
- self.post();
+ return self.post()
def _create_payment_entry(self, amount):
""" Create a journal entry corresponding to a payment, if the payment references invoice(s) they are reconciled.
@@ -571,9 +580,9 @@ class account_payment(models.Model):
writeoff_line['amount_currency'] = amount_currency_wo
writeoff_line['currency_id'] = currency_id
writeoff_line = aml_obj.create(writeoff_line)
- if counterpart_aml['debit'] or writeoff_line['credit']:
+ if counterpart_aml['debit'] or (writeoff_line['credit'] and not counterpart_aml['credit']):
counterpart_aml['debit'] += credit_wo - debit_wo
- if counterpart_aml['credit'] or writeoff_line['debit']:
+ if counterpart_aml['credit'] or (writeoff_line['debit'] and not counterpart_aml['debit']):
counterpart_aml['credit'] += debit_wo - credit_wo
counterpart_aml['amount_currency'] -= amount_currency_wo
diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py
index f1e3bc07..94a6cfb3 100644
--- a/addons/account/models/chart_template.py
+++ b/addons/account/models/chart_template.py
@@ -745,6 +745,9 @@ class WizardMultiChartsAccounts(models.TransientModel):
res.setdefault('domain', {})
res['domain']['sale_tax_id'] = repr(sale_tax_domain)
res['domain']['purchase_tax_id'] = repr(purchase_tax_domain)
+ else:
+ self.sale_tax_id = False
+ self.purchase_tax_id = False
if self.chart_template_id.transfer_account_id:
self.transfer_account_id = self.chart_template_id.transfer_account_id.id
if self.chart_template_id.code_digits:
@@ -775,7 +778,7 @@ class WizardMultiChartsAccounts(models.TransientModel):
if company_id:
company = self.env['res.company'].browse(company_id)
currency_id = company.on_change_country(company.country_id.id)['value']['currency_id']
- res.update({'currency_id': currency_id.id})
+ res.update({'currency_id': currency_id})
chart_templates = account_chart_template.search([('visible', '=', True)])
if chart_templates:
diff --git a/addons/account/models/company.py b/addons/account/models/company.py
index 64be319d..d9b9fe62 100644
--- a/addons/account/models/company.py
+++ b/addons/account/models/company.py
@@ -136,6 +136,12 @@ Best Regards,'''))
company.reflect_code_prefix_change(company.cash_account_code_prefix, new_cash_code, digits)
if values.get('accounts_code_digits'):
company.reflect_code_digits_change(digits)
+
+ #forbid the change of currency_id if there are already some accounting entries existing
+ if 'currency_id' in values and values['currency_id'] != company.currency_id.id:
+ if self.env['account.move.line'].search([('company_id', '=', company.id)]):
+ raise UserError(_('You cannot change the currency of the company since some journal items already exist'))
+
return super(ResCompany, self).write(values)
@api.model
@@ -271,7 +277,7 @@ Best Regards,'''))
default_journal = self.env['account.journal'].search([('type', '=', 'general'), ('company_id', '=', self.id)], limit=1)
if not default_journal:
- raise UserError(_("No miscellaneous journal could be found. Please create one before proceeding."))
+ raise UserError(_("Please install a chart of accounts or create a miscellaneous journal before proceeding."))
self.account_opening_move_id = self.env['account.move'].create({
'name': _('Opening Journal Entry'),
diff --git a/addons/account/models/product.py b/addons/account/models/product.py
index b5556558..a5a6afb5 100644
--- a/addons/account/models/product.py
+++ b/addons/account/models/product.py
@@ -71,3 +71,28 @@ class ProductTemplate(models.Model):
if not fiscal_pos:
fiscal_pos = self.env['account.fiscal.position']
return fiscal_pos.map_accounts(accounts)
+
+class ProductProduct(models.Model):
+ _inherit = "product.product"
+
+ @api.model
+ def _convert_prepared_anglosaxon_line(self, line, partner):
+ return {
+ 'date_maturity': line.get('date_maturity', False),
+ 'partner_id': partner,
+ 'name': line['name'],
+ 'debit': line['price'] > 0 and line['price'],
+ 'credit': line['price'] < 0 and -line['price'],
+ 'account_id': line['account_id'],
+ 'analytic_line_ids': line.get('analytic_line_ids', []),
+ 'amount_currency': line['price'] > 0 and abs(line.get('amount_currency', False)) or -abs(line.get('amount_currency', False)),
+ 'currency_id': line.get('currency_id', False),
+ 'quantity': line.get('quantity', 1.00),
+ 'product_id': line.get('product_id', False),
+ 'product_uom_id': line.get('uom_id', False),
+ 'analytic_account_id': line.get('account_analytic_id', False),
+ 'invoice_id': line.get('invoice_id', False),
+ 'tax_ids': line.get('tax_ids', False),
+ 'tax_line_id': line.get('tax_line_id', False),
+ 'analytic_tag_ids': line.get('analytic_tag_ids', False),
+ }
diff --git a/addons/account/report/account_aged_partner_balance.py b/addons/account/report/account_aged_partner_balance.py
index 3087c502..d7ce4aac 100644
--- a/addons/account/report/account_aged_partner_balance.py
+++ b/addons/account/report/account_aged_partner_balance.py
@@ -27,7 +27,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
res = []
total = []
cr = self.env.cr
- user_company = self.env.user.company_id.id
+ company_ids = self.env.context.get('company_ids', (self.env.user.company_id.id,))
move_state = ['draft', 'posted']
branch = ''
if branch_id:
@@ -44,7 +44,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
if reconciled_after_date:
reconciliation_clause = '(l.reconciled IS FALSE OR l.id IN %s)'
arg_list += (tuple(reconciled_after_date),)
- arg_list += (date_from, user_company)
+ arg_list += (date_from, tuple(company_ids))
query = '''
SELECT DISTINCT l.partner_id, UPPER(res_partner.name)
FROM account_move_line AS l left join res_partner on l.partner_id = res_partner.id, account_account, account_move am
@@ -54,7 +54,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (account_account.internal_type IN %s)
AND ''' + reconciliation_clause + branch +'''
AND (l.date <= %s)
- AND l.company_id = %s
+ AND l.company_id IN %s
ORDER BY UPPER(res_partner.name)'''
cr.execute(query, arg_list)
@@ -79,8 +79,8 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND (COALESCE(l.date_maturity,l.date) > %s)\
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND (l.date <= %s) ''' + branch + '''
- AND l.company_id = %s'''
- cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, user_company))
+ AND l.company_id IN %s'''
+ cr.execute(query, (tuple(move_state), tuple(account_type), date_from, tuple(partner_ids), date_from, tuple(company_ids)))
aml_ids = cr.fetchall()
aml_ids = aml_ids and [x[0] for x in aml_ids] or []
for line in self.env['account.move.line'].browse(aml_ids):
@@ -120,7 +120,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
else:
dates_query += ' <= %s)'
args_list += (periods[str(i)]['stop'],)
- args_list += (date_from, user_company)
+ args_list += (date_from, tuple(company_ids))
query = '''SELECT l.id
FROM account_move_line AS l, account_account, account_move am
@@ -130,7 +130,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
AND ((l.partner_id IN %s) OR (l.partner_id IS NULL))
AND ''' + dates_query + branch +'''
AND (l.date <= %s)
- AND l.company_id = %s'''
+ AND l.company_id IN %s'''
cr.execute(query, args_list)
partners_amount = {}
aml_ids = cr.fetchall()
@@ -193,7 +193,7 @@ class ReportAgedPartnerBalance(models.AbstractModel):
values['name'] = _('Unknown Partner')
values['trust'] = False
- if at_least_one_amount:
+ if at_least_one_amount or self._context.get('include_nullified_amount'):
res.append(values)
return res, total, lines
diff --git a/addons/account/report/account_partner_ledger.py b/addons/account/report/account_partner_ledger.py
index de33ee50..514dc88e 100644
--- a/addons/account/report/account_partner_ledger.py
+++ b/addons/account/report/account_partner_ledger.py
@@ -14,7 +14,7 @@ class ReportPartnerLedger(models.AbstractModel):
full_account = []
currency = self.env['res.currency']
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
- reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".reconciled = false '
+ reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
params = [partner.id, tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
query = """
SELECT "account_move_line".id, "account_move_line".date, j.code, acc.code as a_code, acc.name as a_name, "account_move_line".ref, m.name as move_name, "account_move_line".name, "account_move_line".debit, "account_move_line".credit, "account_move_line".amount_currency,"account_move_line".currency_id, c.symbol AS currency_code
@@ -51,7 +51,7 @@ class ReportPartnerLedger(models.AbstractModel):
return
result = 0.0
query_get_data = self.env['account.move.line'].with_context(data['form'].get('used_context', {}))._query_get()
- reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".reconciled = false '
+ reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
params = [partner.id, tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
query = """SELECT sum(""" + field + """)
@@ -95,7 +95,7 @@ class ReportPartnerLedger(models.AbstractModel):
AND NOT a.deprecated""", (tuple(data['computed']['ACCOUNT_TYPE']),))
data['computed']['account_ids'] = [a for (a,) in self.env.cr.fetchall()]
params = [tuple(data['computed']['move_state']), tuple(data['computed']['account_ids'])] + query_get_data[2]
- reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".reconciled = false '
+ reconcile_clause = "" if data['form']['reconciled'] else ' AND "account_move_line".full_reconcile_id IS NULL '
query = """
SELECT DISTINCT "account_move_line".partner_id
FROM """ + query_get_data[0] + """, account_account AS account, account_move AS am
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_model.js b/addons/account/static/src/js/reconciliation/reconciliation_model.js
index 8c6595a4..c7fd2779 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_model.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_model.js
@@ -524,17 +524,37 @@ var StatementModel = BasicModel.extend({
*/
togglePartialReconcile: function (handle) {
var line = this.getLine(handle);
- var props = _.filter(line.reconciliation_proposition, {'invalid': false});
- var prop = props[0];
- if (props.length !== 1 || Math.abs(line.st_line.amount) >= Math.abs(prop.amount)) {
+
+ // Retrieve the toggle proposition
+ var selected;
+ _.each(line.reconciliation_proposition, function (prop) {
+ if (!prop.invalid) {
+ if (((line.balance.amount < 0 || !line.partial_reconcile) && prop.amount > 0 && line.st_line.amount > 0 && line.st_line.amount < prop.amount) ||
+ ((line.balance.amount > 0 || !line.partial_reconcile) && prop.amount < 0 && line.st_line.amount < 0 && line.st_line.amount > prop.amount)) {
+ selected = prop;
+ return false;
+ }
+ }
+ });
+
+ // If no toggled proposition found, reject it
+ if (selected == null)
return $.Deferred().reject();
- }
- prop.partial_reconcile = !prop.partial_reconcile;
- if (!prop.partial_reconcile) {
+
+ // Inverse partial_reconcile value
+ selected.partial_reconcile = !selected.partial_reconcile;
+ if (!selected.partial_reconcile) {
return this._computeLine(line);
}
+
+ // Compute the write_off
+ var format_options = { currency_id: line.st_line.currency_id };
+ selected.write_off_amount = selected.amount + line.balance.amount;
+ selected.write_off_amount_str = field_utils.format.monetary(Math.abs(selected.write_off_amount), {}, format_options);
+ selected.write_off_amount_str = selected.write_off_amount_str.replace(' ', ' ');
+
return this._computeLine(line).then(function () {
- if (prop.partial_reconcile) {
+ if (selected.partial_reconcile) {
line.balance.amount = 0;
line.balance.type = 1;
line.mode = 'inactive';
@@ -594,7 +614,7 @@ var StatementModel = BasicModel.extend({
handles = [handle];
} else {
_.each(this.lines, function (line, handle) {
- if (!line.reconciled && !line.balance.amount && line.reconciliation_proposition.length) {
+ if (!line.reconciled && line.balance && !line.balance.amount && line.reconciliation_proposition.length) {
handles.push(handle);
}
});
@@ -1051,7 +1071,7 @@ var StatementModel = BasicModel.extend({
// Do not forward port in master. @CSN will change this
var amount = prop.computed_with_tax && -prop.base_amount || -prop.amount;
if (prop.partial_reconcile === true) {
- amount = -line.st_line.amount;
+ amount = -prop.write_off_amount;
}
var result = {
name : prop.label,
diff --git a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
index 9b21da9b..58ee890f 100644
--- a/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
+++ b/addons/account/static/src/js/reconciliation/reconciliation_renderer.js
@@ -354,7 +354,21 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
// reconciliation_proposition
var $props = this.$('.accounting_view tbody').empty();
- var props = _.filter(state.reconciliation_proposition, {'display': true});
+
+ // loop state propositions
+ var props = [];
+ var nb_debit_props = 0;
+ var nb_credit_props = 0;
+ _.each(state.reconciliation_proposition, function (prop) {
+ if (prop.display) {
+ props.push(prop);
+ if (prop.amount < 0)
+ nb_debit_props += 1;
+ else if (prop.amount > 0)
+ nb_credit_props += 1;
+ }
+ });
+
_.each(props, function (line) {
var $line = $(qweb.render("reconciliation.line.mv_line", {'line': line, 'state': state}));
if (!isNaN(line.id)) {
@@ -362,18 +376,16 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
.appendTo($line.find('.cell_info_popover'))
.attr("data-content", qweb.render('reconciliation.line.mv_line.details', {'line': line}));
}
-
- if ((state.balance.amount_currency !== 0 || line.partial_reconcile) && props.length === 1 &&
- line.already_paid === false &&
- (
- (state.st_line.amount > 0 && state.st_line.amount < props[0].amount) ||
- (state.st_line.amount < 0 && state.st_line.amount > props[0].amount))
- ) {
+ if (line.already_paid === false &&
+ ((state.balance.amount_currency < 0 || line.partial_reconcile) && nb_credit_props == 1
+ && line.amount > 0 && state.st_line.amount > 0 && state.st_line.amount < line.amount) ||
+ ((state.balance.amount_currency > 0 || line.partial_reconcile) && nb_debit_props == 1
+ && line.amount < 0 && state.st_line.amount < 0 && state.st_line.amount > line.amount)) {
var $cell = $line.find(line.amount > 0 ? '.cell_right' : '.cell_left');
var text;
if (line.partial_reconcile) {
text = _t("Undo the partial reconciliation.");
- $cell.text(state.st_line.amount_str);
+ $cell.text(line.write_off_amount_str);
} else {
text = _t("This move's amount is higher than the transaction's amount. Click to register a partial payment and keep the payment balance open.");
}
@@ -415,14 +427,15 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
this._renderCreate(state);
}
var data = this.model.get(this.handleCreateRecord).data;
- this.model.notifyChanges(this.handleCreateRecord, state.createForm);
- var record = this.model.get(this.handleCreateRecord);
- _.each(this.fields, function (field, fieldName) {
- if (self._avoidFieldUpdate[fieldName]) return;
- if (fieldName === "partner_id") return;
- if ((data[fieldName] || state.createForm[fieldName]) && !_.isEqual(state.createForm[fieldName], data[fieldName])) {
- field.reset(record);
- }
+ this.model.notifyChanges(this.handleCreateRecord, state.createForm).then(function () {
+ var record = self.model.get(self.handleCreateRecord);
+ _.each(self.fields, function (field, fieldName) {
+ if (self._avoidFieldUpdate[fieldName]) return;
+ if (fieldName === "partner_id") return;
+ if ((data[fieldName] || state.createForm[fieldName]) && !_.isEqual(state.createForm[fieldName], data[fieldName])) {
+ field.reset(record);
+ }
+ });
});
}
this.$('.create .add_line').toggle(!!state.balance.amount_currency);
@@ -626,7 +639,8 @@ var LineRenderer = Widget.extend(FieldManagerMixin, {
return;
}
if(event.keyCode === 13) {
- if (_.findWhere(this.model.lines, {mode: 'create'}).balance.amount) {
+ var created_lines = _.findWhere(this.model.lines, {mode: 'create'});
+ if (created_lines && created_lines.balance.amount) {
this._onCreateProposition();
}
return;
diff --git a/addons/account/static/tests/reconciliation_tests.js b/addons/account/static/tests/reconciliation_tests.js
index ddf2c57d..0cbb4f9a 100644
--- a/addons/account/static/tests/reconciliation_tests.js
+++ b/addons/account/static/tests/reconciliation_tests.js
@@ -34,17 +34,22 @@ var db = {
fields: {
id: {string: "ID", type: 'integer'},
code: {string: "code", type: 'integer'},
- display_name: {string: "Displayed name", type: 'char'},
+ name: {string: "Displayed name", type: 'char'},
},
records: [
- {id: 282, code: 100000, display_name: "100000 Fixed Asset Account"},
- {id: 283, code: 101000, display_name: "101000 Current Assets"},
- {id: 284, code: 101110, display_name: "101110 Stock Valuation Account"},
- {id: 285, code: 101120, display_name: "101120 Stock Interim Account (Received)"},
- {id: 286, code: 101130, display_name: "101130 Stock Interim Account (Delivered)"},
- {id: 287, code: 101200, display_name: "101200 Account Receivable"},
- {id: 288, code: 101300, display_name: "101300 Tax Paid"},
- {id: 308, code: 101401, display_name: "101401 Bank"},
+ {id: 282, code: 100000, name: "100000 Fixed Asset Account"},
+ {id: 283, code: 101000, name: "101000 Current Assets"},
+ {id: 284, code: 101110, name: "101110 Stock Valuation Account"},
+ {id: 285, code: 101120, name: "101120 Stock Interim Account (Received)"},
+ {id: 286, code: 101130, name: "101130 Stock Interim Account (Delivered)"},
+ {id: 287, code: 101200, name: "101200 Account Receivable"},
+ {id: 288, code: 101300, name: "101300 Tax Paid"},
+ {id: 308, code: 101401, name: "101401 Bank"},
+ {id: 500, code: 500, name: "500 Account"},
+ {id: 501, code: 501, name: "501 Account"},
+ {id: 502, code: 502, name: "502 Account"},
+ {id: 503, code: 503, name: "503 Account"},
+ {id: 504, code: 504, name: "504 Account"},
],
mark_as_reconciled: function () {
return $.when();
@@ -767,13 +772,7 @@ QUnit.module('account', {
partner_id: false,
counterpart_aml_dicts:[],
payment_aml_ids: [392],
- new_aml_dicts: [
- {
- "credit": 343.42,
- "debit": 0,
- "name": "Bank fees : Open balance"
- }
- ],
+ new_aml_dicts: [],
}]
], "should call process_reconciliations with partial reconcile values");
}
@@ -802,14 +801,14 @@ QUnit.module('account', {
assert.notOk( widget.$('.cell_left .line_info_button').length, "should not display the partial reconciliation alert");
widget.$('.accounting_view thead td:first').trigger('click');
widget.$('.match .cell_account_code:first').trigger('click');
- assert.equal( widget.$('.accounting_view tbody .cell_left .line_info_button').length, 0, "should not display the partial reconciliation alert");
+ assert.equal( widget.$('.accounting_view tbody .cell_left .line_info_button').length, 1, "should display the partial reconciliation alert");
assert.ok( widget.$('button.btn-primary:not(hidden)').length, "should not display the reconcile button");
assert.ok( widget.$('.text-danger:not(hidden)').length, "should display counterpart alert");
widget.$('.accounting_view .cell_left .line_info_button').trigger('click');
- assert.strictEqual(widget.$('.accounting_view .cell_left .line_info_button').length, 0, "should not display a partial reconciliation alert");
- assert.notOk(widget.$('.accounting_view .cell_left .line_info_button').hasClass('do_partial_reconcile_false'), "should not display the partial reconciliation information");
+ assert.strictEqual(widget.$('.accounting_view .cell_left .line_info_button').length, 1, "should display a partial reconciliation alert");
+ assert.notOk(widget.$('.accounting_view .cell_left .line_info_button').hasClass('do_partial_reconcile_true'), "should display the partial reconciliation information");
assert.ok( widget.$('button.btn-default:not(hidden)').length, "should display the validate button");
- assert.strictEqual( widget.$el.data('mode'), "match", "should be inactive mode");
+ assert.strictEqual( widget.$el.data('mode'), "inactive", "should be inactive mode");
widget.$('button.btn-default:not(hidden)').trigger('click');
clientAction.destroy();
@@ -1027,13 +1026,77 @@ QUnit.module('account', {
clientAction.destroy();
});
+ QUnit.test('Reconciliation create line (many2one test)', function (assert) {
+ assert.expect(5);
+
+ var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
+ var def = $.Deferred();
+
+ testUtils.addMockEnvironment(clientAction, {
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$"
+ }
+ }
+ },
+ archs: {
+ "account.account,false,list": '',
+ "account.account,false,search": '',
+ },
+ mockRPC: function (route, args) {
+ if (args.method === 'name_get') {
+ return def.then(this._super.bind(this, route, args));
+ }
+ return this._super(route, args);
+ },
+ });
+
+ clientAction.prependTo($('#qunit-fixture'));
+
+ var widget = clientAction.widgets[0];
+
+ // open the first line in write-off mode
+ widget.$('.accounting_view tfoot td:first').trigger('click');
+
+ // select an account with the many2one (drop down)
+ widget.$('.create .create_account_id input').trigger('click');
+ $('.ui-autocomplete .ui-menu-item a:contains(101200)').trigger('mouseenter').trigger('click');
+ assert.strictEqual(widget.$('.create .create_account_id input').val(), "101200 Account Receivable", "Display the selected account");
+ assert.strictEqual(widget.$('tbody:first .cell_account_code').text(), "101200", "Display the code of the selected account");
+
+ // use the many2one select dialog to change the account
+ widget.$('.create .create_account_id input').trigger('click');
+ $('.ui-autocomplete .ui-menu-item a:contains(Search)').trigger('mouseenter').trigger('click');
+ // select the account who does not appear in the drop drown
+ $('.modal tr.o_data_row:contains(502)').click();
+ assert.strictEqual(widget.$('.create .create_account_id input').val(), "101200 Account Receivable", "Selected account does not change");
+ // wait the name_get to render the changes
+ def.resolve();
+ assert.strictEqual(widget.$('.create .create_account_id input').val(), "502 Account", "Display the selected account");
+ assert.strictEqual(widget.$('tbody:first .cell_account_code').text(), "502", "Display the code of the selected account");
+ clientAction.destroy();
+ });
+
QUnit.test('Reconciliation create line with taxes', function (assert) {
assert.expect(13);
var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
- 'data': this.params.data,
+ data: this.params.data,
+ session: {
+ currencies: {
+ 3: {
+ digits: [69, 2],
+ position: "before",
+ symbol: "$"
+ }
+ }
+ },
});
clientAction.appendTo($('#qunit-fixture'));
@@ -1045,26 +1108,26 @@ QUnit.module('account', {
widget.$('.create .create_label input').val('test1').trigger('input');
widget.$('.create .create_amount input').val('1100').trigger('input');
- assert.strictEqual(widget.$('.accounting_view tbody .cell_right:last').text(), "1100.00", "should display the value 1100.00 in left column");
+ assert.strictEqual(widget.$('.accounting_view tbody .cell_right:last').text(), "$\u00a01100.00", "should display the value 1100.00 in left column");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'");
- assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "75.00", "should display 'Open Balance' with 75.00 in right column");
+ assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$\u00a075.00", "should display 'Open Balance' with 75.00 in right column");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 1, "should have 1 created reconcile lines");
widget.$('.create .create_tax_id input').trigger('click');
$('.ui-autocomplete .ui-menu-item a:contains(10.00%)').trigger('mouseenter').trigger('click');
- assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "1000.00100.00", "should have 2 created reconcile lines with right column values");
+ assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "$\u00a01000.00$\u00a0100.00", "should have 2 created reconcile lines with right column values");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Open balance", "should display 'Open Balance'");
- assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "75.00", "should display 'Open Balance' with 75.00 in right column");
+ assert.strictEqual(widget.$('.accounting_view tfoot .cell_right').text(), "$\u00a075.00", "should display 'Open Balance' with 75.00 in right column");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "", "should display 'Open Balance' without any value in left column");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines");
widget.$('.create .create_tax_id input').trigger('click');
$('.ui-autocomplete .ui-menu-item a:contains(20.00%)').trigger('mouseenter').trigger('click');
- assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "1100.00220.00", "should have 2 created reconcile lines with right column values");
+ assert.strictEqual(widget.$('.accounting_view tbody .cell_right').text().replace('$_', ''), "$\u00a01100.00$\u00a0220.00", "should have 2 created reconcile lines with right column values");
assert.strictEqual(widget.$('.accounting_view tfoot .cell_label').text(), "Create Write-off", "should display 'Create Write-off'");
- assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "145.00", "should display 'Create Write-off' with 145.00 in right column");
+ assert.strictEqual(widget.$('.accounting_view tfoot .cell_left').text(), "$\u00a0145.00", "should display 'Create Write-off' with 145.00 in right column");
assert.strictEqual(widget.$('.accounting_view tbody tr').length, 2, "should have 2 created reconcile lines");
clientAction.destroy();
@@ -1076,7 +1139,7 @@ QUnit.module('account', {
var clientAction = new ReconciliationClientAction.StatementAction(null, this.params.options);
testUtils.addMockEnvironment(clientAction, {
- 'data': this.params.data,
+ data: this.params.data,
});
clientAction.appendTo($('#qunit-fixture'));
diff --git a/addons/account/tests/test_payment.py b/addons/account/tests/test_payment.py
index a7c5a528..6abc23ae 100644
--- a/addons/account/tests/test_payment.py
+++ b/addons/account/tests/test_payment.py
@@ -17,7 +17,9 @@ class TestPayment(AccountingTestCase):
self.currency_chf_id = self.env.ref("base.CHF").id
self.currency_usd_id = self.env.ref("base.USD").id
self.currency_eur_id = self.env.ref("base.EUR").id
- self.env.ref('base.main_company').write({'currency_id': self.currency_eur_id})
+
+ company = self.env.ref('base.main_company')
+ self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_eur_id, company.id])
self.product = self.env.ref("product.product_product_4")
self.payment_method_manual_in = self.env.ref("account.account_payment_method_manual_in")
self.payment_method_manual_out = self.env.ref("account.account_payment_method_manual_out")
@@ -318,3 +320,53 @@ class TestPayment(AccountingTestCase):
self.assertEqual(payment_id.payment_type, 'outbound')
self.assertEqual(payment_id.partner_id, self.partner_china_exp)
self.assertEqual(payment_id.partner_type, 'supplier')
+
+ def test_payment_and_writeoff_in_other_currency(self):
+ # Use case:
+ # Company is in EUR, create a customer invoice for 25 EUR and register payment of 25 USD.
+ # Mark invoice as fully paid with a write_off
+ # Check that all the aml are correctly created.
+ invoice = self.create_invoice(amount=25, type='out_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
+ # register payment on invoice
+ payment = self.payment_model.create({'payment_type': 'inbound',
+ 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
+ 'partner_type': 'customer',
+ 'partner_id': self.partner_agrolait.id,
+ 'amount': 25,
+ 'currency_id': self.currency_usd_id,
+ 'payment_date': time.strftime('%Y') + '-07-15',
+ 'payment_difference_handling': 'reconcile',
+ 'writeoff_account_id': self.account_payable.id,
+ 'journal_id': self.bank_journal_euro.id,
+ 'invoice_ids': [(4, invoice.id, None)]
+ })
+ payment.post()
+ self.check_journal_items(payment.move_line_ids, [
+ {'account_id': self.account_eur.id, 'debit': 16.35, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': self.currency_usd_id},
+ {'account_id': self.account_payable.id, 'debit': 8.65, 'credit': 0.0, 'amount_currency': 13.22, 'currency_id': self.currency_usd_id},
+ {'account_id': self.account_receivable.id, 'debit': 0.0, 'credit': 25.0, 'amount_currency': -38.22, 'currency_id': self.currency_usd_id},
+ ])
+ # Use case:
+ # Company is in EUR, create a vendor bill for 25 EUR and register payment of 25 USD.
+ # Mark invoice as fully paid with a write_off
+ # Check that all the aml are correctly created.
+ invoice = self.create_invoice(amount=25, type='in_invoice', currency_id=self.currency_eur_id, partner=self.partner_agrolait.id)
+ # register payment on invoice
+ payment = self.payment_model.create({'payment_type': 'inbound',
+ 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
+ 'partner_type': 'supplier',
+ 'partner_id': self.partner_agrolait.id,
+ 'amount': 25,
+ 'currency_id': self.currency_usd_id,
+ 'payment_date': time.strftime('%Y') + '-07-15',
+ 'payment_difference_handling': 'reconcile',
+ 'writeoff_account_id': self.account_payable.id,
+ 'journal_id': self.bank_journal_euro.id,
+ 'invoice_ids': [(4, invoice.id, None)]
+ })
+ payment.post()
+ self.check_journal_items(payment.move_line_ids, [
+ {'account_id': self.account_eur.id, 'debit': 16.35, 'credit': 0.0, 'amount_currency': 25.0, 'currency_id': self.currency_usd_id},
+ {'account_id': self.account_payable.id, 'debit': 0.0, 'credit': 8.65, 'amount_currency': -13.22, 'currency_id': self.currency_usd_id},
+ {'account_id': self.account_receivable.id, 'debit': 0.0, 'credit': 7.7, 'amount_currency': -11.78, 'currency_id': self.currency_usd_id},
+ ])
diff --git a/addons/account/tests/test_reconciliation.py b/addons/account/tests/test_reconciliation.py
index 7782f885..aeacd3e6 100644
--- a/addons/account/tests/test_reconciliation.py
+++ b/addons/account/tests/test_reconciliation.py
@@ -25,7 +25,8 @@ class TestReconciliation(AccountingTestCase):
self.currency_swiss_id = self.env.ref("base.CHF").id
self.currency_usd_id = self.env.ref("base.USD").id
self.currency_euro_id = self.env.ref("base.EUR").id
- self.env.ref('base.main_company').write({'currency_id': self.currency_euro_id})
+ company = self.env.ref('base.main_company')
+ self.cr.execute("UPDATE res_company SET currency_id = %s WHERE id = %s", [self.currency_euro_id, company.id])
self.account_rcv = partner_agrolait.property_account_receivable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_receivable').id)], limit=1)
self.account_rsa = partner_agrolait.property_account_payable_id or self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_account_type_payable').id)], limit=1)
self.product = self.env.ref("product.product_product_4")
@@ -620,3 +621,39 @@ class TestReconciliation(AccountingTestCase):
# Checking if the direction of the move is correct
full_rec_payable = full_rec_move.line_ids.filtered(lambda l: l.account_id == self.account_rsa)
self.assertEqual(full_rec_payable.balance, 18.75)
+
+ def test_unreconcile(self):
+ # Use case:
+ # 2 invoices paid with a single payment. Unreconcile the payment with one invoice, the
+ # other invoice should remain reconciled.
+ inv1 = self.create_invoice(invoice_amount=10, currency_id=self.currency_usd_id)
+ inv2 = self.create_invoice(invoice_amount=20, currency_id=self.currency_usd_id)
+ payment = self.env['account.payment'].create({
+ 'payment_type': 'inbound',
+ 'payment_method_id': self.env.ref('account.account_payment_method_manual_in').id,
+ 'partner_type': 'customer',
+ 'partner_id': self.partner_agrolait_id,
+ 'amount': 100,
+ 'currency_id': self.currency_usd_id,
+ 'journal_id': self.bank_journal_usd.id,
+ })
+ payment.post()
+ credit_aml = payment.move_line_ids.filtered('credit')
+
+ # Check residual before assignation
+ self.assertAlmostEquals(inv1.residual, 10)
+ self.assertAlmostEquals(inv2.residual, 20)
+
+ # Assign credit and residual
+ inv1.assign_outstanding_credit(credit_aml.id)
+ inv2.assign_outstanding_credit(credit_aml.id)
+ self.assertAlmostEquals(inv1.residual, 0)
+ self.assertAlmostEquals(inv2.residual, 0)
+
+ # Unreconcile one invoice at a time and check residual
+ credit_aml.with_context(invoice_id=inv1.id).remove_move_reconcile()
+ self.assertAlmostEquals(inv1.residual, 10)
+ self.assertAlmostEquals(inv2.residual, 0)
+ credit_aml.with_context(invoice_id=inv2.id).remove_move_reconcile()
+ self.assertAlmostEquals(inv1.residual, 10)
+ self.assertAlmostEquals(inv2.residual, 20)
diff --git a/addons/account/views/account_invoice_view.xml b/addons/account/views/account_invoice_view.xml
index e1ff2f9b..ffeaf4e7 100644
--- a/addons/account/views/account_invoice_view.xml
+++ b/addons/account/views/account_invoice_view.xml
@@ -251,7 +251,7 @@
-
+
@@ -297,7 +297,7 @@
-
+
@@ -396,7 +396,7 @@
-
+
@@ -504,7 +504,7 @@
-
+
@@ -535,10 +535,8 @@
-
-
-
-
+
+
@@ -570,33 +568,25 @@
+
account.invoice.select.invoicesaccount.invoiceprimary
-
-
- Not Draft
-
-
-
+
+
account.invoice.select.credit.notesaccount.invoiceprimary
-
-
- Not Draft
-
-
-
+
@@ -667,7 +657,7 @@
[('type','=','out_invoice')]{'type':'out_invoice', 'journal_type': 'sale'}
-
+
Click to create a customer invoice.
@@ -708,7 +698,7 @@
[('type','=','out_refund')]{'default_type': 'out_refund', 'type': 'out_refund', 'journal_type': 'sale'}
-
+
Click to create a credit note.
@@ -746,7 +736,7 @@
[('type','=','in_invoice')]{'default_type': 'in_invoice', 'type': 'in_invoice', 'journal_type': 'purchase'}
-
+
Click to record a new vendor bill.
@@ -782,7 +772,7 @@
[('type','=','in_refund')]{'default_type': 'in_refund', 'type': 'in_refund', 'journal_type': 'purchase'}
-
+
Click to record a new vendor credit note.
diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml
index a29e1c67..e2e88937 100644
--- a/addons/account/views/account_journal_dashboard_view.xml
+++ b/addons/account/views/account_journal_dashboard_view.xml
@@ -117,16 +117,12 @@
View
diff --git a/addons/auth_signup/models/__init__.py b/addons/auth_signup/models/__init__.py
index 44dc1ee6..a2516026 100644
--- a/addons/auth_signup/models/__init__.py
+++ b/addons/auth_signup/models/__init__.py
@@ -4,3 +4,4 @@ from . import res_config_settings
from . import ir_http
from . import res_partner
from . import res_users
+from . import ir_model_fields
diff --git a/addons/auth_signup/models/ir_model_fields.py b/addons/auth_signup/models/ir_model_fields.py
new file mode 100644
index 00000000..f70faca1
--- /dev/null
+++ b/addons/auth_signup/models/ir_model_fields.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo, Flectra. See LICENSE file for full copyright and \
+# licensing details
+
+from flectra import models
+
+
+# This is a nasty hack, targeted for V11 only
+class IrModelFields(models.Model):
+ _inherit = 'ir.model.fields'
+
+ def unlink(self):
+ # Prevent the deletion of some `shared` fields... -_-
+ self = self.filtered(
+ lambda rec: not (
+ rec.model == 'res.config.settings' and
+ rec.name == 'auth_signup_uninvited'
+ )
+ )
+ return super(IrModelFields, self).unlink()
diff --git a/addons/barcodes/models/__init__.py b/addons/barcodes/models/__init__.py
index 05b9acf6..e3f49cb1 100644
--- a/addons/barcodes/models/__init__.py
+++ b/addons/barcodes/models/__init__.py
@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
from . import barcodes
from . import barcode_events_mixin
+from . import ir_http
diff --git a/addons/barcodes/models/ir_http.py b/addons/barcodes/models/ir_http.py
new file mode 100644
index 00000000..4f835693
--- /dev/null
+++ b/addons/barcodes/models/ir_http.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo, Flectra. See LICENSE file for full copyright and \
+# licensing details.
+
+from flectra import models
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ def session_info(self):
+ res = super(IrHttp, self).session_info()
+ res['max_time_between_keys_in_ms'] = int(
+ self.env['ir.config_parameter'].sudo().get_param(
+ 'barcode.max_time_between_keys_in_ms', default='55'))
+ return res
diff --git a/addons/barcodes/static/src/js/barcode_events.js b/addons/barcodes/static/src/js/barcode_events.js
index f709999a..c9e9b855 100644
--- a/addons/barcodes/static/src/js/barcode_events.js
+++ b/addons/barcodes/static/src/js/barcode_events.js
@@ -3,6 +3,7 @@ flectra.define('barcodes.BarcodeEvents', function(require) {
var core = require('web.core');
var mixins = require('web.mixins');
+var session = require('web.session');
// For IE >= 9, use this, new CustomEvent(), instead of new Event()
@@ -26,7 +27,13 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, {
suffix: /[\n\r\t]+/,
// Keys from a barcode scanner are usually processed as quick as possible,
// but some scanners can use an intercharacter delay (we support <= 50 ms)
- max_time_between_keys_in_ms: 55,
+ max_time_between_keys_in_ms: session.max_time_between_keys_in_ms || 55,
+ // To be able to receive the barcode value, an input must be focused.
+ // On mobile devices, this causes the virtual keyboard to open.
+ // Unfortunately it is not possible to avoid this behavior...
+ // To avoid keyboard flickering at each detection of a barcode value,
+ // we want to keep it open for a while (800 ms).
+ inputTimeOut: 800,
init: function() {
mixins.PropertiesMixin.init.call(this);
@@ -37,6 +44,30 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, {
// Bind event handler once the DOM is loaded
// TODO: find a way to be active only when there are listeners on the bus
$(_.bind(this.start, this, false));
+
+ // Mobile device detection
+ var isMobile = navigator.userAgent.match(/Android/i) ||
+ navigator.userAgent.match(/webOS/i) ||
+ navigator.userAgent.match(/iPhone/i) ||
+ navigator.userAgent.match(/iPad/i) ||
+ navigator.userAgent.match(/iPod/i) ||
+ navigator.userAgent.match(/BlackBerry/i) ||
+ navigator.userAgent.match(/Windows Phone/i);
+ this.isChromeMobile = isMobile && window.chrome;
+
+ // Creates an input who will receive the barcode scanner value.
+ if (this.isChromeMobile) {
+ this.$barcodeInput = $('', {
+ name: 'barcode',
+ type: 'text',
+ css: {
+ 'position': 'absolute',
+ 'opacity': 0,
+ },
+ });
+ }
+
+ this.__removeBarcodeField = _.debounce(this._removeBarcodeField, this.inputTimeOut);
},
handle_buffered_keys: function() {
@@ -108,7 +139,7 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, {
e.key === "ArrowUp" || e.key === "ArrowDown" ||
e.key === "Escape" || e.key === "Tab" ||
e.key === "Backspace" || e.key === "Delete" ||
- /F\d\d?/.test(e.key)) {
+ e.key === "Unidentified" || /F\d\d?/.test(e.key)) {
return true;
} else {
return false;
@@ -166,8 +197,84 @@ var BarcodeEvents = core.Class.extend(mixins.PropertiesMixin, {
}
},
+ /**
+ * Try to detect the barcode value by listening all keydown events:
+ * Checks if a dom element who may contains text value has the focus.
+ * If not, it's probably because these events are triggered by a barcode scanner.
+ * To be able to handle this value, a focused input will be created.
+ *
+ * This function also has the responsibility to detect the end of the barcode value.
+ * (1) In most of cases, an optional key (tab or enter) is sent to mark the end of the value.
+ * So, we direclty handle the value.
+ * (2) If no end key is configured, we have to calculate the delay between each keydowns.
+ * 'max_time_between_keys_in_ms' depends of the device and may be configured.
+ * Exceeded this timeout, we consider that the barcode value is entirely sent.
+ *
+ * @private
+ * @param {jQuery.Event} e keydown event
+ */
+ _listenBarcodeScanner: function (e) {
+ if (!$('input:text:focus, textarea:focus, [contenteditable]:focus').length) {
+ $('body').append(this.$barcodeInput);
+ this.$barcodeInput.focus();
+ }
+ if (this.$barcodeInput.is(":focus")) {
+ // Handle buffered keys immediately if the keypress marks the end
+ // of a barcode or after x milliseconds without a new keypress.
+ clearTimeout(this.timeout);
+ // On chrome mobile, e.which only works for some special characters like ENTER or TAB.
+ if (String.fromCharCode(e.which).match(this.suffix)) {
+ this._handleBarcodeValue(e);
+ } else {
+ this.timeout = setTimeout(this._handleBarcodeValue.bind(this, e),
+ this.max_time_between_keys_in_ms);
+ }
+ // if the barcode input doesn't receive keydown for a while, remove it.
+ this.__removeBarcodeField();
+ }
+ },
+
+ /**
+ * Retrieves the barcode value from the temporary input element.
+ * This checks this value and trigger it on the bus.
+ *
+ * @private
+ * @param {jQuery.Event} keydown event
+ */
+ _handleBarcodeValue: function (e) {
+ var barcodeValue = this.$barcodeInput.val();
+ if (barcodeValue.match(this.regexp)) {
+ core.bus.trigger('barcode_scanned', barcodeValue, $(e.target).parent()[0]);
+ this.$barcodeInput.val('');
+ }
+ },
+
+ /**
+ * Remove the temporary input created to store the barcode value.
+ * If nothing happens, this input will be removed, so the focus will be lost
+ * and the virtual keyboard on mobile devices will be closed.
+ *
+ * @private
+ */
+ _removeBarcodeField: function () {
+ if (this.$barcodeInput) {
+ // Reset the value and remove from the DOM.
+ this.$barcodeInput.val('').remove();
+ }
+ },
+
start: function(prevent_key_repeat){
- $('body').bind("keypress", this.__handler);
+ // Chrome Mobile isn't triggering keypress event.
+ // This is marked as Legacy in the DOM-Level-3 Standard.
+ // See: https://www.w3.org/TR/uievents/#legacy-keyboardevent-event-types
+ // This fix is only applied for Google Chrome Mobile but it should work for
+ // all other cases.
+ // In master, we could remove the behavior with keypress and only use keydown.
+ if (this.isChromeMobile) {
+ $('body').on("keydown", this._listenBarcodeScanner.bind(this));
+ } else {
+ $('body').bind("keypress", this.__handler);
+ }
if (prevent_key_repeat === true) {
$('body').bind("keydown", this.__keydown_handler);
$('body').bind('keyup', this.__keyup_handler);
diff --git a/addons/barcodes/static/src/js/barcode_form_view.js b/addons/barcodes/static/src/js/barcode_form_view.js
index 3ed14f73..a765d5ee 100644
--- a/addons/barcodes/static/src/js/barcode_form_view.js
+++ b/addons/barcodes/static/src/js/barcode_form_view.js
@@ -155,7 +155,7 @@ FormController.include({
id: candidate.id,
data: candidateChanges,
};
- return this.model.notifyChanges(this.handle, changes);
+ return this.model.notifyChanges(this.handle, changes, {notifyChange: activeBarcode.notifyChange});
},
/**
* @private
@@ -211,6 +211,8 @@ FormController.include({
* @param {FlectraEvent} event
* @param {string} event.data.name: the current field name
* @param {string} [event.data.fieldName] optional for x2many sub field
+ * @param {boolean} [event.data.notifyChange] optional for x2many sub field
+ * do not trigger on change server side if a candidate has been found
* @param {string} [event.data.quantity] optional field to increase quantity
* @param {Object} [event.data.commands] optional added methods
* can use comand with specific barcode (with ReservedBarcodePrefixes)
@@ -225,7 +227,9 @@ FormController.include({
handle: this.handle,
target: event.target,
widget: event.target.attrs && event.target.attrs.widget,
+ setQuantityWithKeypress: !! event.data.setQuantityWithKeypress,
fieldName: event.data.fieldName,
+ notifyChange: (event.data.notifyChange !== undefined) ? event.data.notifyChange : true,
quantity: event.data.quantity,
commands: event.data.commands || {},
candidate: this.activeBarcode[name] && this.activeBarcode[name].handle === this.handle ?
@@ -319,7 +323,7 @@ FormController.include({
// only catch the event if we're not focused in
// another field and it's a number
- if (!$(event.target).is('body') || !/[0-9]/.test(character)) {
+ if (!$(event.target).is('body, .modal') || !/[0-9]/.test(character)) {
return;
}
diff --git a/addons/base_address_city/models/res_partner.py b/addons/base_address_city/models/res_partner.py
index 4fc79c7b..507139b8 100644
--- a/addons/base_address_city/models/res_partner.py
+++ b/addons/base_address_city/models/res_partner.py
@@ -4,6 +4,8 @@
from lxml import etree
from flectra import api, models, fields
+from flectra.tools.translate import _
+
class Partner(models.Model):
_inherit = 'res.partner'
@@ -28,10 +30,10 @@ class Partner(models.Model):
replacement_xml = """
-
-
+
+
- """
+ """ % (_('City'), _('City'))
city_id_node = etree.fromstring(replacement_xml)
city_node.getparent().replace(city_node, city_id_node)
diff --git a/addons/base_gengo/wizard/base_gengo_translations.py b/addons/base_gengo/wizard/base_gengo_translations.py
index 7f42afd0..74487e2f 100644
--- a/addons/base_gengo/wizard/base_gengo_translations.py
+++ b/addons/base_gengo/wizard/base_gengo_translations.py
@@ -62,9 +62,9 @@ class BaseGengoTranslations(models.TransientModel):
'type': 'ir.actions.act_window',
'view_type': 'form',
'view_mode': 'form',
- 'res_model': 'res.company',
- 'res_id': self.env.user.company_id.id,
- 'target': 'current',
+ 'res_model': 'res.config.settings',
+ 'target': 'inline',
+ 'context': {'module' : 'general_settings'},
}
@api.model
diff --git a/addons/base_iban/models/res_partner_bank.py b/addons/base_iban/models/res_partner_bank.py
index bb76e7fd..4a7bdc60 100644
--- a/addons/base_iban/models/res_partner_bank.py
+++ b/addons/base_iban/models/res_partner_bank.py
@@ -89,6 +89,7 @@ _map_iban_template = {
'bg': 'BGkk BBBB SSSS DDCC CCCC CC', # Bulgaria
'bh': 'BHkk BBBB CCCC CCCC CCCC CC', # Bahrain
'br': 'BRkk BBBB BBBB SSSS SCCC CCCC CCCT N', # Brazil
+ 'by': 'BYkk BBBB AAAA CCCC CCCC CCCC CCCC', # Belarus
'ch': 'CHkk BBBB BCCC CCCC CCCC C', # Switzerland
'cr': 'CRkk BBBC CCCC CCCC CCCC C', # Costa Rica
'cy': 'CYkk BBBS SSSS CCCC CCCC CCCC CCCC', # Cyprus
diff --git a/addons/base_import/models/base_import.py b/addons/base_import/models/base_import.py
index 50eb8790..b682de4b 100644
--- a/addons/base_import/models/base_import.py
+++ b/addons/base_import/models/base_import.py
@@ -302,7 +302,8 @@ class Import(models.TransientModel):
try:
thousand_separator = decimal_separator = False
for val in preview_values:
- if val == '':
+ val = val.strip()
+ if not val:
continue
# value might have the currency symbol left or right from the value
val = self._remove_currency_symbol(val)
@@ -587,6 +588,7 @@ class Import(models.TransientModel):
thousand_separator = options.get('float_thousand_separator', ' ')
decimal_separator = options.get('float_decimal_separator', '.')
for line in data:
+ line[index] = line[index].strip()
if not line[index]:
continue
line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.')
@@ -620,9 +622,11 @@ class Import(models.TransientModel):
# versions, for both data and pattern
user_format = pycompat.to_native(options.get('%s_format' % field['type']))
for num, line in enumerate(data):
+ if line[index]:
+ line[index] = line[index].strip()
if line[index]:
try:
- line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index].strip()), user_format), server_format)
+ line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index]), user_format), server_format)
except ValueError as e:
raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e))
except Exception as e:
diff --git a/addons/base_import/static/src/js/import_action.js b/addons/base_import/static/src/js/import_action.js
index 9b869adb..4133f46b 100644
--- a/addons/base_import/static/src/js/import_action.js
+++ b/addons/base_import/static/src/js/import_action.js
@@ -137,6 +137,7 @@ var DataImport = Widget.extend(ControlPanelMixin, {
model: 'base_import.import',
method: 'create',
args: [{res_model: this.res_model}],
+ kwargs: {context: session.user_context},
});
},
renderButtons: function() {
@@ -280,6 +281,7 @@ var DataImport = Widget.extend(ControlPanelMixin, {
model: 'base_import.import',
method: 'parse_preview',
args: [this.id, this.import_options()],
+ kwargs: {context: session.user_context},
}).done(function (result) {
var signal = result.error ? 'preview_failed' : 'preview_succeeded';
self[signal](result);
diff --git a/addons/base_import_module/models/ir_module.py b/addons/base_import_module/models/ir_module.py
index e026207c..f68cf8da 100644
--- a/addons/base_import_module/models/ir_module.py
+++ b/addons/base_import_module/models/ir_module.py
@@ -145,12 +145,13 @@ def _is_studio_custom(path):
Returns True if any of the records contains a context with the key
studio in it, False if none of the records do
"""
- path = os.path.join(path, 'data')
- filenames = next(iter(os.walk(path)))[2]
- filenames = [f for f in filenames if f.lower().endswith('.xml')]
+ filepaths = []
+ for level in os.walk(path):
+ filepaths += [os.path.join(level[0], fn) for fn in level[2]]
+ filepaths = [fp for fp in filepaths if fp.lower().endswith('.xml')]
- for filename in filenames:
- root = lxml.etree.parse(os.path.join(path, filename)).getroot()
+ for fp in filepaths:
+ root = lxml.etree.parse(fp).getroot()
for record in root:
# there might not be a context if it's a non-studio module
diff --git a/addons/base_setup/models/res_config_settings.py b/addons/base_setup/models/res_config_settings.py
index e9ac1d26..989321bd 100644
--- a/addons/base_setup/models/res_config_settings.py
+++ b/addons/base_setup/models/res_config_settings.py
@@ -104,6 +104,8 @@ class ResConfigSettings(models.TransientModel):
@api.multi
def edit_external_header(self):
+ if not self.external_report_layout:
+ return False
return self._prepare_report_view_action('web.external_layout_' + self.external_report_layout)
@api.multi
diff --git a/addons/base_vat_autocomplete/models/res_partner.py b/addons/base_vat_autocomplete/models/res_partner.py
index 08da5f95..40bc80c1 100644
--- a/addons/base_vat_autocomplete/models/res_partner.py
+++ b/addons/base_vat_autocomplete/models/res_partner.py
@@ -30,16 +30,23 @@ class ResPartner(models.Model):
city = lines.pop()
return (cp, city)
else:
- result = re.match('((?:L-|AT-)?[0-9\-]+) (.+)', lines[-1])
+ result = re.match('((?:L-|AT-)?[0-9\-]+[A-Z]{,2}) (.+)', lines[-1])
if result:
lines.pop()
return (result.group(1), result.group(2))
return False
+ def _set_address_field(partner, field, value):
+ partner[field] = value
+ non_set_address_fields.remove(field)
+
if stdnum_vat is None:
return {}
for partner in self:
+ # If a field is non set in this algorithm
+ # wipe it anyway
+ non_set_address_fields = set(['street', 'street2', 'city', 'zip', 'state_id', 'country_id'])
if not partner.vat:
return {}
if len(partner.vat) > 5 and partner.vat[:2].lower() in stdnum_vat.country_codes:
@@ -68,14 +75,20 @@ class ResPartner(models.Model):
lines = [x.strip() for x in lines[0].split(',') if x]
if len(lines) == 1:
lines = [x.strip() for x in lines[0].split(' ') if x]
- partner.street = lines.pop(0)
+
+ _set_address_field(partner, 'street', lines.pop(0))
+
if len(lines) > 0:
res = _check_city(lines, result['countryCode'])
if res:
- partner.zip = res[0]
- partner.city = res[1]
+ _set_address_field(partner, 'zip', res[0])
+ _set_address_field(partner, 'city', res[1])
if len(lines) > 0:
- partner.street2 = lines.pop(0)
+ _set_address_field(partner, 'street2', lines.pop(0))
country = self.env['res.country'].search([('code', '=', result['countryCode'])], limit=1)
- partner.country_id = country and country.id or False
+ _set_address_field(partner, 'country_id', country and country.id or False)
+
+ for field in non_set_address_fields:
+ if partner[field]:
+ partner[field] = False
diff --git a/addons/board/static/tests/dashboard_tests.js b/addons/board/static/tests/dashboard_tests.js
index 1dc1bae5..b6c994fd 100644
--- a/addons/board/static/tests/dashboard_tests.js
+++ b/addons/board/static/tests/dashboard_tests.js
@@ -402,5 +402,57 @@ QUnit.test('non-existing action in a dashboard', function (assert) {
form.destroy();
});
+QUnit.test('clicking on a kanban\'s button should trigger the action', function (assert) {
+ assert.expect(2);
+
+ var form = createView({
+ View: FormView,
+ model: 'board',
+ data: this.data,
+ arch: '',
+ archs: {
+ 'partner,false,kanban':
+ '' +
+ '
' +
+ '' +
+ '
' +
+ '
' +
+ '
' +
+ '',
+ },
+ intercepts: {
+ execute_action: function (event) {
+ var data = event.data;
+ assert.strictEqual(data.env.model, 'partner', "should have correct model");
+ assert.strictEqual(data.action_data.name, 'sitting_on_a_park_bench',
+ "should call correct method");
+ }
+ },
+
+ mockRPC: function (route) {
+ if (route === '/board/static/src/img/layout_1-1-1.png') {
+ return $.when();
+ }
+ if (route === '/web/action/load') {
+ return $.when({res_model: 'partner', view_mode: 'kanban', views: [[false, 'kanban']]});
+ }
+ if (route === '/web/dataset/search_read') {
+ return $.when({records: [{foo: 'aqualung'}]});
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ form.$('.o_kanban_test').find('button:first').click();
+
+ form.destroy();
+});
+
});
diff --git a/addons/bus/static/src/js/bus.js b/addons/bus/static/src/js/bus.js
index cf144e36..86f81f3a 100644
--- a/addons/bus/static/src/js/bus.js
+++ b/addons/bus/static/src/js/bus.js
@@ -76,7 +76,8 @@ bus.Bus = Widget.extend({
this.last_partners_presence_check = now;
}
var data = {channels: self.channels, last: self.last, options: options};
- session.rpc('/longpolling/poll', data, {shadow : true}).then(function(result) {
+ // The backend has a maximum cycle time of 50 seconds so give +10 seconds
+ session.rpc('/longpolling/poll', data, {shadow : true, timeout: 60000}).then(function(result) {
self.on_notification(result);
if(!self.stop){
self.poll();
diff --git a/addons/calendar/models/calendar.py b/addons/calendar/models/calendar.py
index 8bd409ba..d3323f8d 100644
--- a/addons/calendar/models/calendar.py
+++ b/addons/calendar/models/calendar.py
@@ -434,7 +434,7 @@ class AlarmManager(models.AbstractModel):
result = False
if alarm.type == 'email':
- result = meeting.attendee_ids._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True)
+ result = meeting.attendee_ids.filtered(lambda r: r.state != 'declined')._send_mail_to_attendees('calendar.calendar_template_meeting_reminder', force_send=True)
return result
def do_notif_reminder(self, alert):
@@ -823,7 +823,7 @@ class Meeting(models.Model):
attendee_ids = fields.One2many('calendar.attendee', 'event_id', 'Participant', ondelete='cascade')
partner_ids = fields.Many2many('res.partner', 'calendar_event_res_partner_rel', string='Attendees', states={'done': [('readonly', True)]}, default=_default_partners)
alarm_ids = fields.Many2many('calendar.alarm', 'calendar_alarm_calendar_event_rel', string='Reminders', ondelete="restrict", copy=False)
- is_highlighted = fields.Boolean(compute='_compute_is_highlighted', string='# Meetings Highlight')
+ is_highlighted = fields.Boolean(compute='_compute_is_highlighted', string='Is the Event Highlighted')
@api.multi
def _compute_attendee(self):
@@ -1478,7 +1478,7 @@ class Meeting(models.Model):
partners_to_notify = meeting.partner_ids.ids
event_attendees_changes = attendees_create and real_ids and attendees_create[real_ids[0]]
if event_attendees_changes:
- partners_to_notify.append(event_attendees_changes['removed_partners'].ids)
+ partners_to_notify.extend(event_attendees_changes['removed_partners'].ids)
self.env['calendar.alarm_manager'].notify_next_alarm(partners_to_notify)
if (values.get('start_date') or values.get('start_datetime') or
diff --git a/addons/calendar/views/calendar_views.xml b/addons/calendar/views/calendar_views.xml
index b8e3a7dc..ee55631a 100644
--- a/addons/calendar/views/calendar_views.xml
+++ b/addons/calendar/views/calendar_views.xml
@@ -75,7 +75,7 @@
-
+
@@ -122,10 +122,10 @@
-
-
+
+
-
+
Manage your vehicles, contracts, costs, insurances and assignments
-
+
@@ -10,13 +10,13 @@
-
-
-
Fleet management made easy
-
+
+
+
Fleet management made easy
+
-
+
You won't need any specialized tracking system for company vehicles - with Odoo's smart app, can keep a close eye on your fleet in a few simple clicks. Manage everything through our user-friendly administrative system - fuel log entries, costs and many other features necessary for the management of your company's vehicles.
@@ -24,27 +24,27 @@
-
-
-
Manage leasing and all other contracts
-
+
+
+
Manage leasing and all other contracts
+
Supervise all contracts for your vehicles and receive a warning email when contracts reach their expiration date. Several visual tools are put in place to ensure that you'll remember to renew (or end) your contract. Organize the services around the vehicles and communicate with the qualified service providers; manage invoices and notes. Set up a vehicle policy within your company, as well as an insurance policy in order to manage your fleet in the most efficient way.
-
+
-
-
-
Monitor all costs at once
-
+
+
+
Monitor all costs at once
+
-
+
Identify and trace the money spent by your company for each of the fleet vehicles. Recurring costs of your contracts such as leasing and services contracts are automatically added to your accounting at the beginning of each period of the frequency specified in the contracts, while all other costs like fuel and repairs are automatically added to your report.
@@ -52,19 +52,19 @@
-
-
-
Analysis and reporting
-
+
+
+
Analysis and reporting
+
Show all costs associated with a given vehicle or with a type of service. Compare different types of costs (which vehicles cost the most; which services have been performed on which vehicles etc.) by using the reporting tool. Get really helpful insights about the effective return of each vehicle in order to improve your fleet investments.
-
+
-
+
diff --git a/addons/gamification/__manifest__.py b/addons/gamification/__manifest__.py
index db6153e0..539995c4 100644
--- a/addons/gamification/__manifest__.py
+++ b/addons/gamification/__manifest__.py
@@ -18,7 +18,7 @@ The users can be evaluated using goals and numerical objectives to reach.
For non-numerical achievements, **badges** can be granted to users. From a simple "thank you" to an exceptional achievement, a badge is an easy way to exprimate gratitude to a user for their good work.
-Both goals and badges are flexibles and can be adapted to a large range of modules and actions. When installed, this module creates easy goals to help new users to discover Odoo and configure their user profile.
+Both goals and badges are flexibles and can be adapted to a large range of modules and actions. When installed, this module creates easy goals to help new users to discover Flectra and configure their user profile.
""",
'data': [
diff --git a/addons/gamification/data/goal_base.xml b/addons/gamification/data/goal_base.xml
index 0d8caa0d..e0008327 100644
--- a/addons/gamification/data/goal_base.xml
+++ b/addons/gamification/data/goal_base.xml
@@ -243,12 +243,11 @@
Get your meetings, your leaves... Get your calendar anywhere and never forget an event.
-
+
-
-
+
+
Keep an eye on your events
-
+
See easily the purpose of the meeting, the start time and also the attendee(s)... All that without click on anything...
-
+
-
-
+
+
Create so easily an event
-
+
-
+
In just one click you can create an event...
You can drag and drop your event if you want moved it to another timing.
@@ -38,29 +38,29 @@
-
-
+
+
Create recurrent event
-
+
You can also create recurrent events with only one event.
You need to create an event each monday of the week ? With only one it's possible, you could specify the recurrence and if one of this event is moved, or deleted, it's not a problem, you can untie your event from the others recurrences.
-
+
-
-
+
+
See all events you wants
-
+
-
+
See in your calendar, the event from others peoples where your are attendee, but also their events by simply adding your favorites coworkers.
Every coworker will have their own color in your calendar, and every attendee will have their avatar in the event...
@@ -70,28 +70,28 @@
-
-
+
+
Get an email
-
+
You will receive an email at creation of an event where you are attendee, but also when this event is updated for some fields as date start, ...
-
+
-
-
+
+
Be notified
-
+
-
+
You can ask to have a alarm of type 'notification' in your Odoo.
You will have a notification in you Odoo which ever the page you are.
@@ -100,15 +100,15 @@
-
-
+
+
Google Calendar
-
+
With the plugin Google_calendar, you can synchronize your Odoo calendar with Google Calendar.
-
+
diff --git a/addons/google_drive/models/google_drive.py b/addons/google_drive/models/google_drive.py
index a0ad573b..204edb44 100644
--- a/addons/google_drive/models/google_drive.py
+++ b/addons/google_drive/models/google_drive.py
@@ -9,6 +9,7 @@ import werkzeug.urls
from flectra import api, fields, models
from flectra.exceptions import RedirectWarning, UserError
+from flectra.tools import pycompat
from flectra.tools.safe_eval import safe_eval
from flectra.tools.translate import _
@@ -153,6 +154,9 @@ class GoogleDrive(models.Model):
a length of 1 element only (batch processing is not supported in the code, though nothing really prevent it)
:return: the config id and config name
'''
+ # TO DO in master: fix my signature and my model
+ if isinstance(res_model, pycompat.string_types):
+ res_model = self.env['ir.model'].search([('model', '=', res_model)]).id
if not res_id:
raise UserError(_("Creating google drive may only be done by one at a time."))
# check if a model is configured with a template
diff --git a/addons/google_drive/static/src/js/gdrive.js b/addons/google_drive/static/src/js/gdrive.js
index 4b2e4390..115e5968 100644
--- a/addons/google_drive/static/src/js/gdrive.js
+++ b/addons/google_drive/static/src/js/gdrive.js
@@ -1,78 +1,109 @@
-flectra.define('google_drive.google_drive', function (require) {
+flectra.define('google_drive.sidebar', function (require) {
"use strict";
-var data = require('web.data');
+/**
+ * The purpose of this file is to include the Sidebar widget to add Google
+ * Drive related items.
+ */
+
var Sidebar = require('web.Sidebar');
+
Sidebar.include({
- init: function () {
- var self = this;
- var ids;
- this._super.apply(this, arguments);
- var view = self.getParent();
- var result;
- if (view.fields_view && view.fields_view.type === "form") {
- ids = [];
- view.on("load_record", self, function (r) {
- ids = [r.id];
- self.add_gdoc_items(view, r.id);
- });
+ // TO DO: clean me in master
+ /**
+ * @override
+ */
+ start: function () {
+ var def;
+ if (this.options.viewType === "form") {
+ def = this._addGoogleDocItems(this.env.model, this.env.activeIds[0]);
}
+ return $.when(def).then(this._super.bind(this));
},
- add_gdoc_items: function (view, res_id) {
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} model
+ * @param {integer} resID
+ * @returns {Deferred}
+ */
+ _addGoogleDocItems: function (model, resID) {
var self = this;
+ if (!resID) {
+ return $.when();
+ }
var gdoc_item = _.indexOf(_.pluck(self.items.other, 'classname'), 'oe_share_gdoc');
if (gdoc_item !== -1) {
self.items.other.splice(gdoc_item, 1);
}
- if (res_id) {
- view.sidebar_eval_context().done(function (context) {
- var ds = new data.DataSet(this, 'google.drive.config', context);
- ds.call('get_google_drive_config', [view.dataset.model, res_id, context]).done(function (r) {
- if (!_.isEmpty(r)) {
- _.each(r, function (res) {
- var already_there = false;
- for (var i = 0;i < self.items.other.length;i++){
- if (self.items.other[i].classname === "oe_share_gdoc" && self.items.other[i].label.indexOf(res.name) > -1){
- already_there = true;
- break;
- }
- }
- if (!already_there){
- self.add_items('other', [{
- label: res.name,
- config_id: res.id,
- res_id: res_id,
- res_model: view.dataset.model,
- callback: self.on_google_doc,
- classname: 'oe_share_gdoc'
- },
- ]);
- }
- });
+ return this._rpc({
+ args: [this.env.model, resID],
+ context: this.env.context,
+ method: 'get_google_drive_config',
+ model: 'google.drive.config',
+ }).then(function (r) {
+ if (!_.isEmpty(r)) {
+ _.each(r, function (res) {
+ var already_there = false;
+ for (var i = 0; i < self.items.other.length; i++) {
+ var item = self.items.other[i];
+ if (item.classname === 'oe_share_gdoc' && item.label.indexOf(res.name) > -1) {
+ already_there = true;
+ break;
+ }
+ }
+ if (!already_there) {
+ self._addItems('other', [{
+ callback: self._onGoogleDocItemClicked.bind(self, res.id, resID),
+ classname: 'oe_share_gdoc',
+ config_id: res.id,
+ label: res.name,
+ res_id: resID,
+ res_model: model,
+ }]);
}
});
- });
- }
+ }
+ });
},
- on_google_doc: function (doc_item) {
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {integer} configID
+ * @param {integer} resID
+ */
+ _onGoogleDocItemClicked: function (configID, resID) {
var self = this;
- var domain = [['id', '=', doc_item.config_id]];
+ var domain = [['id', '=', configID]];
var fields = ['google_drive_resource_id', 'google_drive_client_id'];
this._rpc({
+ args: [domain, fields],
+ method: 'search_read',
+ model: 'google.drive.config',
+ }).then(function (configs) {
+ self._rpc({
+ args: [configID, resID, configs[0].google_drive_resource_id],
+ context: self.env.context,
+ method: 'get_google_drive_url',
model: 'google.drive.config',
- method: 'search_read',
- args: [domain, fields],
- })
- .then(function (configs) {
- var ds = new data.DataSet(self, 'google.drive.config');
- ds.call('get_google_drive_url', [doc_item.config_id, doc_item.res_id,configs[0].google_drive_resource_id, self.dataset.context]).done(function (url) {
- if (url){
- window.open(url, '_blank');
- }
- });
+ }).then(function (url) {
+ if (url){
+ window.open(url, '_blank');
+ }
});
+ });
},
-});
});
+
+return Sidebar;
+});
diff --git a/addons/google_drive/static/tests/gdrive_test.js b/addons/google_drive/static/tests/gdrive_test.js
new file mode 100644
index 00000000..9c9be5be
--- /dev/null
+++ b/addons/google_drive/static/tests/gdrive_test.js
@@ -0,0 +1,112 @@
+flectra.define('google_drive.gdrive_integration', function (require) {
+"use strict";
+//rebuild
+var FormView = require('web.FormView');
+var testUtils = require('web.test_utils');
+var GoogleDriveSideBar = require('google_drive.sidebar');
+
+var createView = testUtils.createView;
+
+/*
+ * @override
+ * Avoid breaking other tests because of the new route
+ * that the module introduces
+ */
+var _addGoogleDocItemsOriginal = GoogleDriveSideBar.prototype._addGoogleDocItems;
+
+var _addGoogleDocItemsMocked = function (model, resID) {
+ return $.when();
+};
+
+GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
+
+QUnit.module('gdrive_integration', {
+ beforeEach: function () {
+ // For our test to work, the _addGoogleDocItems function needs to be the original
+ GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsOriginal;
+
+ this.data = {
+ partner: {
+ fields: {
+ display_name: {string: "Displayed name", type: "char", searchable: true},
+ },
+ records: [{
+ id: 1,
+ display_name: "Locomotive Breath",
+ }],
+ },
+ 'google.drive.config': {
+ fields: {
+ model_id: {string: 'Model', type: 'int'},
+ name: {string: 'Name', type: 'char'},
+ google_drive_resource_id: {string: 'Resource ID', type: 'char'},
+ },
+ records: [{
+ id: 27,
+ name: 'Cyberdyne Systems',
+ model_id: 1,
+ google_drive_resource_id: 'T1000',
+ }],
+ },
+ 'ir.attachment': {
+ fields: {
+ name: {string: 'Name', type:'char'}
+ },
+ records: [],
+ }
+ };
+ },
+
+ afterEach: function() {
+ GoogleDriveSideBar.prototype._addGoogleDocItems = _addGoogleDocItemsMocked;
+ }
+
+}, function () {
+ QUnit.module('Google Drive Sidebar');
+
+ QUnit.test('rendering of the google drive attachments in Sidebar', function (assert) {
+ assert.expect(3);
+
+ var form = createView({
+ View: FormView,
+ model: 'partner',
+ data: this.data,
+ arch: '',
+ res_id: 1,
+ viewOptions: {sidebar: true},
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_config') {
+ assert.deepEqual(args.args, ['partner', 1],
+ 'The route to get google drive config should have been called');
+ return $.when([{id: 27, name: 'Cyberdyne Systems'}]);
+ }
+ if (route === '/web/dataset/call_kw/google.drive.config/search_read'){
+ return $.when([{google_drive_resource_id: "T1000",
+ google_drive_client_id: "cyberdyne.org",
+ id: 1}]);
+ }
+ if (route === '/web/dataset/call_kw/google.drive.config/get_google_drive_url') {
+ assert.deepEqual(args.args, [27, 1, 'T1000'],
+ 'The route to get the Google url should have been called');
+ // We don't return anything useful, otherwise it will open a new tab
+ return $.when();
+ }
+ return this._super.apply(this, arguments);
+ }
+ });
+
+ var google_action = form.sidebar.$('.oe_share_gdoc');
+
+ assert.strictEqual(google_action.length, 1,
+ 'The button to the google action should be present');
+
+ // Trigger opening of the dynamic link
+ google_action.find('a:first').click();
+
+ form.destroy();
+ });
+});
+
+});
diff --git a/addons/google_drive/views/google_drive_templates.xml b/addons/google_drive/views/google_drive_templates.xml
index 42c4124f..8069b61f 100644
--- a/addons/google_drive/views/google_drive_templates.xml
+++ b/addons/google_drive/views/google_drive_templates.xml
@@ -8,4 +8,9 @@
+
+
+
+
+
diff --git a/addons/hr/models/hr.py b/addons/hr/models/hr.py
index b2e71857..dc5b61b9 100644
--- a/addons/hr/models/hr.py
+++ b/addons/hr/models/hr.py
@@ -111,13 +111,14 @@ class Employee(models.Model):
active = fields.Boolean('Active', related='resource_id.active', default=True, store=True)
# private partner
address_home_id = fields.Many2one(
- 'res.partner', 'Private Address', help='Enter here the private address of the employee, not the one linked to your company.')
+ 'res.partner', 'Private Address', help='Enter here the private address of the employee, not the one linked to your company.',
+ groups="hr.group_hr_user")
is_address_home_a_company = fields.Boolean(
'The employee adress has a company linked',
compute='_compute_is_address_home_a_company',
)
country_id = fields.Many2one(
- 'res.country', 'Nationality (Country)')
+ 'res.country', 'Nationality (Country)', groups="hr.group_hr_user")
gender = fields.Selection([
('male', 'Male'),
('female', 'Female'),
@@ -125,7 +126,8 @@ class Employee(models.Model):
], groups="hr.group_hr_user", default="male")
marital = fields.Selection([
('single', 'Single'),
- ('married', 'Married (or similar)'),
+ ('married', 'Married'),
+ ('cohabitant', 'Legal Cohabitant'),
('widower', 'Widower'),
('divorced', 'Divorced')
], string='Marital Status', groups="hr.group_hr_user", default='single')
@@ -139,9 +141,9 @@ class Employee(models.Model):
domain="[('partner_id', '=', address_home_id)]",
groups="hr.group_hr_user",
help='Employee bank salary account')
- permit_no = fields.Char('Work Permit No')
- visa_no = fields.Char('Visa No')
- visa_expire = fields.Date('Visa Expire Date')
+ permit_no = fields.Char('Work Permit No', groups="hr.group_hr_user")
+ visa_no = fields.Char('Visa No', groups="hr.group_hr_user")
+ visa_expire = fields.Date('Visa Expire Date', groups="hr.group_hr_user")
# image: all image fields are base64 encoded and PIL-supported
image = fields.Binary(
@@ -269,7 +271,11 @@ class Employee(models.Model):
"""Checks that choosen address (res.partner) is not linked to a company.
"""
for employee in self:
- employee.is_address_home_a_company = employee.address_home_id.parent_id.id is not False
+ try:
+ employee.is_address_home_a_company = employee.address_home_id.parent_id.id is not False
+ except AccessError:
+ employee.is_address_home_a_company = False
+
class Department(models.Model):
_name = "hr.department"
diff --git a/addons/hr/static/description/index.html b/addons/hr/static/description/index.html
index d5d0c6f1..7ab62ba0 100644
--- a/addons/hr/static/description/index.html
+++ b/addons/hr/static/description/index.html
@@ -1,8 +1,8 @@
-
-
-
Modern open source HR solution
+
+
+
Modern open source HR solution
Manage the most important asset in your company: People
-
+
@@ -10,14 +10,14 @@
-
-
-
Successfully manage your employees
+
+
+
Successfully manage your employees
Centralize all your HR information
-
+
-
+
Oversee all important information by department at a glance. Restrict some information to HR managers, and make others public for all employees to easily find their colleagues. Get alerts for any new leave request, allocation request, application or upcoming appraisal.
@@ -25,14 +25,14 @@
-
-
-
Boost Engagement With Social Tools
+
+
+
Boost Engagement With Social Tools
Improve communication between employees and motivate them through rewards
-
+
-
+
Enterprise Social Network
@@ -41,8 +41,8 @@
-
-
+
+
Gamification
@@ -50,11 +50,11 @@
Inspire achievement with challenges, goals and rewards. Design your own targets, define clear objectives and provide real time feedback and tangible results. Showcase the top performers to the entire team and publicly recognize a job well done.
Use here the home address of the employee.
This private address is used in the expense report reimbursement document.
It should be different from the work address.
@@ -289,12 +290,14 @@
+
Subordinate Hierarchyir.actions.act_windowhr.employee
- [('id','in',active_ids)]
- tree
+ ['|', ('id','in',active_ids), ('parent_id', 'in', active_ids)]
+ form
+ tree,form
diff --git a/addons/hr_expense/static/description/index.html b/addons/hr_expense/static/description/index.html
index 72251ba0..3c7cb3f8 100644
--- a/addons/hr_expense/static/description/index.html
+++ b/addons/hr_expense/static/description/index.html
@@ -2,7 +2,7 @@
Online Expenses Management
Easily Manage Employees' Expenses
-
+
@@ -10,9 +10,9 @@
-
-
-
Save time on expense records
+
+
+
Save time on expense records
Everything at one place
Manage your employees' daily expenses. Whether it's travel expenses or any other costs, access all your employees' fee notes and complete, validate or refuse them. No need to download any specialized software or program to manage expenses - everything can be done directly in the app!
@@ -23,39 +23,39 @@
-
-
-
Stop losing receipts
+
+
+
Stop losing receipts
Upload all receipts within expense records
-
+
Allow employees to add copies of the receipts and proofs directly to the expense records to avoid losing them. Save time and increase your efficiency by keeping a clean and complete record of all expenses.
-
+
-
-
-
Manage expenses per team
+
+
+
Manage expenses per team
Have a clear overview of a team's spendings
As a manager, follow expenses recorded by a full team to keep an eye on the costs and make sure to keep to the target and budget.
-
+
-
-
-
Share the workload between departments
+
+
+
Share the workload between departments
Get everyone involved to save time
-
+
@@ -64,7 +64,7 @@
Draft and confirm expenses, and load receipts to the expense records.
-
+
@@ -73,7 +73,7 @@
Comment, validate or refuse expenses, and add or edit information.