[MERGE] forward port branch saas-12.2 up to c9f832d9f0
closes odoo/odoo#31790 Signed-off-by: Christophe Simonis <chs@odoo.com>
This commit is contained in:
@@ -637,9 +637,12 @@ class AccountBankStatementLine(models.Model):
|
||||
|
||||
# Create The payment
|
||||
payment = self.env['account.payment']
|
||||
partner_id = self.partner_id or (aml_dict.get('move_line') and aml_dict['move_line'].partner_id) or self.env['res.partner']
|
||||
if abs(total)>0.00001:
|
||||
payment_vals = self._prepare_payment_vals(total)
|
||||
if self.partner_id and len(account_types) == 1:
|
||||
if not payment_vals['partner_id']:
|
||||
payment_vals['partner_id'] = partner_id.id
|
||||
if payment_vals['partner_id'] and len(account_types) == 1:
|
||||
payment_vals['partner_type'] = 'customer' if account_types == receivable_account_type else 'supplier'
|
||||
payment = payment.create(payment_vals)
|
||||
|
||||
|
||||
@@ -1621,12 +1621,14 @@ class AccountInvoice(models.Model):
|
||||
fmt = partial(formatLang, invoice.with_context(lang=invoice.partner_id.lang).env, currency_obj=currency)
|
||||
res = {}
|
||||
for line in invoice.tax_line_ids:
|
||||
res.setdefault(line.tax_id.tax_group_id, {'base': 0.0, 'amount': 0.0})
|
||||
res[line.tax_id.tax_group_id]['amount'] += line.amount_total
|
||||
res[line.tax_id.tax_group_id]['base'] += line.base
|
||||
res = sorted(res.items(), key=lambda l: l[0].sequence)
|
||||
tax = line.tax_id
|
||||
group_key = (tax.tax_group_id, tax.amount_type, tax.amount)
|
||||
res.setdefault(group_key, {'base': 0.0, 'amount': 0.0})
|
||||
res[group_key]['amount'] += line.amount_total
|
||||
res[group_key]['base'] += line.base
|
||||
res = sorted(res.items(), key=lambda l: l[0][0].sequence)
|
||||
invoice.amount_by_group = [(
|
||||
r[0].name, r[1]['amount'], r[1]['base'],
|
||||
r[0][0].name, r[1]['amount'], r[1]['base'],
|
||||
fmt(r[1]['amount']), fmt(r[1]['base']),
|
||||
len(res),
|
||||
) for r in res]
|
||||
|
||||
@@ -172,7 +172,7 @@ class account_journal(models.Model):
|
||||
LEFT JOIN account_move move ON aml.move_id = move.id
|
||||
WHERE aml.account_id in %%s
|
||||
AND move.date <= %%s AND move.state = 'posted';""" % (amount_field,)
|
||||
self.env.cr.execute(query, (account_ids, fields.Date.today(),))
|
||||
self.env.cr.execute(query, (account_ids, fields.Date.context_today(self),))
|
||||
query_results = self.env.cr.dictfetchall()
|
||||
if query_results and query_results[0].get('sum') != None:
|
||||
account_sum = query_results[0].get('sum')
|
||||
|
||||
@@ -236,10 +236,14 @@ class account_abstract_payment(models.AbstractModel):
|
||||
currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or invoices and invoices[0].currency_id
|
||||
|
||||
# Avoid currency rounding issues by summing the amounts according to the company_currency_id before
|
||||
invoice_datas = invoices.read_group(
|
||||
[('id', 'in', invoices.ids)],
|
||||
['currency_id', 'type', 'residual_signed'],
|
||||
['currency_id', 'type'], lazy=False)
|
||||
total = 0.0
|
||||
groups = groupby(invoices, lambda i: i.currency_id)
|
||||
for payment_currency, payment_invoices in groups:
|
||||
amount_total = sum([MAP_INVOICE_TYPE_PAYMENT_SIGN[i.type] * i.residual_signed for i in payment_invoices])
|
||||
for invoice_data in invoice_datas:
|
||||
amount_total = MAP_INVOICE_TYPE_PAYMENT_SIGN[invoice_data['type']] * invoice_data['residual_signed']
|
||||
payment_currency = self.env['res.currency'].browse(invoice_data['currency_id'][0])
|
||||
if payment_currency == currency:
|
||||
total += amount_total
|
||||
else:
|
||||
|
||||
@@ -574,7 +574,7 @@ class AccountReconcileModel(models.Model):
|
||||
if line_residual > total_residual:
|
||||
amount_percentage = (total_residual / line_residual) * 100
|
||||
else:
|
||||
amount_percentage = (line_residual / total_residual) * 100
|
||||
amount_percentage = (line_residual / total_residual) * 100 if total_residual else 0
|
||||
return amount_percentage >= self.match_total_amount_param
|
||||
|
||||
@api.multi
|
||||
|
||||
@@ -143,11 +143,12 @@ class AccountReconciliation(models.AbstractModel):
|
||||
else:
|
||||
aml_ids = matching_amls[line.id]['aml_ids']
|
||||
bank_statements_left += line.statement_id
|
||||
target_currency = line.currency_id or line.journal_id.currency_id or line.journal_id.company_id.currency_id
|
||||
|
||||
amls = aml_ids and self.env['account.move.line'].browse(aml_ids)
|
||||
line_vals = {
|
||||
'st_line': self._get_statement_line(line),
|
||||
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls) or [],
|
||||
'reconciliation_proposition': aml_ids and self._prepare_move_lines(amls, target_currency=target_currency, target_date=line.date) or [],
|
||||
'model_id': matching_amls[line.id].get('model') and matching_amls[line.id]['model'].id,
|
||||
'write_off': matching_amls[line.id].get('status') == 'write_off',
|
||||
}
|
||||
@@ -486,21 +487,12 @@ class AccountReconciliation(models.AbstractModel):
|
||||
('payment_id', '<>', False)
|
||||
]
|
||||
|
||||
# Black lines = unreconciled & (not linked to a payment or open balance created by statement
|
||||
domain_matching = [('reconciled', '=', False)]
|
||||
if partner_id:
|
||||
domain_matching = expression.AND([
|
||||
domain_matching,
|
||||
[('account_id.internal_type', 'in', ['payable', 'receivable'])]
|
||||
])
|
||||
else:
|
||||
# TODO : find out what use case this permits (match a check payment, registered on a journal whose account type is other instead of liquidity)
|
||||
domain_matching = expression.AND([
|
||||
domain_matching,
|
||||
[('account_id.reconcile', '=', True)]
|
||||
])
|
||||
# default domain matching
|
||||
domain_matching = expression.AND([
|
||||
[('reconciled', '=', False)],
|
||||
[('account_id.reconcile', '=', True)]
|
||||
])
|
||||
|
||||
# Let's add what applies to both
|
||||
domain = expression.OR([domain_reconciliation, domain_matching])
|
||||
if partner_id:
|
||||
domain = expression.AND([domain, [('partner_id', '=', partner_id)]])
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import odoo.tests
|
||||
import time
|
||||
import requests
|
||||
from odoo.addons.account.tests.test_reconciliation import TestReconciliation
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,3 +28,29 @@ class TestUi(odoo.tests.HttpCase):
|
||||
self.phantom_js(prep.url.replace('http://localhost','').replace('?','#'),
|
||||
"odoo.__DEBUG__.services['web_tour.tour'].run('bank_statement_reconciliation')",
|
||||
"odoo.__DEBUG__.services['web_tour.tour'].tours.bank_statement_reconciliation.ready", login="admin")
|
||||
|
||||
|
||||
@odoo.tests.tagged('post_install', '-at_install')
|
||||
class TestReconciliationWidget(TestReconciliation):
|
||||
|
||||
def test_statement_suggestion_other_currency(self):
|
||||
# company currency is EUR
|
||||
# payment in USD
|
||||
invoice = self.create_invoice(invoice_amount=50, currency_id=self.currency_usd_id)
|
||||
|
||||
# journal currency in USD
|
||||
bank_stmt = self.acc_bank_stmt_model.create({
|
||||
'journal_id': self.bank_journal_usd.id,
|
||||
'date': time.strftime('%Y-07-15'),
|
||||
'name': 'payment %s' % invoice.number,
|
||||
})
|
||||
|
||||
bank_stmt_line = self.acc_bank_stmt_line_model.create({'name': 'payment',
|
||||
'statement_id': bank_stmt.id,
|
||||
'partner_id': self.partner_agrolait_id,
|
||||
'amount': 50,
|
||||
'date': time.strftime('%Y-07-15'),
|
||||
})
|
||||
|
||||
result = self.env['account.reconciliation.widget'].get_bank_statement_line_data(bank_stmt_line.ids)
|
||||
self.assertEqual(result['lines'][0]['reconciliation_proposition'][0]['amount_str'], '$ 50.00')
|
||||
@@ -276,7 +276,7 @@
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<span class="float-right">
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'posted': 'success'}}"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -549,4 +549,4 @@
|
||||
sequence="1"/>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<span class="float-right badge badge-secondary">
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'sent': 'success'}}"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<span t-field="line.uom_id" groups="uom.group_uom"/>
|
||||
</td>
|
||||
<td t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
|
||||
<span t-field="line.price_unit" t-options='{"widget": "monetary", "display_currency": o.currency_id}'/>
|
||||
<span t-field="line.price_unit"/>
|
||||
</td>
|
||||
<td t-if="display_discount" t-attf-class="text-right {{ 'd-none d-md-table-cell' if report_type == 'html' else '' }}">
|
||||
<span t-field="line.discount"/>
|
||||
|
||||
@@ -160,7 +160,10 @@ class AccountAnalyticAccount(models.Model):
|
||||
if operator == 'ilike' and not (name or '').strip():
|
||||
domain = []
|
||||
else:
|
||||
domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id.name', operator, name)]
|
||||
# `partner_id` is in auto_join and the searches using ORs with auto_join fields doesn't work
|
||||
# we have to cut the search in two searches ... https://github.com/odoo/odoo/issues/25175
|
||||
partner_ids = self.env['res.partner']._search([('name', operator, name)], limit=limit, access_rights_uid=name_get_uid)
|
||||
domain = ['|', '|', ('code', operator, name), ('name', operator, name), ('partner_id', 'in', partner_ids)]
|
||||
analytic_account_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
|
||||
return self.browse(analytic_account_ids).name_get()
|
||||
|
||||
|
||||
@@ -27,16 +27,16 @@ class ResPartner(models.Model):
|
||||
signup_token = fields.Char(copy=False, groups="base.group_erp_manager")
|
||||
signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager")
|
||||
signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager")
|
||||
signup_valid = fields.Boolean(compute='_compute_signup_valid', compute_sudo=True, string='Signup Token is Valid')
|
||||
signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid')
|
||||
signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL')
|
||||
|
||||
@api.multi
|
||||
@api.depends('signup_token', 'signup_expiration')
|
||||
def _compute_signup_valid(self):
|
||||
dt = now()
|
||||
for partner in self:
|
||||
partner.signup_valid = bool(partner.signup_token) and \
|
||||
(not partner.signup_expiration or dt <= partner.signup_expiration)
|
||||
for partner, partner_sudo in zip(self, self.sudo()):
|
||||
partner.signup_valid = bool(partner_sudo.signup_token) and \
|
||||
(not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration)
|
||||
|
||||
@api.multi
|
||||
def _compute_signup_url(self):
|
||||
|
||||
@@ -84,4 +84,19 @@ record['name'] = record.name + 'X'
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="test_mail_template_automation" model="mail.template">
|
||||
<field name="name">Template Automation</field>
|
||||
<field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
|
||||
<field name="body_html"><div>Email automation</div></field>
|
||||
</record>
|
||||
<record id="test_rule_on_write_recompute_send_email" model="base.automation">
|
||||
<field name="name">Base Automation: test send an email</field>
|
||||
<field name="model_id" ref="base_automation.model_base_automation_lead_test"/>
|
||||
<field name="template_id" ref="base_automation.test_mail_template_automation"/>
|
||||
<field name="state">email</field>
|
||||
<field name="trigger">on_write</field>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="filter_domain">[('deadline', '!=', False)]</field>
|
||||
<field name="filter_pre_domain">[('deadline', '=', False)]</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
||||
@@ -164,14 +164,17 @@ class BaseAutomation(models.Model):
|
||||
return records
|
||||
|
||||
def _filter_post(self, records):
|
||||
return self._filter_post_export_domain(records)[0]
|
||||
|
||||
def _filter_post_export_domain(self, records):
|
||||
""" Filter the records that satisfy the postcondition of action ``self``. """
|
||||
if self.filter_domain and records:
|
||||
domain = [('id', 'in', records.ids)] + safe_eval(self.filter_domain, self._get_eval_context())
|
||||
return records.search(domain)
|
||||
return records.search(domain), domain
|
||||
else:
|
||||
return records
|
||||
return records, None
|
||||
|
||||
def _process(self, records):
|
||||
def _process(self, records, domain_post=None):
|
||||
""" Process action ``self`` on the ``records`` that have not been done yet. """
|
||||
# filter out the records on which self has already been done
|
||||
action_done = self._context['__action_done']
|
||||
@@ -198,7 +201,12 @@ class BaseAutomation(models.Model):
|
||||
for record in records:
|
||||
# we process the action if any watched field has been modified
|
||||
if self._check_trigger_fields(record):
|
||||
ctx = {'active_model': record._name, 'active_ids': record.ids, 'active_id': record.id}
|
||||
ctx = {
|
||||
'active_model': record._name,
|
||||
'active_ids': record.ids,
|
||||
'active_id': record.id,
|
||||
'domain_post': domain_post,
|
||||
}
|
||||
self.action_server_id.with_context(**ctx).run()
|
||||
|
||||
def _check_trigger_fields(self, record):
|
||||
@@ -274,7 +282,8 @@ class BaseAutomation(models.Model):
|
||||
_write.origin(records, vals, **kw)
|
||||
# check postconditions, and execute actions on the records that satisfy them
|
||||
for action in actions.with_context(old_values=old_values):
|
||||
action._process(action._filter_post(pre[action]))
|
||||
records, domain_post = action._filter_post_export_domain(pre[action])
|
||||
action._process(records, domain_post=domain_post)
|
||||
return True
|
||||
|
||||
return _write
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# # -*- coding: utf-8 -*-
|
||||
# # Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.tests import common
|
||||
|
||||
@@ -9,13 +10,14 @@ class base_automation_test(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super(base_automation_test, self).setUp()
|
||||
self.user_admin = self.env.ref('base.user_root')
|
||||
self.user_root = self.env.ref('base.user_root')
|
||||
self.user_admin = self.env.ref('base.user_admin')
|
||||
self.user_demo = self.env.ref('base.user_demo')
|
||||
|
||||
def create_lead(self, **kwargs):
|
||||
vals = {
|
||||
'name': "Lead Test",
|
||||
'user_id': self.user_admin.id,
|
||||
'user_id': self.user_root.id,
|
||||
}
|
||||
vals.update(kwargs)
|
||||
return self.env['base.automation.lead.test'].create(vals)
|
||||
@@ -27,7 +29,7 @@ class base_automation_test(common.TransactionCase):
|
||||
"""
|
||||
lead = self.create_lead(state='open')
|
||||
self.assertEqual(lead.state, 'open')
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
|
||||
def test_01_check_to_state_draft_post(self):
|
||||
"""
|
||||
@@ -49,15 +51,15 @@ class base_automation_test(common.TransactionCase):
|
||||
"""
|
||||
lead = self.create_lead(state='open')
|
||||
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
# change state to pending and check that responsible has not changed
|
||||
lead.write({'state': 'pending'})
|
||||
self.assertEqual(lead.state, 'pending', "Lead state should be 'pending'")
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
|
||||
# change state to done and check that responsible has not changed
|
||||
lead.write({'state': 'done'})
|
||||
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not chang on creation of Lead with state from 'pending' to 'done'.")
|
||||
|
||||
def test_03_check_from_draft_to_done_without_steps(self):
|
||||
"""
|
||||
@@ -70,7 +72,7 @@ class base_automation_test(common.TransactionCase):
|
||||
"""
|
||||
lead = self.create_lead(state='open')
|
||||
self.assertEqual(lead.state, 'open', "Lead state should be 'open'")
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state 'open'.")
|
||||
# change state to done and check that responsible has changed
|
||||
lead.write({'state': 'done'})
|
||||
self.assertEqual(lead.state, 'done', "Lead state should be 'done'")
|
||||
@@ -85,7 +87,7 @@ class base_automation_test(common.TransactionCase):
|
||||
partner.write({'customer': False})
|
||||
lead = self.create_lead(state='open', partner_id=partner.id)
|
||||
self.assertFalse(lead.customer, "Customer field should updated to False")
|
||||
self.assertEqual(lead.user_id, self.user_admin, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
|
||||
self.assertEqual(lead.user_id, self.user_root, "Responsible should not change on creation of Lead with state from 'draft' to 'open'.")
|
||||
# change partner, recompute on lead should trigger the rule
|
||||
partner.write({'customer': True})
|
||||
self.assertTrue(lead.customer, "Customer field should updated to True")
|
||||
@@ -101,15 +103,52 @@ class base_automation_test(common.TransactionCase):
|
||||
self.assertFalse(lead.deadline, 'There should not be a deadline defined')
|
||||
# change priority and user; this triggers deadline recomputation, and
|
||||
# the server action should set the boolean field to True
|
||||
lead.write({'priority': True, 'user_id': self.user_admin.id})
|
||||
lead.write({'priority': True, 'user_id': self.user_root.id})
|
||||
self.assertTrue(lead.deadline, 'Deadline should be defined')
|
||||
self.assertTrue(lead.is_assigned_to_admin, 'Lead should be assigned to admin')
|
||||
|
||||
def test_11b_recomputed_field(self):
|
||||
mail_automation = self.env.ref('base_automation.test_rule_on_write_recompute_send_email')
|
||||
send_mail_count = 0
|
||||
|
||||
def _patched_get_actions(*args, **kwargs):
|
||||
obj = args[0]
|
||||
if '__action_done' not in obj._context:
|
||||
obj = obj.with_context(__action_done={})
|
||||
return mail_automation.with_env(obj.env)
|
||||
|
||||
def _patched_send_mail(*args, **kwargs):
|
||||
nonlocal send_mail_count
|
||||
send_mail_count += 1
|
||||
|
||||
patchers = [
|
||||
patch('odoo.addons.base_automation.models.base_automation.BaseAutomation._get_actions', _patched_get_actions),
|
||||
patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', _patched_send_mail),
|
||||
]
|
||||
|
||||
patchers[0].start()
|
||||
|
||||
lead = self.create_lead()
|
||||
self.assertFalse(lead.priority)
|
||||
self.assertFalse(lead.deadline)
|
||||
|
||||
patchers[1].start()
|
||||
|
||||
lead.write({'priority': True})
|
||||
|
||||
self.assertTrue(lead.priority)
|
||||
self.assertTrue(lead.deadline)
|
||||
|
||||
for patcher in patchers:
|
||||
patcher.stop()
|
||||
|
||||
self.assertEqual(send_mail_count, 1)
|
||||
|
||||
def test_12_recursive(self):
|
||||
""" Check that a rule is executed recursively by a secondary change. """
|
||||
lead = self.create_lead(state='open')
|
||||
self.assertEqual(lead.state, 'open')
|
||||
self.assertEqual(lead.user_id, self.user_admin)
|
||||
self.assertEqual(lead.user_id, self.user_root)
|
||||
# change partner; this should trigger the rule that modifies the state
|
||||
partner = self.env.ref('base.res_partner_1')
|
||||
lead.write({'partner_id': partner.id})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
Name,Is a company,Related company,Address type,Customer,Supplier,Street,ZIP,City,State,Country
|
||||
Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,CA,United States
|
||||
Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,CA,United States
|
||||
House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,CA,United States
|
||||
Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,CA,United States
|
||||
Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,CA,United States
|
||||
Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,CA,United States
|
||||
International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,DC,United States
|
||||
Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,DC,United States
|
||||
Aurora Shelves,1,,,1,0,25 Pacific Road,95101,San José,California,United States
|
||||
Roger Martins,0,Aurora Shelves,Invoice address,1,0,27 Pacific Road,95102,San José,California,United States
|
||||
House Sales Direct,1,,,1,0,104 Saint Mary Avenue,94059,Redwood,California,United States
|
||||
Yvan Holiday,0,House Sales Direct,Contact,1,0,104 Saint Mary Avenue,94060,Redwood,California,United States
|
||||
Jack Unsworth,0,House Sales Direct,Invoice address,1,0,227 Jackson Road,94061,Redwood,California,United States
|
||||
Michael Mason,0,,,1,0,16 5th Avenue,94104,San Francisco,California,United States
|
||||
International Wood,1,,,1,0,748 White House Boulevard,20004,Washington,D.C.,United States
|
||||
Sharon Pecker,0,International Wood,Invoice address,1,0,755 White House Boulevard,20005,Washington,D.C.,United States
|
||||
|
||||
|
@@ -31,7 +31,10 @@ class Team(models.Model):
|
||||
compute='_compute_overdue_opportunities',
|
||||
string='Overdue Opportunities Revenues')
|
||||
|
||||
alias_user_id = fields.Many2one(domain=lambda self: [
|
||||
# Since we are in a _inherits case, this is not an override
|
||||
# but a plain definition of a field
|
||||
# So we need to reset the property related of that field
|
||||
alias_user_id = fields.Many2one('res.users', related='alias_id.alias_user_id', inherited=True, domain=lambda self: [
|
||||
('groups_id', 'in', self.env.ref('sales_team.group_sale_salesman_all_leads').id)])
|
||||
|
||||
def _compute_unassigned_leads_count(self):
|
||||
|
||||
@@ -73,6 +73,11 @@ class SaleOrderLine(models.Model):
|
||||
if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id):
|
||||
self.event_ticket_id = None
|
||||
|
||||
@api.onchange('product_uom', 'product_uom_qty')
|
||||
def product_uom_change(self):
|
||||
if not self.event_ticket_id:
|
||||
super(SaleOrderLine, self).product_uom_change()
|
||||
|
||||
@api.onchange('event_ticket_id')
|
||||
def _onchange_event_ticket_id(self):
|
||||
company = self.event_id.company_id or self.env.user.company_id
|
||||
|
||||
@@ -1,20 +1,75 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!--Karma-->
|
||||
<record id="base.user_root" model="res.users">
|
||||
<field name="karma">2500</field>
|
||||
</record>
|
||||
<record id="base.user_admin" model="res.users">
|
||||
<field name="karma">2500</field>
|
||||
</record>
|
||||
<odoo><data noupdate="1">
|
||||
<!--Karma-->
|
||||
<record id="base.user_root" model="res.users">
|
||||
<field name="karma">2500</field>
|
||||
</record>
|
||||
<record id="base.user_admin" model="res.users">
|
||||
<field name="karma">2500</field>
|
||||
</record>
|
||||
|
||||
<!--Ranks-->
|
||||
<record id="rank_newbie" model="gamification.karma.rank">
|
||||
<field name="name">Newbie</field>
|
||||
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
|
||||
<field name="karma_min">1</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
<!--Ranks-->
|
||||
<record id="rank_newbie" model="gamification.karma.rank">
|
||||
<field name="name">Newbie</field>
|
||||
<field name="description" type="html"><p>You just began the adventure! Welcome!</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank to show the rest of the world you exist!</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_student_badge.svg"/>
|
||||
</div>
|
||||
</field>
|
||||
<field name="karma_min">1</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_newbie_badge.svg"/>
|
||||
</record>
|
||||
|
||||
<record id="rank_student" model="gamification.karma.rank">
|
||||
<field name="name">Student</field>
|
||||
<field name="description" type="html"><p>You're a young padawan now. May the force be with you!</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank to improve your status.</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_bachelor_badge.svg"/>
|
||||
</div>
|
||||
</field>
|
||||
<field name="karma_min">100</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
|
||||
</record>
|
||||
|
||||
<record id="rank_bachelor" model="gamification.karma.rank">
|
||||
<field name="name">Bachelor</field>
|
||||
<field name="description" type="html"><p>You love learning things. Curiosity is a good way to progress.</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and become a Master!</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_master_badge.svg"/>
|
||||
</div>
|
||||
</field>
|
||||
<field name="karma_min">500</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
|
||||
</record>
|
||||
|
||||
<record id="rank_master" model="gamification.karma.rank">
|
||||
<field name="name">Master</field>
|
||||
<field name="description" type="html"><p>You know what you are talking about. People learn from you.</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and become a powerful user!</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="gamification/static/img/rank_doctor_badge.svg"/>
|
||||
</div>
|
||||
</field>
|
||||
<field name="karma_min">2000</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
|
||||
</record>
|
||||
|
||||
<record id="rank_doctor" model="gamification.karma.rank">
|
||||
<field name="name">Doctor</field>
|
||||
<field name="description" type="html"><p>You have reached the last rank. Congratulations!</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">You have reached the last rank. Congratulations!</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="karma_min">10000</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
|
||||
</record>
|
||||
</data></odoo>
|
||||
|
||||
@@ -10,51 +10,39 @@
|
||||
|
||||
<!--Ranks-->
|
||||
<record id="rank_student" model="gamification.karma.rank">
|
||||
<field name="name">Student</field>
|
||||
<field name="description" type="html"><p>You begin to understand things ! Keep going!</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very nice mug !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
|
||||
</div></field>
|
||||
<field name="karma_min">100</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_student_badge.svg"/>
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very nice mug !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_mug.png"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="rank_bachelor" model="gamification.karma.rank">
|
||||
<field name="name">Bachelor</field>
|
||||
<field name="description" type="html"><p>Hey ! You're not so dumb after all !</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very magic wand !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
|
||||
</div></field>
|
||||
<field name="karma_min">500</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_bachelor_badge.svg"/>
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very magic wand !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_wand.png"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="rank_master" model="gamification.karma.rank">
|
||||
<field name="name">Master</field>
|
||||
<field name="description" type="html"><p>You're good ! Really Good !</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very nice hat !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
|
||||
</div></field>
|
||||
<field name="karma_min">2000</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_master_badge.svg"/>
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a very nice hat !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_hat.png"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="rank_doctor" model="gamification.karma.rank">
|
||||
<field name="name">Doctor</field>
|
||||
<field name="description" type="html"><p>Ok, RESPECT !</p></field>
|
||||
<field name="description_motivational" type="html">
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
|
||||
</div></field>
|
||||
<field name="karma_min">10000</field>
|
||||
<field name="image" type="base64" file="gamification/static/img/rank_doctor_badge.svg"/>
|
||||
<div class="media align-items-center">
|
||||
<div class="media-body">Reach the next rank and gain a week-end with a unicorn !</div>
|
||||
<img class="ml-3 img img-fluid" style="max-height: 72px;" src="/gamification/static/img/rank_misc_unicorn.png"/>
|
||||
</div>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data></odoo>
|
||||
|
||||
@@ -259,7 +259,7 @@ class GamificationBadge(models.Model):
|
||||
elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids:
|
||||
return self.USER_NOT_VIP
|
||||
elif self.rule_auth == 'having':
|
||||
all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)])
|
||||
all_user_badges = self.env['gamification.badge.user'].search([('user_id', '=', self.env.uid)]).mapped('badge_id')
|
||||
if self.rule_auth_badge_ids - all_user_badges:
|
||||
return self.BADGE_REQUIRED
|
||||
|
||||
|
||||
@@ -47,9 +47,10 @@ class Users(models.Model):
|
||||
|
||||
@api.multi
|
||||
def write(self, vals):
|
||||
result = super(Users, self).write(vals)
|
||||
if 'karma' in vals:
|
||||
self._recompute_rank()
|
||||
return super(Users, self).write(vals)
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
def add_karma(self, karma):
|
||||
@@ -72,13 +73,16 @@ class Users(models.Model):
|
||||
self.env['gamification.karma.rank'].search([], order="karma_min DESC")]
|
||||
for user in self:
|
||||
old_rank = user.rank_id
|
||||
for i in range(0, len(ranks)):
|
||||
if user.karma >= ranks[i]['karma_min']:
|
||||
user.write({
|
||||
'rank_id': ranks[i]['rank'].id,
|
||||
'next_rank_id': ranks[i - 1]['rank'].id if i > 0 else False
|
||||
})
|
||||
break
|
||||
if user.karma == 0 and ranks:
|
||||
user.write({'next_rank_id': ranks[-1]['rank'].id})
|
||||
else:
|
||||
for i in range(0, len(ranks)):
|
||||
if user.karma >= ranks[i]['karma_min']:
|
||||
user.write({
|
||||
'rank_id': ranks[i]['rank'].id,
|
||||
'next_rank_id': ranks[i - 1]['rank'].id if 0 < i else False
|
||||
})
|
||||
break
|
||||
if old_rank != user.rank_id:
|
||||
user._rank_changed()
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
@@ -360,13 +360,13 @@ class Employee(models.Model):
|
||||
self = self.sudo(real_user)
|
||||
return self
|
||||
|
||||
def _message_log(self, body='', subject=False, message_type='notification', **kwargs):
|
||||
return super(Employee, self._post_author()).message_post(body=body, subject=subject, message_type=message_type, **kwargs)
|
||||
def _message_log(self, *args, **kwargs):
|
||||
return super(Employee, self._post_author())._message_log(*args, **kwargs)
|
||||
|
||||
@api.multi
|
||||
@api.returns('mail.message', lambda value: value.id)
|
||||
def message_post(self, **kwargs):
|
||||
return super(Employee, self._post_author()).message_post(**kwargs)
|
||||
def message_post(self, *args, **kwargs):
|
||||
return super(Employee, self._post_author()).message_post(*args, **kwargs)
|
||||
|
||||
|
||||
class Department(models.Model):
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * crm_project
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.5\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-21 13:17+0000\n"
|
||||
"PO-Revision-Date: 2018-08-24 09:17+0000\n"
|
||||
"Language-Team: Azerbaijani (https://www.transifex.com/odoo/teams/41243/az/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: az\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
|
||||
msgid ""
|
||||
"<span class=\"o_stat_text\">Convert To</span>\n"
|
||||
" <span class=\"o_stat_text\">Task</span>"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
|
||||
msgid "Convert to Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
msgid "Create Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Create a new customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
|
||||
msgid "Customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Do not link to a customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
|
||||
msgid "Lead"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model,name:crm_project.model_crm_lead_convert2task
|
||||
msgid "Lead convert to Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Link to an existing customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
|
||||
msgid "Related Customer"
|
||||
msgstr ""
|
||||
@@ -1,115 +0,0 @@
|
||||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * crm_project
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server saas~11.5\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2018-09-21 13:17+0000\n"
|
||||
"PO-Revision-Date: 2018-08-24 09:17+0000\n"
|
||||
"Language-Team: Filipino (https://www.transifex.com/odoo/teams/41243/fil/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Language: fil\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n == 1 || n==2 || n==3) || (n % 10 != 4 || n % 10 != 6 || n % 10 != 9);\n"
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
|
||||
msgid ""
|
||||
"<span class=\"o_stat_text\">Convert To</span>\n"
|
||||
" <span class=\"o_stat_text\">Task</span>"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.actions.act_window,name:crm_project.crm_lead_convert2task_action
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_view_form
|
||||
msgid "Convert to Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model_terms:ir.ui.view,arch_db:crm_project.crm_lead_convert2task_view_form
|
||||
msgid "Create Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Create a new customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_uid
|
||||
msgid "Created by"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__create_date
|
||||
msgid "Created on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__partner_id
|
||||
msgid "Customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__display_name
|
||||
msgid "Display Name"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Do not link to a customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__id
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task____last_update
|
||||
msgid "Last Modified on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_uid
|
||||
msgid "Last Updated by"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__write_date
|
||||
msgid "Last Updated on"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__lead_id
|
||||
msgid "Lead"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model,name:crm_project.model_crm_lead_convert2task
|
||||
msgid "Lead convert to Task"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: selection:crm.lead.convert2task,action:0
|
||||
msgid "Link to an existing customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__project_id
|
||||
msgid "Project"
|
||||
msgstr ""
|
||||
|
||||
#. module: crm_project
|
||||
#: model:ir.model.fields,field_description:crm_project.field_crm_lead_convert2task__action
|
||||
msgid "Related Customer"
|
||||
msgstr ""
|
||||
@@ -10,7 +10,7 @@ class GamificationBadgeUserWizard(models.TransientModel):
|
||||
|
||||
employee_id = fields.Many2one('hr.employee', string='Employee', required=True)
|
||||
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id',
|
||||
store=False, readonly=True)
|
||||
store=False, readonly=True, compute_sudo=True)
|
||||
|
||||
@api.multi
|
||||
def action_grant_badge(self):
|
||||
|
||||
@@ -111,13 +111,12 @@ class HolidaysRequest(models.Model):
|
||||
"\nThe status is 'Approved', when time off request is approved by manager.")
|
||||
payslip_status = fields.Boolean('Reported in last payslips', help='Green this button when the time off has been taken into account in the payslip.')
|
||||
report_note = fields.Text('HR Comments')
|
||||
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
|
||||
user_id = fields.Many2one('res.users', string='User', related='employee_id.user_id', related_sudo=True, compute_sudo=True, store=True, default=lambda self: self.env.uid, readonly=True)
|
||||
# leave type configuration
|
||||
holiday_status_id = fields.Many2one(
|
||||
"hr.leave.type", string="Time Off Type", required=True, readonly=True,
|
||||
states={'draft': [('readonly', False)], 'confirm': [('readonly', False)]},
|
||||
domain=[('valid', '=', True)])
|
||||
leave_type_request_unit = fields.Selection(related='holiday_status_id.request_unit', readonly=True)
|
||||
validation_type = fields.Selection('Validation Type', related='holiday_status_id.validation_type', readonly=False)
|
||||
# HR data
|
||||
employee_id = fields.Many2one(
|
||||
|
||||
@@ -252,7 +252,7 @@ class HolidaysAllocation(models.Model):
|
||||
if self.env.user.employee_ids:
|
||||
self.department_id = self.department_id or self.env.user.employee_ids[0].department_id
|
||||
self.employee_id = None
|
||||
elif self.holiday_type == 'category':
|
||||
else:
|
||||
self.employee_id = None
|
||||
self.department_id = None
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<div>
|
||||
<strong class="o_kanban_record_title"><t t-esc="record.employee_id.value"/></strong>
|
||||
<span class="float-right">
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'validate': 'success','confirm': 'default', 'cancel': 'danger'}}"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-muted o_kanban_record_subtitle">
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<record id="group_hr_holidays_team_leader" model="res.groups">
|
||||
<field name="name">Team Approver</field>
|
||||
<field name="category_id" ref="base.module_category_human_resources_time_off"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="group_hr_holidays_user" model="res.groups">
|
||||
@@ -48,7 +49,7 @@
|
||||
<record id="hr_leave_rule_team_leader_update" model="ir.rule">
|
||||
<field name="name">Time Off: approver: write on employee's time off in his team</field>
|
||||
<field name="model_id" ref="model_hr_leave"/>
|
||||
<field name="domain_force">[('employee_id.leave_manager_id', '=' , user.id), ('holiday_type', '=', 'employee')]</field>
|
||||
<field name="domain_force">['&', ('holiday_type', '=', 'employee'), '|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
@@ -123,6 +124,16 @@
|
||||
<field name="groups" eval="[(4, ref('group_hr_holidays_manager'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="hr_leave_allocation_rule_team_leader" model="ir.rule">
|
||||
<field name="name">Allocations: Team Leader: read employee's allocations in his team</field>
|
||||
<field name="model_id" ref="model_hr_leave_allocation"/>
|
||||
<field name="domain_force">['|', ('employee_id.leave_manager_id', '=' , user.id), ('employee_id.parent_id.user_id', '=' , user.id)]</field>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
<field name="groups" eval="[(4, ref('hr_holidays.group_hr_holidays_team_leader'))]"/>
|
||||
</record>
|
||||
|
||||
<record id="resource_leaves_team_leader" model="ir.rule">
|
||||
<field name="name">Time Off Resources: Approver</field>
|
||||
<field name="model_id" ref="model_resource_calendar_leaves"/>
|
||||
|
||||
@@ -23,6 +23,9 @@ class TestHrHolidaysBase(common.TransactionCase):
|
||||
self.user_hrmanager_2 = mail_new_test_user(self.env, login='florence', groups='base.group_user,hr_holidays.group_hr_holidays_manager')
|
||||
self.user_hrmanager_2_id = self.user_hrmanager_2.id
|
||||
|
||||
self.user_team_leader = mail_new_test_user(self.env, login='arthur', groups='base.group_user,hr_holidays.group_hr_holidays_team_leader')
|
||||
self.user_team_leader_id = self.user_team_leader.id
|
||||
|
||||
# Hr Data
|
||||
Department = self.env['hr.department'].with_context(tracking_disable=True)
|
||||
|
||||
|
||||
@@ -362,6 +362,37 @@ class TestLeavesRights(TestHrHolidaysBase):
|
||||
self.employee_leave.sudo(self.user_hruser).action_approve()
|
||||
self.employee_leave.sudo(self.user_hrmanager_id).action_validate()
|
||||
|
||||
# ----------------------------------------
|
||||
# Validation: team leader
|
||||
# ----------------------------------------
|
||||
|
||||
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
|
||||
def test_leave_validation_team_leader_other(self):
|
||||
""" Team Leader validates its employee's leave only"""
|
||||
self.leave_type.write({'allocation_type': 'fixed'})
|
||||
self.env['hr.leave.allocation'].create({
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'employee_id': self.employee_emp.id,
|
||||
'number_of_days': 10,
|
||||
}).action_approve()
|
||||
with self.assertRaises(AccessError):
|
||||
self.employee_leave.sudo(self.user_team_leader).action_approve()
|
||||
|
||||
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
|
||||
def test_leave_validation_team_leader(self):
|
||||
""" Team Leader validates its employee's leave"""
|
||||
self.leave_type.write({'allocation_type': 'fixed'})
|
||||
|
||||
# set the team leader
|
||||
self.employee_emp.leave_manager_id = self.user_team_leader
|
||||
|
||||
self.env['hr.leave.allocation'].create({
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
'employee_id': self.employee_emp.id,
|
||||
'number_of_days': 10,
|
||||
}).action_approve()
|
||||
self.employee_leave.sudo(self.user_team_leader).action_approve()
|
||||
|
||||
|
||||
class TestMultiCompany(TestHrHolidaysBase):
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ class TestLeaveRequests(TestHrHolidaysBase):
|
||||
'number_of_days': 1,
|
||||
})
|
||||
|
||||
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail')
|
||||
@mute_logger('odoo.models.unlink', 'odoo.addons.mail.models.mail_mail', 'odoo.models')
|
||||
def test_accrual_validity_time_not_valid(self):
|
||||
""" Employee ask leav during a not valid validity time """
|
||||
with self.assertRaises(ValidationError):
|
||||
|
||||
@@ -275,9 +275,9 @@ class HrBenefit(models.Model):
|
||||
}]
|
||||
self.env['resource.calendar.attendance'].create(vals_list)
|
||||
|
||||
@api.model
|
||||
def action_validate(self, ids):
|
||||
benefits = self.env['hr.benefit'].search([('id', 'in', ids), ('state', '!=', 'validated')])
|
||||
@api.multi
|
||||
def action_validate(self):
|
||||
benefits = self.filtered(lambda benefit: benefit.state != 'validated')
|
||||
benefits.write({'display_warning': False})
|
||||
if not benefits._check_if_error():
|
||||
benefits.write({'state': 'validated'})
|
||||
@@ -311,4 +311,4 @@ class Contacts(models.Model):
|
||||
|
||||
_sql_constraints = [
|
||||
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have twice the same employee.')
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
# # -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import exceptions
|
||||
from odoo.fields import Datetime, Date
|
||||
from odoo.tests.common import tagged
|
||||
from odoo.addons.hr_payroll.tests.common import TestPayslipBase
|
||||
@@ -130,7 +128,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_stop': end,
|
||||
'benefit_type_id': self.benefit_type.id,
|
||||
})
|
||||
benefit.action_validate(benefit.ids)
|
||||
benefit.action_validate()
|
||||
benefits = self.env['hr.benefit'].search([('employee_id', '=', self.richard_emp.id)])
|
||||
self.assertTrue(all((b.state == 'validated' for b in benefits)), "Benefits should be approved")
|
||||
self.assertEqual(len(benefits), 3, "Benefit should be split in three")
|
||||
@@ -204,7 +202,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
benef.action_validate()
|
||||
calendar_leave = self.env['resource.calendar.leaves'].search([('name', '=', 'Richard leave from benef')])
|
||||
self.assertTrue(calendar_leave, "It should have created a leave in the calendar")
|
||||
self.assertEqual(calendar_leave.benefit_type_id, benef.benefit_type_id, "It should have the same benefit type")
|
||||
@@ -220,7 +218,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end + relativedelta(hours=5),
|
||||
})
|
||||
benef2 = self.env['hr.benefit'].create({
|
||||
self.env['hr.benefit'].create({
|
||||
'name': '2',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'benefit_type_id': self.env.ref('hr_payroll.benefit_type_attendance').id,
|
||||
@@ -228,7 +226,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start + relativedelta(hours=3),
|
||||
'date_stop': end,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids), "It should not validate benefits conflicting with others")
|
||||
self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with others")
|
||||
self.assertTrue(benef1.display_warning)
|
||||
self.assertNotEqual(benef1.state, 'validated')
|
||||
|
||||
@@ -241,7 +239,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': self.start,
|
||||
'date_stop': self.end,
|
||||
})
|
||||
leave_1 = self.env['hr.leave'].create({
|
||||
self.env['hr.leave'].create({
|
||||
'name': 'Doctor Appointment',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'holiday_status_id': self.leave_type.id,
|
||||
@@ -249,7 +247,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_to': self.start + relativedelta(days=1),
|
||||
'number_of_days': 2,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits conflicting with non approved leaves")
|
||||
self.assertFalse(benef1.action_validate(), "It should not validate benefits conflicting with non approved leaves")
|
||||
self.assertTrue(benef1.display_warning)
|
||||
|
||||
def test_validate_undefined_benefit(self):
|
||||
@@ -260,7 +258,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': self.start,
|
||||
'date_stop': self.end,
|
||||
})
|
||||
self.assertFalse(benef1.action_validate(benef1.ids),"It should not validate benefits without a type")
|
||||
self.assertFalse(benef1.action_validate(), "It should not validate benefits without a type")
|
||||
|
||||
def test_approve_leave_benefit(self):
|
||||
start = self.to_datetime_tz('2015-11-01 09:00:00')
|
||||
@@ -273,7 +271,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_to': start + relativedelta(days=1),
|
||||
'number_of_days': 2,
|
||||
})
|
||||
benef = self.env['hr.benefit'].create({
|
||||
self.env['hr.benefit'].create({
|
||||
'name': '1',
|
||||
'employee_id': self.richard_emp.id,
|
||||
'contract_id': self.richard_emp.contract_id.id,
|
||||
@@ -299,7 +297,7 @@ class TestBenefit(TestPayslipBase):
|
||||
self.assertTrue(new_benef, "It should have created a benefit for the last two days")
|
||||
self.assertTrue(new_leave_benef, "It should have created a leave benefit for the first day")
|
||||
|
||||
self.assertTrue(benef.action_validate((new_benef | new_leave_benef).ids), "It should be able to validate the benefits")
|
||||
self.assertTrue((new_benef | new_leave_benef).action_validate(), "It should be able to validate the benefits")
|
||||
|
||||
def test_refuse_leave_benefit(self):
|
||||
start = self.to_datetime_tz('2015-11-01 09:00:00')
|
||||
@@ -321,7 +319,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_stop': end,
|
||||
'leave_id': leave.id
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
benef.action_validate()
|
||||
self.assertTrue(benef.display_warning, "It should have an error (conflicting leave to approve")
|
||||
leave.action_refuse()
|
||||
self.assertFalse(benef.display_warning, "It should not have an error")
|
||||
@@ -342,7 +340,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
benef.action_validate()
|
||||
data = self.richard_emp._get_benefit_days_data(self.benefit_type, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 7.0)
|
||||
|
||||
@@ -358,7 +356,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
leave_benef.action_validate(leave_benef.ids)
|
||||
leave_benef.action_validate()
|
||||
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 5.0, "It should equal the number of hours richard should have worked")
|
||||
|
||||
@@ -374,7 +372,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
leave_benef.action_validate(leave_benef.ids)
|
||||
leave_benef.action_validate()
|
||||
data = self.richard_emp._get_benefit_days_data(self.benefit_type_leave, self.start, self.end)
|
||||
self.assertEqual(data['hours'], 0.0, "It should equal the number of hours richard should have worked")
|
||||
|
||||
@@ -390,7 +388,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
leave_benef.action_validate(leave_benef.ids)
|
||||
leave_benef.action_validate()
|
||||
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
|
||||
payslip_wizard.with_context({'default_date_start': Date.to_string(start), 'default_date_end': Date.to_string(end)}).compute_sheet()
|
||||
payslip = self.env['hr.payslip'].search([('employee_id', '=', self.richard_emp.id)])
|
||||
@@ -414,7 +412,7 @@ class TestBenefit(TestPayslipBase):
|
||||
'date_start': start,
|
||||
'date_stop': end,
|
||||
})
|
||||
benef.action_validate(benef.ids)
|
||||
benef.action_validate()
|
||||
payslip_wizard = self.env['hr.payslip.employees'].create({'employee_ids': [(4, self.richard_emp.id)]})
|
||||
payslip_wizard.with_context({
|
||||
'default_date_start': Date.to_string(start),
|
||||
|
||||
@@ -90,7 +90,7 @@ var AbstractGroupedOne2ManyRenderer = ListRenderer.extend({
|
||||
return qweb.render('hr_trash_button');
|
||||
},
|
||||
|
||||
_renderAddItemButton(group) {
|
||||
_renderAddItemButton: function (group) {
|
||||
return qweb.render(this.addLineButtonTemplate, {
|
||||
context: JSON.stringify(this._getCreateLineContext(group)),
|
||||
});
|
||||
@@ -141,8 +141,8 @@ var ResumeLineRenderer = AbstractGroupedOne2ManyRenderer.extend({
|
||||
var date_start = data.date_start && data.date_start.format(dateFormat) || "";
|
||||
var date_end = data.date_end && data.date_end.format(dateFormat) || _t("Current");
|
||||
return _.extend(data, {
|
||||
date_start,
|
||||
date_end,
|
||||
date_start: date_start,
|
||||
date_end: date_end,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -18,52 +18,52 @@
|
||||
</div>
|
||||
<group class="mt16">
|
||||
<label for="default_holidays"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_holidays" nolabel="1"/>
|
||||
<span>days / year</span>
|
||||
</div>
|
||||
<label for="default_commission_on_target"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_commission_on_target" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_fuel_card"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_fuel_card" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_representation_fees"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_representation_fees" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_internet"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_internet" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_mobile"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_mobile" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_internet"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_internet" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_mobile_plus"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_mobile_plus" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_meal_voucher_amount"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_meal_voucher_amount" nolabel="1"/>
|
||||
<span>€ / month</span>
|
||||
</div>
|
||||
<label for="default_eco_checks"/>
|
||||
<div class="o_row">
|
||||
<div>
|
||||
<field name="default_eco_checks" nolabel="1"/>
|
||||
<span>€ / year</span>
|
||||
</div>
|
||||
|
||||
@@ -76,6 +76,27 @@ class AccountFrFec(models.TransientModel):
|
||||
listrow = list(row)
|
||||
return listrow
|
||||
|
||||
def _get_company_legal_data(self, company):
|
||||
"""
|
||||
Dom-Tom are excluded from the EU's fiscal territory
|
||||
Those regions do not have SIREN
|
||||
sources:
|
||||
https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570
|
||||
http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom
|
||||
"""
|
||||
dom_tom_group = self.env.ref('l10n_fr.dom-tom')
|
||||
is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped('code')
|
||||
if not is_dom_tom and not company.vat:
|
||||
raise Warning(
|
||||
_("Missing VAT number for company %s") % company.name)
|
||||
if not is_dom_tom and company.vat[0:2] != 'FR':
|
||||
raise Warning(
|
||||
_("FEC is for French companies only !"))
|
||||
|
||||
return {
|
||||
'siren': company.vat[4:13] if not is_dom_tom else '',
|
||||
}
|
||||
|
||||
@api.multi
|
||||
def generate_fec(self):
|
||||
self.ensure_one()
|
||||
@@ -87,6 +108,9 @@ class AccountFrFec(models.TransientModel):
|
||||
# 2) CSV files are easier to read/use for a regular accountant.
|
||||
# So it will be easier for the accountant to check the file before
|
||||
# sending it to the fiscal administration
|
||||
company = self.env.user.company_id
|
||||
company_legal_data = self._get_company_legal_data(company)
|
||||
|
||||
header = [
|
||||
u'JournalCode', # 0
|
||||
u'JournalLib', # 1
|
||||
@@ -108,18 +132,7 @@ class AccountFrFec(models.TransientModel):
|
||||
u'Idevise', # 17
|
||||
]
|
||||
|
||||
company = self.env.user.company_id
|
||||
if not company.vat:
|
||||
raise Warning(
|
||||
_("Missing VAT number for company %s") % company.name)
|
||||
if company.vat[0:2] != 'FR':
|
||||
raise Warning(
|
||||
_("FEC is for French companies only !"))
|
||||
|
||||
fecfile = io.BytesIO()
|
||||
w = pycompat.csv_writer(fecfile, delimiter='|')
|
||||
w.writerow(header)
|
||||
|
||||
rows_to_write = [header]
|
||||
# INITIAL BALANCE
|
||||
unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings')
|
||||
unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once
|
||||
@@ -195,7 +208,8 @@ class AccountFrFec(models.TransientModel):
|
||||
else:
|
||||
listrow[11] = '0,00'
|
||||
listrow[12] = str(-listrow_amount).replace('.', ',')
|
||||
w.writerow(listrow)
|
||||
rows_to_write.append(listrow)
|
||||
|
||||
#if the unaffected earnings account wasn't in the selection yet: add it manually
|
||||
if (not unaffected_earnings_line
|
||||
and unaffected_earnings_results
|
||||
@@ -206,7 +220,7 @@ class AccountFrFec(models.TransientModel):
|
||||
if unaffected_earnings_account:
|
||||
unaffected_earnings_results[4] = unaffected_earnings_account.code
|
||||
unaffected_earnings_results[5] = unaffected_earnings_account.name
|
||||
w.writerow(unaffected_earnings_results)
|
||||
rows_to_write.append(unaffected_earnings_results)
|
||||
|
||||
# INITIAL BALANCE - receivable/payable
|
||||
sql_query = '''
|
||||
@@ -264,7 +278,7 @@ class AccountFrFec(models.TransientModel):
|
||||
for row in self._cr.fetchall():
|
||||
listrow = list(row)
|
||||
account_id = listrow.pop()
|
||||
w.writerow(listrow)
|
||||
rows_to_write.append(listrow)
|
||||
|
||||
# LINES
|
||||
sql_query = '''
|
||||
@@ -329,20 +343,19 @@ class AccountFrFec(models.TransientModel):
|
||||
sql_query, (self.date_from, self.date_to, company.id))
|
||||
|
||||
for row in self._cr.fetchall():
|
||||
w.writerow(list(row))
|
||||
rows_to_write.append(list(row))
|
||||
|
||||
siren = company.vat[4:13]
|
||||
fecvalue = self._csv_write_rows(rows_to_write)
|
||||
end_date = fields.Date.to_string(self.date_to).replace('-', '')
|
||||
suffix = ''
|
||||
if self.export_type == "nonofficial":
|
||||
suffix = '-NONOFFICIAL'
|
||||
fecvalue = fecfile.getvalue()
|
||||
|
||||
self.write({
|
||||
'fec_data': base64.encodestring(fecvalue),
|
||||
# Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date
|
||||
'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix),
|
||||
'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix),
|
||||
})
|
||||
fecfile.close()
|
||||
|
||||
action = {
|
||||
'name': 'FEC',
|
||||
@@ -351,3 +364,29 @@ class AccountFrFec(models.TransientModel):
|
||||
'target': 'self',
|
||||
}
|
||||
return action
|
||||
|
||||
def _csv_write_rows(self, rows, lineterminator=u'\r\n'):
|
||||
"""
|
||||
Write FEC rows into a file
|
||||
It seems that Bercy's bureaucracy is not too happy about the
|
||||
empty new line at the End Of File.
|
||||
|
||||
@param {list(list)} rows: the list of rows. Each row is a list of strings
|
||||
@param {unicode string} [optional] lineterminator: effective line terminator
|
||||
Has nothing to do with the csv writer parameter
|
||||
The last line written won't be terminated with it
|
||||
|
||||
@return the value of the file
|
||||
"""
|
||||
fecfile = io.BytesIO()
|
||||
writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='')
|
||||
|
||||
rows_length = len(rows)
|
||||
for i, row in enumerate(rows):
|
||||
if not i == rows_length - 1:
|
||||
row.append(lineterminator)
|
||||
writer.writerow(row)
|
||||
|
||||
fecvalue = fecfile.getvalue()
|
||||
fecfile.close()
|
||||
return fecvalue
|
||||
|
||||
@@ -87,11 +87,20 @@ class AccountChartTemplate(models.Model):
|
||||
return self.process_translations(langs, field, in_ids, out_ids)
|
||||
|
||||
def _get_template_from_model(self, company_id, model):
|
||||
""" Find the records and their matching template """
|
||||
# genereated records have an external id with the format <company id>_<template id>
|
||||
out_data = self.env['ir.model.data'].search([('model', '=', model), ('name', '=like', str(company_id)+'\_%')])
|
||||
out_ids = self.env[model].search([('id', 'in', out_data.mapped('res_id'))], order='id')
|
||||
out_ids = self.env[model].browse(out_data.mapped('res_id'))
|
||||
|
||||
# templates and records may have been created in a different order
|
||||
# reorder them based on external id names
|
||||
in_xml_id_names = [xml_id.partition(str(company_id) + '_')[-1] for xml_id in out_data.mapped('name')]
|
||||
in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', in_xml_id_names)])
|
||||
in_ids = self.env[model+'.template'].search([('id', 'in', in_xml_ids.mapped('res_id'))], order='id')
|
||||
in_xml_id_names = {name: index for index, name in enumerate(in_xml_id_names)}
|
||||
|
||||
in_xml_ids = self.env['ir.model.data'].search([('model', '=', model+'.template'), ('name', 'in', list(in_xml_id_names))])
|
||||
in_xml_ids = in_xml_ids.sorted(key=lambda x: in_xml_id_names[x.name])
|
||||
|
||||
in_ids = self.env[model+'.template'].browse(in_xml_ids.mapped('res_id'))
|
||||
return (in_ids, out_ids)
|
||||
|
||||
class BaseLanguageInstall(models.TransientModel):
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
.o_list_view {
|
||||
.o_data_row {
|
||||
.o_text_overflow {
|
||||
@include o-text-overflow(table-cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_lunch_kanban {
|
||||
display: flex;
|
||||
flex-direction: column; // display lunch widget above kanban renderer
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title"><field name="product_id"/></strong>
|
||||
</div>
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'new': 'default', 'confirmed': 'success', 'cancelled':'danger'}}"/>
|
||||
</div>
|
||||
<div>
|
||||
<field name="note"/>
|
||||
|
||||
@@ -80,18 +80,27 @@ class ServerActions(models.Model):
|
||||
def _is_recompute(self, action):
|
||||
"""When an activity is set on update of a record,
|
||||
update might be triggered many times by recomputes.
|
||||
When need to know it to skip these steps."""
|
||||
When need to know it to skip these steps.
|
||||
Except if the computed field is supposed to trigger the action
|
||||
"""
|
||||
records = self.env[action.model_id.model].browse(
|
||||
self._context.get('active_ids', self._context.get('active_id')))
|
||||
old_values = action._context.get('old_values')
|
||||
if old_values:
|
||||
field_names = list(old_values[records[0].id])
|
||||
field = records._fields[field_names[0]]
|
||||
# Pick an arbitrary field; if it is marked to be recomputed,
|
||||
# it means we are in an extraneous write triggered by the recompute.
|
||||
# In this case, we should not create a new activity.
|
||||
if records._recompute_check(field):
|
||||
return True
|
||||
domain_post = action._context.get('domain_post')
|
||||
tracked_fields = []
|
||||
if domain_post:
|
||||
for leaf in domain_post:
|
||||
if isinstance(leaf, (tuple, list)):
|
||||
tracked_fields.append(leaf[0])
|
||||
fields_to_check = [field for record, field_names in old_values.items() for field in field_names if field not in tracked_fields]
|
||||
if fields_to_check:
|
||||
field = records._fields[fields_to_check[0]]
|
||||
# Pick an arbitrary field; if it is marked to be recomputed,
|
||||
# it means we are in an extraneous write triggered by the recompute.
|
||||
# In this case, we should not create a new activity.
|
||||
if records._recompute_check(field):
|
||||
return True
|
||||
return False
|
||||
|
||||
@api.model
|
||||
|
||||
@@ -130,9 +130,9 @@ class MailActivity(models.Model):
|
||||
activity_type_id = fields.Many2one(
|
||||
'mail.activity.type', string='Activity Type',
|
||||
domain="['|', ('res_model_id', '=', False), ('res_model_id', '=', res_model_id)]", ondelete='restrict')
|
||||
activity_category = fields.Selection(related='activity_type_id.category', readonly=False)
|
||||
activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=False)
|
||||
icon = fields.Char('Icon', related='activity_type_id.icon', readonly=False)
|
||||
activity_category = fields.Selection(related='activity_type_id.category', readonly=True)
|
||||
activity_decoration = fields.Selection(related='activity_type_id.decoration_type', readonly=True)
|
||||
icon = fields.Char('Icon', related='activity_type_id.icon', readonly=True)
|
||||
summary = fields.Char('Summary')
|
||||
note = fields.Html('Note')
|
||||
date_deadline = fields.Date('Due Date', index=True, required=True, default=fields.Date.context_today)
|
||||
@@ -149,14 +149,14 @@ class MailActivity(models.Model):
|
||||
('today', 'Today'),
|
||||
('planned', 'Planned')], 'State',
|
||||
compute='_compute_state')
|
||||
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type")
|
||||
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type')
|
||||
recommended_activity_type_id = fields.Many2one('mail.activity.type', string="Recommended Activity Type", readonly=True)
|
||||
previous_activity_type_id = fields.Many2one('mail.activity.type', string='Previous Activity Type', readonly=True)
|
||||
has_recommended_activities = fields.Boolean(
|
||||
'Next activities available',
|
||||
compute='_compute_has_recommended_activities',
|
||||
help='Technical field for UX purpose')
|
||||
mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=False)
|
||||
force_next = fields.Boolean(related='activity_type_id.force_next', readonly=False)
|
||||
mail_template_ids = fields.Many2many(related='activity_type_id.mail_template_ids', readonly=True)
|
||||
force_next = fields.Boolean(related='activity_type_id.force_next', readonly=True)
|
||||
# access
|
||||
can_write = fields.Boolean(compute='_compute_can_write', help='Technical field to hide buttons if the current user has no access.')
|
||||
|
||||
@@ -296,6 +296,12 @@ class MailActivity(models.Model):
|
||||
activity.user_id.display_name)
|
||||
else:
|
||||
try:
|
||||
target_user = activity.user_id
|
||||
target_record = self.env[activity.res_model].browse(activity.res_id)
|
||||
if hasattr(target_record, 'company_id') and (
|
||||
target_record.company_id != target_user.company_id and (
|
||||
len(target_user.company_ids) > 1)):
|
||||
return # in that case we skip the check, assuming it would fail because of the company
|
||||
model.browse(activity.res_id).check_access_rule('read')
|
||||
except exceptions.AccessError:
|
||||
raise exceptions.UserError(
|
||||
|
||||
@@ -40,7 +40,9 @@ class MailTracking(models.Model):
|
||||
def _compute_groups(self):
|
||||
for tracking in self:
|
||||
model = self.env[tracking.mail_message_id.model]
|
||||
tracking.groups = model._fields[tracking.field].groups
|
||||
field = model._fields.get(tracking.field)
|
||||
# field may not exist anymore if module has been uninstalled
|
||||
tracking.groups = field.groups if field else 'base.group_system'
|
||||
|
||||
@api.model
|
||||
def create_tracking_values(self, initial_value, new_value, col_name, col_info, tracking_sequence):
|
||||
|
||||
@@ -53,7 +53,7 @@ var MentionManager = Widget.extend({
|
||||
var listener = _.findWhere(this._listeners, { delimiter: delimiter });
|
||||
if (listener) {
|
||||
var escapedVal = _.escape(this._composer.$input.val());
|
||||
var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | )', 'g'));
|
||||
var inputMentions = escapedVal.match(new RegExp(delimiter+'[^ ]+(?= | |$)', 'g'));
|
||||
return this._validateSelection(listener.selection, inputMentions);
|
||||
}
|
||||
return [];
|
||||
@@ -166,9 +166,8 @@ var MentionManager = Widget.extend({
|
||||
listener.model,
|
||||
listener.delimiter,
|
||||
matchName);
|
||||
var subtext = s.substring(startIndex, endIndex)
|
||||
.replace(match[0], processedText);
|
||||
substrings.push(subtext);
|
||||
substrings.push(s.substring(startIndex, match.index));
|
||||
substrings.push(processedText);
|
||||
startIndex = endIndex;
|
||||
}
|
||||
substrings.push(s.substring(startIndex, s.length));
|
||||
@@ -258,7 +257,7 @@ var MentionManager = Widget.extend({
|
||||
// create the regex of all mention's names
|
||||
var names = _.pluck(listener.selection, 'name');
|
||||
var escapedNames = _.map(names, function (str) {
|
||||
return "("+_.str.escapeRegExp(listener.delimiter+str)+")";
|
||||
return "("+_.str.escapeRegExp(listener.delimiter+str)+")(?= | |$)";
|
||||
});
|
||||
var regexStr = escapedNames.join('|');
|
||||
// extract matches
|
||||
|
||||
@@ -466,7 +466,7 @@ var ThreadWidget = Widget.extend({
|
||||
return message.getID() === messageID;
|
||||
});
|
||||
return QWeb.render('mail.widget.Thread.Message.MailTooltip', {
|
||||
data: message.getCustomerEmailData()
|
||||
data: message.hasCustomerEmailData() ? message.getCustomerEmailData() : [],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -507,6 +507,77 @@ QUnit.test('@ mention with special chars', async function (assert) {
|
||||
discuss.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('@ mention with removed space', async function (assert) {
|
||||
assert.expect(4);
|
||||
var fetchListenersDef = testUtils.makeTestPromise();
|
||||
var receiveMessageDef = testUtils.makeTestPromise();
|
||||
this.data.initMessaging = {
|
||||
channel_slots: {
|
||||
channel_channel: [{
|
||||
id: 1,
|
||||
channel_type: "channel",
|
||||
name: "general",
|
||||
}],
|
||||
},
|
||||
};
|
||||
var objectDiscuss;
|
||||
var discuss = await createDiscuss({
|
||||
id: 1,
|
||||
context: {},
|
||||
params: {},
|
||||
data: this.data,
|
||||
services: this.services,
|
||||
mockRPC: function (route, args) {
|
||||
if (args.method === 'channel_fetch_listeners') {
|
||||
fetchListenersDef.resolve();
|
||||
return Promise.resolve([
|
||||
{id: 1, name: 'Admin'},
|
||||
]);
|
||||
}
|
||||
if (args.method === 'message_post') {
|
||||
assert.deepEqual(args.kwargs.partner_ids, [],
|
||||
"mentioned partners are sent to server"
|
||||
);
|
||||
var data = {
|
||||
author_id: ["42", "Me"],
|
||||
body: args.kwargs.body,
|
||||
channel_ids: [1],
|
||||
};
|
||||
var notification = [[false, 'mail.channel', 1], data];
|
||||
objectDiscuss.call('bus_service', 'trigger', 'notification', [notification]);
|
||||
receiveMessageDef.resolve();
|
||||
return Promise.resolve(42);
|
||||
}
|
||||
return this._super.apply(this, arguments);
|
||||
},
|
||||
});
|
||||
objectDiscuss = discuss;
|
||||
var $general = discuss.$('.o_mail_discuss_sidebar')
|
||||
.find('.o_mail_discuss_item[data-thread-id=1]');
|
||||
// click on general
|
||||
$general.click();
|
||||
var $input = discuss.$('textarea.o_composer_text_field').first();
|
||||
assert.ok($input.length, "should display a composer input");
|
||||
// Note: focus is needed in order to trigger rpc 'channel_fetch_listeners'
|
||||
$input.focus();
|
||||
$input.val("@");
|
||||
$input.trigger('keyup');
|
||||
await fetchListenersDef;
|
||||
await testUtils.nextTick();
|
||||
// equivalent to $mentionPropositions.find('active').click();
|
||||
$input.trigger($.Event('keyup', {which: $.ui.keyCode.ENTER}));
|
||||
$input.val('@Admin: hi');
|
||||
// send message
|
||||
await testUtils.fields.triggerKeydown($input, 'enter');
|
||||
await receiveMessageDef;
|
||||
await testUtils.nextTick();
|
||||
assert.containsNone(discuss, '.o_thread_message_content a',
|
||||
"should not contain a link in the message content");
|
||||
assert.strictEqual(discuss.$('.o_thread_message_content').html().trim(),
|
||||
"@Admin: hi", "should not have linked mention without space");
|
||||
discuss.destroy();
|
||||
});
|
||||
|
||||
QUnit.test('@ mention in mailing channel', async function (assert) {
|
||||
assert.expect(8);
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import lxml
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
from ast import literal_eval
|
||||
from base64 import b64encode
|
||||
|
||||
from odoo import api, fields, models, tools, _, SUPERUSER_ID
|
||||
from odoo.exceptions import UserError
|
||||
@@ -26,6 +28,10 @@ MASS_MAILING_BUSINESS_MODELS = [
|
||||
'mail.mass_mailing.contact'
|
||||
]
|
||||
|
||||
# Syntax of the data URL Scheme: https://tools.ietf.org/html/rfc2397#section-3
|
||||
# Used to find inline images
|
||||
image_re = re.compile(r"data:(image/[A-Za-z]+);base64,(.*)")
|
||||
|
||||
|
||||
class MassMailingTag(models.Model):
|
||||
"""Model of categories of mass mailing, i.e. marketing, newsletter, ... """
|
||||
@@ -675,6 +681,53 @@ class MassMailing(models.Model):
|
||||
if len(record_lists) > 0:
|
||||
record.sudo().message_post(body=_(message % ', '.join(str(list.name) for list in record_lists.mapped('list_id'))))
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
if values.get('body_html'):
|
||||
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
|
||||
return super(MassMailing, self).create(values)
|
||||
|
||||
def write(self, values):
|
||||
if values.get('body_html'):
|
||||
values['body_html'] = self._convert_inline_images_to_urls(values['body_html'])
|
||||
return super(MassMailing, self).write(values)
|
||||
|
||||
def _convert_inline_images_to_urls(self, body_html):
|
||||
"""
|
||||
Find inline base64 encoded images, make an attachement out of
|
||||
them and replace the inline image with an url to the attachement.
|
||||
"""
|
||||
|
||||
def _image_to_url(b64image: bytes):
|
||||
"""Store an image in an attachement and returns an url"""
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': "cropped_image",
|
||||
'datas': b64image,
|
||||
'datas_fname': "cropped_image_mailing_{}".format(self.id),
|
||||
'type': 'binary',})
|
||||
|
||||
attachment.generate_access_token()
|
||||
|
||||
return '/web/image/%s?access_token=%s' % (
|
||||
attachment.id, attachment.access_token)
|
||||
|
||||
|
||||
modified = False
|
||||
root = lxml.html.fromstring(body_html)
|
||||
for node in root.iter('img'):
|
||||
match = image_re.match(node.attrib.get('src', ''))
|
||||
if match:
|
||||
mime = match.group(1) # unsed
|
||||
image = match.group(2).encode() # base64 image as bytes
|
||||
|
||||
node.attrib['src'] = _image_to_url(image)
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
return lxml.html.tostring(root)
|
||||
|
||||
return body_html
|
||||
|
||||
#------------------------------------------------------
|
||||
# Views & Actions
|
||||
#------------------------------------------------------
|
||||
|
||||
@@ -43,6 +43,9 @@ var MassMailingFieldHtml = FieldHtml.extend({
|
||||
*/
|
||||
commitChanges: function () {
|
||||
var self = this;
|
||||
if (!this.wysiwyg || !this.isRendered) {
|
||||
return this._super();
|
||||
}
|
||||
var fieldName = this.nodeOptions['inline-field'];
|
||||
|
||||
if (this.mode == "readonly") {
|
||||
|
||||
@@ -56,6 +56,7 @@ class TestMassMailingShortener(common.TransactionCase):
|
||||
"contact_list_ids": [[6, False, [mailing_list_A.id]]],
|
||||
"mass_mailing_campaign_id": False,
|
||||
"name": "sdf",
|
||||
"subject": "test_00_test_mass_mailing_shortener",
|
||||
"body_html": """
|
||||
Hi,
|
||||
% set url = "www.odoo.com"
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="btn btn-secondary pull-right" id="button_add_blacklist" style="display:none">Blacklist Me</div>
|
||||
</div>
|
||||
<div class="btn btn-secondary pull-right" id="button_remove_blacklist" style="display:none">Come Back</div>
|
||||
<input type="submit" id="send_form" class="btn btn-primary" value="Update my subscriptions"/>
|
||||
<button type="submit" id="send_form" class="btn btn-primary">Update my subscriptions</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -84,10 +84,9 @@ class MrpAbstractWorkorder(models.AbstractModel):
|
||||
for move_line in move_raw.move_line_ids:
|
||||
# Get workorder lines that match reservation.
|
||||
candidates = move_workorder_lines._find_candidate(move_line)
|
||||
while candidates:
|
||||
for candidate in candidates:
|
||||
if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0:
|
||||
break
|
||||
candidate = candidates.pop()
|
||||
qty_to_add = move_line.product_uom_qty - candidate.qty_done
|
||||
line_values['to_update'][candidate] = {
|
||||
'qty_done': candidate.qty_done + qty_to_add,
|
||||
@@ -136,7 +135,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
|
||||
'qty_to_consume': to_consume_in_line,
|
||||
'qty_reserved': move_line.product_uom_qty,
|
||||
'lot_id': move_line.lot_id.id,
|
||||
'qty_done': is_tracked and 0 or to_consume_in_line
|
||||
'qty_done': to_consume_in_line
|
||||
}
|
||||
lines.append(line)
|
||||
qty_to_consume -= to_consume_in_line
|
||||
@@ -159,7 +158,7 @@ class MrpAbstractWorkorder(models.AbstractModel):
|
||||
'product_id': move.product_id.id,
|
||||
'product_uom_id': move.product_uom.id,
|
||||
'qty_to_consume': qty_to_consume,
|
||||
'qty_done': is_tracked and 0 or qty_to_consume
|
||||
'qty_done': qty_to_consume
|
||||
}
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
@@ -94,6 +94,8 @@ class MrpBom(models.Model):
|
||||
self.product_uom_id = self.product_tmpl_id.uom_id.id
|
||||
if self.product_id.product_tmpl_id != self.product_tmpl_id:
|
||||
self.product_id = False
|
||||
for line in self.bom_line_ids:
|
||||
line.attribute_value_ids = False
|
||||
|
||||
@api.onchange('routing_id')
|
||||
def onchange_routing_id(self):
|
||||
@@ -292,6 +294,8 @@ class MrpBomLine(models.Model):
|
||||
|
||||
@api.onchange('parent_product_tmpl_id')
|
||||
def onchange_parent_product(self):
|
||||
if not self.parent_product_tmpl_id:
|
||||
return {}
|
||||
return {'domain': {'attribute_value_ids': [
|
||||
('id', 'in', self.parent_product_tmpl_id._get_valid_product_attribute_values().ids),
|
||||
('attribute_id.create_variant', '!=', 'no_variant')
|
||||
|
||||
@@ -605,7 +605,8 @@ class MrpProduction(models.Model):
|
||||
for move_raw in production.move_raw_ids:
|
||||
move_raw.write({
|
||||
'group_id': production.procurement_group_id.id,
|
||||
'unit_factor': move_raw.product_uom_qty / production.product_qty
|
||||
'unit_factor': move_raw.product_uom_qty / production.product_qty,
|
||||
'reference': self.name, # set reference when MO name is different than 'New'
|
||||
})
|
||||
production._generate_finished_moves()
|
||||
production.move_raw_ids._adjust_procure_method()
|
||||
@@ -825,8 +826,8 @@ class MrpProduction(models.Model):
|
||||
moves_to_finish = order.move_finished_ids.filtered(lambda x: x.state not in ('done','cancel'))
|
||||
moves_to_finish._action_done()
|
||||
order.action_assign()
|
||||
consume_move_lines = moves_to_do.mapped('active_move_line_ids')
|
||||
for moveline in moves_to_finish.mapped('active_move_line_ids'):
|
||||
consume_move_lines = moves_to_do.mapped('move_line_ids')
|
||||
for moveline in moves_to_finish.mapped('move_line_ids'):
|
||||
if moveline.product_id == order.product_id and moveline.move_id.has_tracking != 'none':
|
||||
if any([not ml.lot_produced_id for ml in consume_move_lines]):
|
||||
raise UserError(_('You can not consume without telling for which lot you consumed it'))
|
||||
|
||||
@@ -209,9 +209,6 @@ class MrpWorkorder(models.Model):
|
||||
'location_dest_id': by_product_move.location_dest_id.id,
|
||||
}
|
||||
|
||||
def _link_to_quality_check(self, old_move_line, new_move_line):
|
||||
return True
|
||||
|
||||
@api.multi
|
||||
def record_production(self):
|
||||
if not self:
|
||||
@@ -229,10 +226,6 @@ class MrpWorkorder(models.Model):
|
||||
# TODO: should be same as checking if for every workorder something has been done?
|
||||
if not self.next_work_order_id:
|
||||
self._update_finished_move()
|
||||
self.production_id.move_finished_ids.filtered(
|
||||
lambda move: move.product_id == self.product_id and
|
||||
move.state not in ('done', 'cancel')
|
||||
).workorder_id = self.id
|
||||
|
||||
# Transfer quantities from temporary to final move line or make them final
|
||||
self._update_raw_moves()
|
||||
|
||||
@@ -16,7 +16,6 @@ class StockMoveLine(models.Model):
|
||||
lot_produced_qty = fields.Float(
|
||||
'Quantity Finished Product', digits=dp.get_precision('Product Unit of Measure'),
|
||||
help="Informative, not used in matching")
|
||||
done_wo = fields.Boolean('Done for Work Order', default=True, help="Technical Field which is False when temporarily filled in in work order") # TDE FIXME: naming
|
||||
done_move = fields.Boolean('Move Done', related='move_id.is_done', readonly=False, store=True) # TDE FIXME: naming
|
||||
|
||||
def _get_similar_move_lines(self):
|
||||
@@ -24,12 +23,11 @@ class StockMoveLine(models.Model):
|
||||
if self.move_id.production_id:
|
||||
finished_moves = self.move_id.production_id.move_finished_ids
|
||||
finished_move_lines = finished_moves.mapped('move_line_ids')
|
||||
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
|
||||
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
|
||||
if self.move_id.raw_material_production_id:
|
||||
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
|
||||
raw_moves_lines = raw_moves.mapped('move_line_ids')
|
||||
raw_moves_lines |= self.move_id.active_move_line_ids
|
||||
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name) and ml.done_wo == self.done_wo)
|
||||
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
|
||||
return lines
|
||||
|
||||
def _reservation_is_updatable(self, quantity, reserved_quant):
|
||||
@@ -46,7 +44,7 @@ class StockMoveLine(models.Model):
|
||||
for move_line in self:
|
||||
if move_line.move_id.production_id and 'lot_id' in vals:
|
||||
move_line.production_id.move_raw_ids.mapped('move_line_ids')\
|
||||
.filtered(lambda r: r.done_wo and not r.done_move and r.lot_produced_id == move_line.lot_id)\
|
||||
.filtered(lambda r: not r.done_move and r.lot_produced_id == move_line.lot_id)\
|
||||
.write({'lot_produced_id': vals['lot_id']})
|
||||
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
|
||||
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
|
||||
@@ -71,7 +69,6 @@ class StockMove(models.Model):
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', 'Work Order To Consume')
|
||||
# Quantities to process, in normalized UoMs
|
||||
active_move_line_ids = fields.One2many('stock.move.line', 'move_id', domain=[('done_wo', '=', True)], string='Lots')
|
||||
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line')
|
||||
unit_factor = fields.Float('Unit Factor')
|
||||
is_done = fields.Boolean(
|
||||
@@ -89,10 +86,6 @@ class StockMove(models.Model):
|
||||
.filtered(lambda ml: ml.qty_done == 0.0)\
|
||||
.write({'move_id': new_move, 'product_uom_qty': 0})
|
||||
|
||||
@api.depends('active_move_line_ids.qty_done', 'active_move_line_ids.product_uom_id')
|
||||
def _compute_done_quantity(self):
|
||||
super(StockMove, self)._compute_done_quantity()
|
||||
|
||||
@api.depends('raw_material_production_id.move_finished_ids.move_line_ids.lot_id')
|
||||
def _compute_order_finished_lot_ids(self):
|
||||
for move in self:
|
||||
@@ -116,13 +109,6 @@ class StockMove(models.Model):
|
||||
if move.raw_material_production_id:
|
||||
move.is_locked = move.raw_material_production_id.is_locked
|
||||
|
||||
def _get_move_lines(self):
|
||||
self.ensure_one()
|
||||
if self.raw_material_production_id:
|
||||
return self.active_move_line_ids
|
||||
else:
|
||||
return super(StockMove, self)._get_move_lines()
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_is_done(self):
|
||||
for move in self:
|
||||
|
||||
@@ -90,6 +90,7 @@ class StockRule(models.Model):
|
||||
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
|
||||
'company_id': company_id.id,
|
||||
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
|
||||
'user_id': False,
|
||||
}
|
||||
|
||||
def _get_date_planned(self, product_id, company_id, values):
|
||||
|
||||
@@ -168,9 +168,21 @@ class StockWarehouse(models.Model):
|
||||
values = super(StockWarehouse, self)._get_locations_values(vals)
|
||||
def_values = self.default_get(['manufacture_steps'])
|
||||
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
|
||||
code = vals.get('code') or self.code
|
||||
code = code.replace(' ', '').upper()
|
||||
values.update({
|
||||
'pbm_loc_id': {'name': _('Pre-Production'), 'active': manufacture_steps in ('pbm', 'pbm_sam'), 'usage': 'internal'},
|
||||
'sam_loc_id': {'name': _('Post-Production'), 'active': manufacture_steps == 'pbm_sam', 'usage': 'internal'},
|
||||
'pbm_loc_id': {
|
||||
'name': _('Pre-Production'),
|
||||
'active': manufacture_steps in ('pbm', 'pbm_sam'),
|
||||
'usage': 'internal',
|
||||
'barcode': code + '-PREPRODUCTION'
|
||||
},
|
||||
'sam_loc_id': {
|
||||
'name': _('Post-Production'),
|
||||
'active': manufacture_steps == 'pbm_sam',
|
||||
'usage': 'internal',
|
||||
'barcode': code + '-POSTPRODUCTION'
|
||||
},
|
||||
})
|
||||
return values
|
||||
|
||||
|
||||
@@ -676,6 +676,32 @@ class TestMrpOrder(TestMrpCommon):
|
||||
self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.')
|
||||
mo.button_mark_done()
|
||||
|
||||
def test_product_produce_6(self):
|
||||
""" Build some final products and change the quantity to produce
|
||||
directly in the wizard. The component line should be updated .
|
||||
"""
|
||||
self.stock_location = self.env.ref('stock.stock_location_stock')
|
||||
mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1)
|
||||
self.assertEqual(len(mo), 1, 'MO should have been created')
|
||||
|
||||
mo.action_assign()
|
||||
|
||||
produce_form = Form(self.env['mrp.product.produce'].with_context({
|
||||
'active_id': mo.id,
|
||||
'active_ids': [mo.id],
|
||||
}))
|
||||
produce_form.qty_producing = 3
|
||||
self.assertEqual(len(produce_form.workorder_line_ids._records), 4, 'Update the produce quantity should change the components quantity.')
|
||||
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 15, 'Update the produce quantity should change the components quantity.')
|
||||
produce_form.qty_producing = 4
|
||||
self.assertEqual(len(produce_form.workorder_line_ids._records), 6, 'Update the produce quantity should change the components quantity.')
|
||||
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 20, 'Update the produce quantity should change the components quantity.')
|
||||
produce_form.qty_producing = 1
|
||||
self.assertEqual(len(produce_form.workorder_line_ids._records), 2, 'Update the produce quantity should change the components quantity.')
|
||||
self.assertEqual(sum([x['qty_done'] for x in produce_form.workorder_line_ids._records]), 5, 'Update the produce quantity should change the components quantity.')
|
||||
produce_wizard = produce_form.save()
|
||||
produce_wizard.do_produce()
|
||||
|
||||
def test_product_produce_uom(self):
|
||||
plastic_laminate = self.env.ref('mrp.product_product_plastic_laminate')
|
||||
bom = self.env.ref('mrp.mrp_bom_plastic_laminate')
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<span class="float-right text-right">
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'done': 'success'}}"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<field name="finished_lots_exist" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="active_move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
|
||||
<field name="move_line_ids" attrs="{'invisible': [('parent.state', '=', 'draft')], 'readonly': [('is_locked', '=', True)]}" context="{'default_workorder_id': workorder_id, 'default_product_uom_id': product_uom, 'default_product_id': product_id, 'default_location_id': location_id, 'default_location_dest_id': location_dest_id, 'default_production_id': production_id or raw_material_production_id}">
|
||||
<tree editable="bottom" decoration-success="product_uom_qty==qty_done" decoration-danger="(product_uom_qty > 0) and (qty_done>product_uom_qty)">
|
||||
<field name="lot_id" attrs="{'column_invisible': [('parent.has_tracking', '=', 'none')]}" domain="[('product_id', '=', parent.product_id)]" context="{'default_product_id': parent.product_id}"/>
|
||||
<field name="lot_produced_id" options="{'no_open': True, 'no_create': True}" domain="[('id', 'in', parent.order_finished_lot_ids)]" invisible="not context.get('final_lots')"/>
|
||||
@@ -61,7 +61,6 @@
|
||||
<field name="product_uom_id" invisible="1"/>
|
||||
<field name="location_id" invisible="1"/>
|
||||
<field name="location_dest_id" invisible="1"/>
|
||||
<field name="done_wo" invisible="1"/>
|
||||
<field name="production_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
@@ -54,8 +55,16 @@ class PaymentAcquirerAuthorize(models.Model):
|
||||
values['x_fp_sequence'],
|
||||
values['x_fp_timestamp'],
|
||||
values['x_amount'],
|
||||
values['x_currency_code']])
|
||||
return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
|
||||
values['x_currency_code']]).encode('utf-8')
|
||||
|
||||
# [BACKWARD COMPATIBILITY] Check that the merchant did update his transaction
|
||||
# key to signature key (end of MD5 support from Authorize.net)
|
||||
# The signature key is now '128-character hexadecimal format', while the
|
||||
# transaction key was only 16-character.
|
||||
if len(values['x_trans_key']) == 128:
|
||||
return hmac.new(values['x_trans_key'].decode("hex").encode('utf-8'), data, hashlib.sha512).hexdigest().upper()
|
||||
else:
|
||||
return hmac.new(values['x_trans_key'].encode('utf-8'), data, hashlib.md5).hexdigest()
|
||||
|
||||
@api.multi
|
||||
def authorize_form_generate_values(self, values):
|
||||
@@ -172,7 +181,7 @@ class TxAuthorize(models.Model):
|
||||
def _authorize_form_get_tx_from_data(self, data):
|
||||
""" Given a data dict coming from authorize, verify it and find the related
|
||||
transaction record. """
|
||||
reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_MD5_Hash')
|
||||
reference, trans_id, fingerprint = data.get('x_invoice_num'), data.get('x_trans_id'), data.get('x_SHA2_Hash') or data.get('x_MD5_Hash')
|
||||
if not reference or not trans_id or not fingerprint:
|
||||
error_msg = _('Authorize: received data with missing reference (%s) or trans_id (%s) or fingerprint (%s)') % (reference, trans_id, fingerprint)
|
||||
_logger.info(error_msg)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
from werkzeug import urls
|
||||
from lxml import objectify
|
||||
@@ -29,15 +27,6 @@ class AuthorizeCommon(PaymentAcquirerCommon):
|
||||
@odoo.tests.tagged('post_install', '-at_install', '-standard', 'external')
|
||||
class AuthorizeForm(AuthorizeCommon):
|
||||
|
||||
def _authorize_generate_hashing(self, values):
|
||||
data = '^'.join([
|
||||
values['x_login'],
|
||||
values['x_fp_sequence'],
|
||||
values['x_fp_timestamp'],
|
||||
values['x_amount'],
|
||||
]) + '^'
|
||||
return hmac.new(values['x_trans_key'].encode('utf-8'), data.encode('utf-8'), hashlib.md5).hexdigest()
|
||||
|
||||
def test_10_Authorize_form_render(self):
|
||||
self.assertEqual(self.authorize.environment, 'test', 'test without test environment')
|
||||
|
||||
@@ -82,7 +71,7 @@ class AuthorizeForm(AuthorizeCommon):
|
||||
'x_ship_to_state': None,
|
||||
}
|
||||
|
||||
form_values['x_fp_hash'] = self._authorize_generate_hashing(form_values)
|
||||
form_values['x_fp_hash'] = self.env['payment.acquirer']._authorize_generate_hashing(form_values)
|
||||
# render the button
|
||||
res = self.authorize.render('SO004', 56.16, self.currency_usd.id, values=self.buyer_values)
|
||||
# check form result
|
||||
@@ -109,7 +98,9 @@ class AuthorizeForm(AuthorizeCommon):
|
||||
# typical data posted by authorize after client has successfully paid
|
||||
authorize_post_data = {
|
||||
'return_url': u'/shop/payment/validate',
|
||||
# x_MD5_Hash will be empty starting the 28th March 2019
|
||||
'x_MD5_Hash': u'7934485E1C105940BE854208D10FAB4F',
|
||||
'x_SHA2_Hash': u'7D3AC844BE8CA3F649AB885A90D22CFE35B850338EC91D1A5ADD819A85FF948A3D777334A18CDE36821DC8F2B42A6E1950C1FF96B52B60F23201483A656195FB',
|
||||
'x_account_number': u'XXXX0027',
|
||||
'x_address': u'Huge Street 2/543',
|
||||
'x_amount': u'320.00',
|
||||
|
||||
@@ -82,7 +82,7 @@ class PaymentTransactionPayulatam(models.Model):
|
||||
if not reference or not txnid or not sign:
|
||||
raise ValidationError(_('PayUlatam: received data with missing reference (%s) or transaction id (%s) or sign (%s)') % (reference, txnid, sign))
|
||||
|
||||
transaction = self.search([('acquirer_reference', '=', reference)])
|
||||
transaction = self.search([('reference', '=', reference)])
|
||||
|
||||
if not transaction:
|
||||
error_msg = (_('PayUlatam: received data for reference %s; no order found') % (reference))
|
||||
|
||||
@@ -121,10 +121,9 @@ class PayUlatamForm(PayUlatamCommon):
|
||||
'amount': 0.01,
|
||||
'acquirer_id': self.payulatam.id,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': 'test_ref_2',
|
||||
'reference': 'test_ref_10',
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id,
|
||||
'acquirer_reference': 'test_ref_10',
|
||||
'partner_id': self.buyer_id})
|
||||
|
||||
# validate transaction
|
||||
|
||||
@@ -1479,6 +1479,8 @@ td {
|
||||
body {
|
||||
margin: 0;
|
||||
color: white !important;
|
||||
/* avoid black background if backgrounds are printed */
|
||||
background: initial;
|
||||
}
|
||||
.oe_leftbar,
|
||||
.oe_loading,
|
||||
|
||||
@@ -1326,7 +1326,7 @@ var ClientListScreenWidget = ScreenWidget.extend({
|
||||
self.save_client_details(partner);
|
||||
});
|
||||
};
|
||||
this.reload_partners().then( function() {
|
||||
return this.reload_partners().then( function() {
|
||||
var partner = self.pos.db.get_partner_by_id(partner_id);
|
||||
if (partner) {
|
||||
self.new_client = partner;
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<span class="float-right text-right">
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default',
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default',
|
||||
'invoiced': 'success', 'cancel': 'danger'}}"/>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<div class="o_kanban_record_headings">
|
||||
<strong class="o_kanban_record_title"><span><field name="config_id"/></span></strong>
|
||||
</div>
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'opening_control': 'default',
|
||||
<field name="state" widget="label_selection" options="{'classes': {'opening_control': 'default',
|
||||
'opened': 'success', 'closing_control': 'warning', 'closed': 'warning'}}"/>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
|
||||
@@ -55,6 +55,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
|
||||
'url': get_url(page),
|
||||
'num': page
|
||||
},
|
||||
"page_first": {
|
||||
'url': get_url(1),
|
||||
'num': 1
|
||||
},
|
||||
"page_start": {
|
||||
'url': get_url(pmin),
|
||||
'num': pmin
|
||||
@@ -71,6 +75,10 @@ def pager(url, total, page=1, step=30, scope=5, url_args=None):
|
||||
'url': get_url(pmax),
|
||||
'num': pmax
|
||||
},
|
||||
"page_last": {
|
||||
'url': get_url(page_count),
|
||||
'num': page_count
|
||||
},
|
||||
"pages": [
|
||||
{'url': get_url(page_num), 'num': page_num} for page_num in range(pmin, pmax+1)
|
||||
]
|
||||
|
||||
@@ -10,9 +10,9 @@ class PortalShare(models.TransientModel):
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super(PortalShare, self).default_get(fields)
|
||||
record = self.env[result['res_model']].browse(result['res_id'])
|
||||
result['res_model'] = self._context.get('active_model')
|
||||
result['res_id'] = self._context.get('active_id')
|
||||
record = self.env[result['res_model']].browse(result['res_id'])
|
||||
result['share_link'] = record.get_base_url() + record._get_share_url(redirect=True)
|
||||
return result
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class PortalWizard(models.TransientModel):
|
||||
contact_ids = set()
|
||||
user_changes = []
|
||||
for partner in self.env['res.partner'].sudo().browse(partner_ids):
|
||||
contact_partners = partner.child_ids or [partner]
|
||||
contact_partners = partner.child_ids | partner
|
||||
for contact in contact_partners:
|
||||
# make sure that each contact appears at most once in the list
|
||||
if contact.id not in contact_ids:
|
||||
|
||||
@@ -301,6 +301,8 @@ class ProductProduct(models.Model):
|
||||
# When a unique variant is created from tmpl then the standard price is set by _set_standard_price
|
||||
if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1):
|
||||
product._set_standard_price(vals.get('standard_price') or 0.0)
|
||||
# `_get_variant_id_for_combination` depends on existing variants
|
||||
self.clear_caches()
|
||||
return products
|
||||
|
||||
@api.multi
|
||||
@@ -309,6 +311,13 @@ class ProductProduct(models.Model):
|
||||
res = super(ProductProduct, self).write(values)
|
||||
if 'standard_price' in values:
|
||||
self._set_standard_price(values['standard_price'])
|
||||
if 'attribute_value_ids' in values:
|
||||
# `_get_variant_id_for_combination` depends on `attribute_value_ids`
|
||||
self.clear_caches()
|
||||
if 'active' in values:
|
||||
# prefetched o2m have to be reloaded (because of active_test)
|
||||
# (eg. product.template: product_variant_ids)
|
||||
self.invalidate_cache()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
@@ -329,6 +338,8 @@ class ProductProduct(models.Model):
|
||||
# delete templates after calling super, as deleting template could lead to deleting
|
||||
# products due to ondelete='cascade'
|
||||
unlink_templates.unlink()
|
||||
# `_get_variant_id_for_combination` depends on existing variants
|
||||
self.clear_caches()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
@@ -617,6 +628,10 @@ class ProductProduct(models.Model):
|
||||
We must make sure that all attributes are used to take into account the case where
|
||||
attributes would be added to the template.
|
||||
|
||||
This method does not check if the combination is possible, it just
|
||||
checks if it has valid attributes and values. A possible combination
|
||||
is always valid, but a valid combination is not always possible.
|
||||
|
||||
:param valid_attributes: a recordset of product.attribute
|
||||
:param valid_values: a recordset of product.attribute.value
|
||||
:return: True if the attibutes and values are correct, False instead
|
||||
|
||||
@@ -44,7 +44,13 @@ class ProductAttribute(models.Model):
|
||||
if products:
|
||||
message = ', '.join(products.mapped('name'))
|
||||
raise UserError(_('You are trying to change the type of an attribute value still referenced on at least one product template: %s') % message)
|
||||
return super(ProductAttribute, self).write(vals)
|
||||
invalidate_cache = 'sequence' in vals and any(record.sequence != vals['sequence'] for record in self)
|
||||
res = super(ProductAttribute, self).write(vals)
|
||||
if invalidate_cache:
|
||||
# prefetched o2m have to be resequenced
|
||||
# (eg. product.template: attribute_line_ids)
|
||||
self.invalidate_cache()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def _get_related_product_templates(self):
|
||||
@@ -78,6 +84,16 @@ class ProductAttributeValue(models.Model):
|
||||
def _variant_name(self, variable_attributes):
|
||||
return ", ".join([v.name for v in self if v.attribute_id in variable_attributes])
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
invalidate_cache = 'sequence' in values and any(record.sequence != values['sequence'] for record in self)
|
||||
res = super(ProductAttributeValue, self).write(values)
|
||||
if invalidate_cache:
|
||||
# prefetched o2m have to be resequenced
|
||||
# (eg. product.template.attribute.line: value_ids)
|
||||
self.invalidate_cache()
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
linked_products = self._get_related_product_templates()
|
||||
@@ -136,8 +152,8 @@ class ProductTemplateAttributeLine(models.Model):
|
||||
for product_template_attribute_line in self:
|
||||
product_template_attribute_line.product_template_value_ids = self.env['product.template.attribute.value'].search([
|
||||
('product_tmpl_id', 'in', product_template_attribute_line.product_tmpl_id.ids),
|
||||
('product_attribute_value_id.attribute_id', 'in', product_template_attribute_line.value_ids.mapped('attribute_id').ids)]
|
||||
).sorted(lambda product_product_attribute: product_product_attribute.sequence)
|
||||
('product_attribute_value_id', 'in', product_template_attribute_line.value_ids.ids)]
|
||||
)
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
@@ -246,9 +262,9 @@ class ProductTemplateAttributeExclusion(models.Model):
|
||||
_description = 'Product Template Attribute Exclusion'
|
||||
|
||||
product_template_attribute_value_id = fields.Many2one(
|
||||
'product.template.attribute.value', string="Attribute Value", ondelete='cascade')
|
||||
'product.template.attribute.value', string="Attribute Value", ondelete='cascade', index=True)
|
||||
product_tmpl_id = fields.Many2one(
|
||||
'product.template', string='Product Template', ondelete='cascade', required=True)
|
||||
'product.template', string='Product Template', ondelete='cascade', required=True, index=True)
|
||||
value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', relation="product_attr_exclusion_value_ids_rel",
|
||||
string='Attribute Values', domain="[('product_tmpl_id', '=', product_tmpl_id)]")
|
||||
|
||||
@@ -246,7 +246,11 @@ class Pricelist(models.Model):
|
||||
break
|
||||
# Final price conversion into pricelist currency
|
||||
if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist':
|
||||
price = product.currency_id._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
|
||||
if suitable_rule.base == 'standard_price':
|
||||
cur = product.cost_currency_id
|
||||
else:
|
||||
cur = product.currency_id
|
||||
price = cur._convert(price, self.currency_id, self.env.user.company_id, date, round=False)
|
||||
|
||||
results[product.id] = (price, suitable_rule and suitable_rule.id or False)
|
||||
|
||||
@@ -494,3 +498,11 @@ class PricelistItem(models.Model):
|
||||
'price_min_margin': 0.0,
|
||||
'price_max_margin': 0.0,
|
||||
})
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
res = super(PricelistItem, self).write(values)
|
||||
# When the pricelist changes we need the product.template price
|
||||
# to be invalided and recomputed.
|
||||
self.invalidate_cache()
|
||||
return res
|
||||
|
||||
@@ -62,6 +62,8 @@ class ProductTemplate(models.Model):
|
||||
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', 'Currency', compute='_compute_currency_id')
|
||||
cost_currency_id = fields.Many2one(
|
||||
'res.currency', 'Cost Currency', compute='_compute_cost_currency_id')
|
||||
|
||||
# price fields
|
||||
# price: total template price, context dependent (partner, pricelist, quantity)
|
||||
@@ -119,6 +121,25 @@ class ProductTemplate(models.Model):
|
||||
|
||||
is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant')
|
||||
attribute_line_ids = fields.One2many('product.template.attribute.line', 'product_tmpl_id', 'Product Attributes')
|
||||
|
||||
valid_product_template_attribute_line_ids = fields.Many2many('product.template.attribute.line',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attribute Lines', help="Technical compute")
|
||||
valid_product_attribute_value_ids = fields.Many2many('product.attribute.value',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attribute Values', help="Technical compute")
|
||||
valid_product_attribute_ids = fields.Many2many('product.attribute',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attributes', help="Technical compute")
|
||||
# wnva = without no_variant attributes
|
||||
valid_product_template_attribute_line_wnva_ids = fields.Many2many('product.template.attribute.line',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attribute Lines Without No Variant Attributes', help="Technical compute")
|
||||
valid_product_attribute_value_wnva_ids = fields.Many2many('product.attribute.value',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attribute Values Without No Variant Attributes', help="Technical compute")
|
||||
valid_product_attribute_wnva_ids = fields.Many2many('product.attribute',
|
||||
compute="_compute_valid_attributes", string='Valid Product Attributes Without No Variant Attributes', help="Technical compute")
|
||||
valid_archived_variant_ids = fields.Many2many('product.product',
|
||||
compute="_compute_valid_attributes", string='Valid Archived Variants', help="Technical compute")
|
||||
valid_existing_variant_ids = fields.Many2many('product.product',
|
||||
compute="_compute_valid_existing_variant_ids", string='Valid Existing Variants', help="Technical compute")
|
||||
|
||||
product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True)
|
||||
# performance: product_variant_id provides prefetching on the first product variant only
|
||||
product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id')
|
||||
@@ -145,6 +166,10 @@ class ProductTemplate(models.Model):
|
||||
for template in self:
|
||||
template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id
|
||||
|
||||
def _compute_cost_currency_id(self):
|
||||
for template in self:
|
||||
template.cost_currency_id = self.env.user.company_id.currency_id.id
|
||||
|
||||
@api.multi
|
||||
def _compute_template_price(self):
|
||||
prices = self._compute_template_price_no_inverse()
|
||||
@@ -464,7 +489,7 @@ class ProductTemplate(models.Model):
|
||||
# Iterator containing all possible `product.attribute.value` combination
|
||||
# The iterator is used to avoid MemoryError in case of a huge number of combination.
|
||||
all_variants = itertools.product(*(
|
||||
line.value_ids.ids for line in tmpl_id._get_valid_product_template_attribute_lines()._without_no_variant_attributes()
|
||||
line.value_ids.ids for line in tmpl_id.valid_product_template_attribute_line_wnva_ids
|
||||
))
|
||||
# Set containing existing `product.attribute.value` combination
|
||||
existing_variants = {
|
||||
@@ -490,8 +515,8 @@ class ProductTemplate(models.Model):
|
||||
# should be activated
|
||||
# - if the product does not have valid attributes or attribute values, it should be
|
||||
# deleted
|
||||
valid_value_ids = tmpl_id._get_valid_product_attribute_values()._without_no_variant_attributes()
|
||||
valid_attribute_ids = tmpl_id._get_valid_product_attributes()._without_no_variant_attributes()
|
||||
valid_value_ids = tmpl_id.valid_product_attribute_value_wnva_ids
|
||||
valid_attribute_ids = tmpl_id.valid_product_attribute_wnva_ids
|
||||
for product_id in tmpl_id.product_variant_ids:
|
||||
if product_id._has_valid_attributes(valid_attribute_ids, valid_value_ids):
|
||||
if not product_id.active:
|
||||
@@ -523,6 +548,10 @@ class ProductTemplate(models.Model):
|
||||
# This is the case from existing stock reordering rules.
|
||||
variant.write({'active': False})
|
||||
|
||||
# prefetched o2m have to be reloaded (because of active_test)
|
||||
# (eg. product.template: product_variant_ids)
|
||||
# We can't rely on existing invalidate_cache because of the savepoint.
|
||||
self.invalidate_cache()
|
||||
return True
|
||||
|
||||
def has_dynamic_attributes(self):
|
||||
@@ -536,7 +565,7 @@ class ProductTemplate(models.Model):
|
||||
return any(a.create_variant == 'dynamic' for a in self._get_valid_product_attributes())
|
||||
|
||||
@api.multi
|
||||
def _get_valid_product_template_attribute_lines(self):
|
||||
def _compute_valid_attributes(self):
|
||||
"""A product template attribute line is considered valid if it has at
|
||||
least one possible value.
|
||||
|
||||
@@ -545,42 +574,68 @@ class ProductTemplate(models.Model):
|
||||
value to input), indeed single value attributes can be used to filter
|
||||
products among others based on that attribute/value.
|
||||
|
||||
This method is necessary because it was previously possible to save a
|
||||
line without any value on it, so the database might not be consistent in
|
||||
that regard.
|
||||
A product attribute value is considered valid for a template if it is
|
||||
defined on a product template attribute line.
|
||||
|
||||
:return: all the valid product template attribute lines of this template
|
||||
:rtype: recordset `product.template.attribute.line`
|
||||
A product attribute is considered valid for a template if it
|
||||
has at least one possible value set on the template.
|
||||
|
||||
For what is considered an archived variant, see `_has_valid_attributes`.
|
||||
"""
|
||||
archived_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', False)])
|
||||
|
||||
# prefetch
|
||||
self.mapped('attribute_line_ids.value_ids.id')
|
||||
self.mapped('attribute_line_ids.attribute_id.create_variant')
|
||||
|
||||
for record in self:
|
||||
record.valid_product_template_attribute_line_ids = record.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
|
||||
record.valid_product_template_attribute_line_wnva_ids = record.valid_product_template_attribute_line_ids._without_no_variant_attributes()
|
||||
|
||||
record.valid_product_attribute_value_ids = record.valid_product_template_attribute_line_ids.mapped('value_ids')
|
||||
record.valid_product_attribute_value_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('value_ids')
|
||||
|
||||
record.valid_product_attribute_ids = record.valid_product_template_attribute_line_ids.mapped('attribute_id')
|
||||
record.valid_product_attribute_wnva_ids = record.valid_product_template_attribute_line_wnva_ids.mapped('attribute_id')
|
||||
|
||||
valid_value_ids = record.valid_product_attribute_value_wnva_ids
|
||||
valid_attribute_ids = record.valid_product_attribute_wnva_ids
|
||||
|
||||
record.valid_archived_variant_ids = archived_variants.filtered(
|
||||
lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
|
||||
)
|
||||
|
||||
@api.multi
|
||||
def _compute_valid_existing_variant_ids(self):
|
||||
"""This compute is done outside of `_compute_valid_attributes` because
|
||||
it is often not needed, and it can be very bad on performance."""
|
||||
existing_variants = self.env['product.product'].search([('product_tmpl_id', 'in', self.ids), ('active', '=', True)])
|
||||
for record in self:
|
||||
|
||||
valid_value_ids = record.valid_product_attribute_value_wnva_ids
|
||||
valid_attribute_ids = record.valid_product_attribute_wnva_ids
|
||||
|
||||
record.valid_existing_variant_ids = existing_variants.filtered(
|
||||
lambda v: v.product_tmpl_id == record and v._has_valid_attributes(valid_attribute_ids, valid_value_ids)
|
||||
)
|
||||
|
||||
@api.multi
|
||||
def _get_valid_product_template_attribute_lines(self):
|
||||
"""deprecated, use `valid_product_template_attribute_line_ids`"""
|
||||
self.ensure_one()
|
||||
return self.attribute_line_ids.filtered(lambda ptal: ptal.value_ids)
|
||||
return self.valid_product_template_attribute_line_ids
|
||||
|
||||
@api.multi
|
||||
def _get_valid_product_attributes(self):
|
||||
"""A product attribute is considered valid for a template if it
|
||||
has at least one possible value set on the template.
|
||||
|
||||
See `_get_valid_product_template_attribute_lines`.
|
||||
|
||||
:return: all the valid product attributes of this template
|
||||
:rtype: recordset `product.attribute`
|
||||
"""
|
||||
"""deprecated, use `valid_product_attribute_ids`"""
|
||||
self.ensure_one()
|
||||
product_attributes = self.env['product.attribute']
|
||||
for ptal in self._get_valid_product_template_attribute_lines():
|
||||
product_attributes |= ptal.attribute_id
|
||||
return product_attributes
|
||||
return self.valid_product_attribute_ids
|
||||
|
||||
@api.multi
|
||||
def _get_valid_product_attribute_values(self):
|
||||
"""A product attribute value is considered valid for a template if it is
|
||||
defined on a product template attribute line.
|
||||
|
||||
:return: all the valid product attribute values of this template
|
||||
:rtype: recordset `product.attribute.value`
|
||||
"""
|
||||
"""deprecated, use `valid_product_attribute_value_ids`"""
|
||||
self.ensure_one()
|
||||
return self._get_valid_product_template_attribute_lines().mapped('value_ids')
|
||||
return self.valid_product_attribute_value_ids
|
||||
|
||||
@api.multi
|
||||
def _get_possible_variants(self, parent_combination=None):
|
||||
@@ -589,6 +644,13 @@ class ProductTemplate(models.Model):
|
||||
For dynamic attributes, it will only return the variants that have been
|
||||
created already. For no_variant attributes, it will return an empty
|
||||
recordset because the variants themselves are not a full combination.
|
||||
If there are a lot of variants, this method might be slow. Even if there
|
||||
aren't too many variants, for performance reasons, do not call this
|
||||
method in a loop over the product templates.
|
||||
|
||||
Therefore this method has a very restricted reasonable use case and you
|
||||
should strongly consider doing things differently if you consider using
|
||||
this method.
|
||||
|
||||
:param parent_combination: combination from which `self` is an
|
||||
optional or accessory product.
|
||||
@@ -688,19 +750,8 @@ class ProductTemplate(models.Model):
|
||||
|
||||
Array, each element is an array with ids of an archived combination.
|
||||
"""
|
||||
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
|
||||
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
|
||||
|
||||
# Search only among those having the right set of attributes.
|
||||
domain = [('product_tmpl_id', '=', self.id), ('active', '=', False)]
|
||||
for pa in valid_attribute_ids:
|
||||
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
|
||||
archived_variants = self.env['product.product'].search(domain)
|
||||
|
||||
archived_variants = archived_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
|
||||
|
||||
return [archived_variant.product_template_attribute_value_ids.ids
|
||||
for archived_variant in archived_variants]
|
||||
for archived_variant in self.valid_archived_variant_ids]
|
||||
|
||||
@api.multi
|
||||
def _get_existing_combinations(self):
|
||||
@@ -712,19 +763,8 @@ class ProductTemplate(models.Model):
|
||||
|
||||
Array, each element is an array with ids of an existing combination.
|
||||
"""
|
||||
valid_value_ids = self._get_valid_product_attribute_values()._without_no_variant_attributes()
|
||||
valid_attribute_ids = self._get_valid_product_attributes()._without_no_variant_attributes()
|
||||
|
||||
# Search only among those having the right set of attributes.
|
||||
domain = [('product_tmpl_id', '=', self.id), ('active', '=', True)]
|
||||
for pa in valid_attribute_ids:
|
||||
domain = expression.AND([[('attribute_value_ids.attribute_id.id', '=', pa.id)], domain])
|
||||
existing_variants = self.env['product.product'].search(domain)
|
||||
|
||||
existing_variants = existing_variants.filtered(lambda v: v._has_valid_attributes(valid_attribute_ids, valid_value_ids))
|
||||
|
||||
return [variant.product_template_attribute_value_ids.ids
|
||||
for variant in existing_variants]
|
||||
for variant in self.valid_existing_variant_ids]
|
||||
|
||||
@api.multi
|
||||
def _get_no_variant_product_template_attribute_values(self):
|
||||
@@ -760,17 +800,20 @@ class ProductTemplate(models.Model):
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if len(combination) != len(self._get_valid_product_template_attribute_lines()):
|
||||
if len(combination) != len(self.valid_product_template_attribute_line_ids):
|
||||
# number of attribute values passed is different than the
|
||||
# configuration of attributes on the template
|
||||
return False
|
||||
|
||||
if self._get_valid_product_attributes() != combination.mapped('attribute_id'):
|
||||
if self.valid_product_attribute_ids != combination.mapped('attribute_id'):
|
||||
# combination has different attributes than the ones configured on the template
|
||||
return False
|
||||
|
||||
variant = self._get_variant_for_combination(combination)
|
||||
if not self.has_dynamic_attributes() and not variant:
|
||||
if self.valid_product_attribute_value_ids < combination.mapped('product_attribute_value_id'):
|
||||
# combination has different values than the ones configured on the template
|
||||
return False
|
||||
|
||||
if not self.has_dynamic_attributes() and not self._get_variant_for_combination(combination):
|
||||
# the variant has been deleted
|
||||
return False
|
||||
|
||||
@@ -812,40 +855,64 @@ class ProductTemplate(models.Model):
|
||||
self.ensure_one()
|
||||
|
||||
filtered_combination = combination._without_no_variant_attributes()
|
||||
attribute_values = filtered_combination.mapped('product_attribute_value_id')
|
||||
return self.env['product.product'].browse(self._get_variant_id_for_combination(attribute_values))
|
||||
|
||||
@api.multi
|
||||
@tools.ormcache('self', 'attribute_values')
|
||||
def _get_variant_id_for_combination(self, attribute_values):
|
||||
"""See `_get_variant_for_combination`. This method returns an ID
|
||||
so it can be cached."""
|
||||
self.ensure_one()
|
||||
# If there are a lot of variants on this template, it is much faster to
|
||||
# build a query than using the existing o2m.
|
||||
domain = [('product_tmpl_id', '=', self.id)]
|
||||
for ptav in filtered_combination:
|
||||
domain = expression.AND([[('attribute_value_ids.id', '=', ptav.product_attribute_value_id.id)], domain])
|
||||
res = self.env['product.product'].search(domain)
|
||||
for pav in attribute_values:
|
||||
domain = expression.AND([[('attribute_value_ids', 'in', pav.id)], domain])
|
||||
|
||||
res = self.env['product.product'].with_context(active_test=False).search(domain)
|
||||
|
||||
# The domain above is checking for the `product.attribute.value`, but we
|
||||
# need to make sure it's the same `product.template.attribute.value`.
|
||||
# Also there should theorically be only 0 or 1 but an existing database
|
||||
# might not be consistent so we need to make sure to take max 1.
|
||||
return res.filtered(
|
||||
lambda v: v.product_template_attribute_value_ids == filtered_combination
|
||||
)[:1]
|
||||
lambda v: v.attribute_value_ids == attribute_values
|
||||
)[:1].id
|
||||
|
||||
@api.multi
|
||||
@tools.ormcache('self')
|
||||
def _get_first_possible_variant_id(self):
|
||||
"""See `_create_first_product_variant`. This method returns an ID
|
||||
so it can be cached."""
|
||||
self.ensure_one()
|
||||
return self._create_first_product_variant().id
|
||||
|
||||
@api.multi
|
||||
def _get_first_possible_combination(self, parent_combination=None, necessary_values=None):
|
||||
"""
|
||||
Iterate the attributes and values in order and stop at the first
|
||||
combination of values that is possible.
|
||||
"""See `_get_possible_combinations` (one iteration).
|
||||
|
||||
When encountering an impossible combination, try to change the
|
||||
value of latest attributes first.
|
||||
|
||||
Ignore attributes that have no values.
|
||||
|
||||
Note this method return the same result (empty recordset) if no
|
||||
This method return the same result (empty recordset) if no
|
||||
combination is possible at all which would be considered a negative
|
||||
result, or if there are no attribute lines on the template in which
|
||||
case the "empty combination" is actually a possible combination.
|
||||
Therefore the result of this method when empty should be tested
|
||||
with `_is_combination_possible` if it's important to know if the
|
||||
resulting empty combination is actually possible or not.
|
||||
"""
|
||||
return next(self._get_possible_combinations(parent_combination, necessary_values), self.env['product.template.attribute.value'])
|
||||
|
||||
@api.multi
|
||||
def _get_possible_combinations(self, parent_combination=None, necessary_values=None):
|
||||
"""Generator returning combinations that are possible, following the
|
||||
sequence of attributes and values.
|
||||
|
||||
See `_is_combination_possible` for what is a possible combination.
|
||||
|
||||
When encountering an impossible combination, try to change the value
|
||||
of attributes by starting with the further regarding their sequences.
|
||||
|
||||
Ignore attributes that have no values.
|
||||
|
||||
:param parent_combination: combination from which `self` is an
|
||||
optional or accessory product.
|
||||
@@ -854,63 +921,81 @@ class ProductTemplate(models.Model):
|
||||
:param necessary_values: values that must be in the returned combination
|
||||
:type necessary_values: recordset of `product.template.attribute.value`
|
||||
|
||||
:return: the first possible combination found, or empty if none possible
|
||||
:rtype: recordset of `product.template.attribute.value`
|
||||
:return: the possible combinations
|
||||
:rtype: generator of recordset of `product.template.attribute.value`
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
if not self.active:
|
||||
return self.env['product.template.attribute.value']
|
||||
return _("The product template is archived so no combination is possible.")
|
||||
|
||||
attribute_lines = self._get_valid_product_template_attribute_lines()
|
||||
ptal_stack = [self._get_valid_product_template_attribute_lines()]
|
||||
combination_stack = [self.env['product.template.attribute.value']]
|
||||
|
||||
def iterate_attribute_lines(attribute_lines, combination):
|
||||
"""
|
||||
:param attribute_lines: recordset of product.template.attribute.line
|
||||
that are still to iterate
|
||||
:param combination: recordset of product.template.attribute.value
|
||||
that have to be tested for possibility
|
||||
# keep going while we have attribute lines to test
|
||||
while len(ptal_stack):
|
||||
attribute_lines = ptal_stack.pop()
|
||||
combination = combination_stack.pop()
|
||||
|
||||
:return: the first possible combination found, or empty
|
||||
:rtype: recordset of `product.template.attribute.value`
|
||||
"""
|
||||
if not attribute_lines:
|
||||
if self._is_combination_possible(combination, parent_combination):
|
||||
return combination
|
||||
else:
|
||||
return self.env['product.template.attribute.value']
|
||||
for cur in attribute_lines[0].product_template_value_ids:
|
||||
res = iterate_attribute_lines(attribute_lines[1:], combination + cur)
|
||||
if res and all(v in res for v in (necessary_values or [])):
|
||||
return res
|
||||
return self.env['product.template.attribute.value']
|
||||
# full combination, if it's possible return it, otherwise skip it
|
||||
if self._is_combination_possible(combination, parent_combination) and all(v in combination for v in (necessary_values or [])):
|
||||
yield(combination)
|
||||
else:
|
||||
# we have remaining attribute lines to consider
|
||||
for ptav in reversed(attribute_lines[0].product_template_value_ids):
|
||||
ptal_stack.append(attribute_lines[1:])
|
||||
combination_stack.append(combination + ptav)
|
||||
|
||||
return iterate_attribute_lines(attribute_lines, self.env['product.template.attribute.value'])
|
||||
return _("There are no remaining possible combination.")
|
||||
|
||||
@api.multi
|
||||
def _get_closest_possible_combination(self, combination):
|
||||
"""Get the first possible combination that is the closest to the given
|
||||
combination.
|
||||
"""See `_get_closest_possible_combinations` (one iteration).
|
||||
|
||||
This method return the same result (empty recordset) if no
|
||||
combination is possible at all which would be considered a negative
|
||||
result, or if there are no attribute lines on the template in which
|
||||
case the "empty combination" is actually a possible combination.
|
||||
Therefore the result of this method when empty should be tested
|
||||
with `_is_combination_possible` if it's important to know if the
|
||||
resulting empty combination is actually possible or not.
|
||||
"""
|
||||
return next(self._get_closest_possible_combinations(combination), self.env['product.template.attribute.value'])
|
||||
|
||||
@api.multi
|
||||
def _get_closest_possible_combinations(self, combination):
|
||||
"""Generator returning the possible combinations that are the closest to
|
||||
the given combination.
|
||||
|
||||
If the given combination is incomplete, try to complete it.
|
||||
|
||||
If the given combination is invalid, try to remove values from it before
|
||||
completing it.
|
||||
|
||||
See `_get_first_possible_combination` note about empty result.
|
||||
|
||||
:param combination: the values to include if they are possible
|
||||
:type combination: recordset `product.template.attribute.value`
|
||||
|
||||
:return: the first possible combination that is including as much
|
||||
:return: the possible combinations that are including as much
|
||||
elements as possible from the given combination.
|
||||
:rtype: recordset of product.template.attribute.value
|
||||
:rtype: generator of recordset of product.template.attribute.value
|
||||
"""
|
||||
while True:
|
||||
result = self._get_first_possible_combination(necessary_values=combination)
|
||||
if result or not combination:
|
||||
return result
|
||||
combination = combination[:-1]
|
||||
res = self._get_possible_combinations(necessary_values=combination)
|
||||
try:
|
||||
# If there is at least one result for the given combination
|
||||
# we consider that combination set, and we yield all the
|
||||
# possible combinations for it.
|
||||
yield(next(res))
|
||||
for cur in res:
|
||||
yield(cur)
|
||||
return _("There are no remaining closest combination.")
|
||||
except StopIteration:
|
||||
# There are no results for the given combination, we try to
|
||||
# progressively remove values from it.
|
||||
if not combination:
|
||||
return _("There are no possible combination.")
|
||||
combination = combination[:-1]
|
||||
|
||||
@api.multi
|
||||
def _get_current_company(self, **kwargs):
|
||||
|
||||
@@ -204,8 +204,8 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
|
||||
self.assertEqual(len(variant), 0)
|
||||
|
||||
# also test _has_valid_attributes (case ok):
|
||||
valid_value_ids = self.computer._get_valid_product_attribute_values()._without_no_variant_attributes()
|
||||
valid_attribute_ids = self.computer._get_valid_product_attributes()._without_no_variant_attributes()
|
||||
valid_value_ids = self.computer.valid_product_attribute_value_wnva_ids
|
||||
valid_attribute_ids = self.computer.valid_product_attribute_wnva_ids
|
||||
self.assertTrue(ok_variant._has_valid_attributes(valid_attribute_ids, valid_value_ids))
|
||||
|
||||
# also test _has_valid_attributes (case not ok):
|
||||
@@ -321,34 +321,52 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
|
||||
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
|
||||
self._add_exclude(computer_ram_16, computer_hdd_1)
|
||||
|
||||
# ram_8 is allowed with the other attributes
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
|
||||
# Below invalidate cache between every test to force reload the
|
||||
# o2m attribute_line_ids to order it by the new sequence
|
||||
# Basic case: test all iterations of generator
|
||||
gen = self.computer._get_possible_combinations()
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_4)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_16 + computer_hdd_4)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_4)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_4)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_16 + computer_hdd_4)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_2)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
|
||||
with self.assertRaises(StopIteration):
|
||||
next(gen)
|
||||
|
||||
# Give priority to ram_16 but it is not allowed by hdd_1 so it should return hhd_2 instead
|
||||
# Test invalidate_cache on product.attribute.value write
|
||||
computer_ram_16.product_attribute_value_id.sequence = -1
|
||||
self.computer.invalidate_cache() # need o2m to be reordered
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_16 + computer_hdd_2)
|
||||
|
||||
# Move down the ram, so it will try to change the ram instead of the hdd
|
||||
# Test invalidate_cache on product.attribute write
|
||||
self.ram_attribute.sequence = 10
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
|
||||
# Give priority to ram_32 and is allowed with the rest so it should return it
|
||||
self.ram_attribute.sequence = 2
|
||||
computer_ram_16.product_attribute_value_id.sequence = 2
|
||||
computer_ram_32.product_attribute_value_id.sequence = -1
|
||||
self.computer.invalidate_cache() # need o2m to be reordered
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
|
||||
|
||||
# Give priority to ram_16 but now it is not allowing any hdd so it should return ram_8 instead
|
||||
computer_ram_32.product_attribute_value_id.sequence = 3
|
||||
computer_ram_16.product_attribute_value_id.sequence = -1
|
||||
self.computer.invalidate_cache() # need o2m to be reordered
|
||||
self._add_exclude(computer_ram_16, computer_hdd_2)
|
||||
self._add_exclude(computer_ram_16, computer_hdd_4)
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
|
||||
# Only the last combination is possible
|
||||
computer_ram_16.product_attribute_value_id.sequence = 2
|
||||
self.computer.invalidate_cache() # need o2m to be reordered
|
||||
self._add_exclude(computer_ram_8, computer_hdd_1)
|
||||
self._add_exclude(computer_ram_8, computer_hdd_2)
|
||||
self._add_exclude(computer_ram_8, computer_hdd_4)
|
||||
@@ -357,26 +375,37 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
|
||||
self._add_exclude(computer_ram_32, computer_ssd_256)
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), computer_ssd_512 + computer_ram_32 + computer_hdd_4)
|
||||
|
||||
# No possible combination
|
||||
# No possible combination (test helper and iterator)
|
||||
self._add_exclude(computer_ram_32, computer_hdd_4)
|
||||
self.assertEqual(self.computer._get_first_possible_combination(), self.env['product.template.attribute.value'])
|
||||
gen = self.computer._get_possible_combinations()
|
||||
with self.assertRaises(StopIteration):
|
||||
next(gen)
|
||||
|
||||
def test_get_closest_possible_combination(self):
|
||||
def test_get_closest_possible_combinations(self):
|
||||
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
|
||||
computer_ssd_512 = self._get_product_template_attribute_value(self.ssd_512)
|
||||
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
|
||||
computer_ram_16 = self._get_product_template_attribute_value(self.ram_16)
|
||||
computer_ram_32 = self._get_product_template_attribute_value(self.ram_32)
|
||||
computer_hdd_1 = self._get_product_template_attribute_value(self.hdd_1)
|
||||
computer_hdd_2 = self._get_product_template_attribute_value(self.hdd_2)
|
||||
computer_hdd_4 = self._get_product_template_attribute_value(self.hdd_4)
|
||||
self._add_exclude(computer_ram_16, computer_hdd_1)
|
||||
|
||||
# CASE nothing special
|
||||
self.assertEqual(self.computer._get_closest_possible_combination(None),
|
||||
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
# CASE nothing special (test 2 iterations)
|
||||
gen = self.computer._get_closest_possible_combinations(None)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_2)
|
||||
|
||||
# CASE contains computer_hdd_1
|
||||
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_1),
|
||||
computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
# CASE contains computer_hdd_1 (test all iterations)
|
||||
gen = self.computer._get_closest_possible_combinations(computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_8 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_256 + computer_ram_32 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_8 + computer_hdd_1)
|
||||
self.assertEqual(next(gen), computer_ssd_512 + computer_ram_32 + computer_hdd_1)
|
||||
with self.assertRaises(StopIteration):
|
||||
next(gen)
|
||||
|
||||
# CASE contains computer_hdd_2
|
||||
self.assertEqual(self.computer._get_closest_possible_combination(computer_hdd_2),
|
||||
@@ -393,3 +422,27 @@ class TestProductAttributeValueConfig(TestProductAttributeValueSetup):
|
||||
# CASE invalid combination (too much):
|
||||
self.assertEqual(self.computer._get_closest_possible_combination(computer_ssd_256 + computer_ram_8 + computer_hdd_4 + computer_hdd_2),
|
||||
computer_ssd_256 + computer_ram_8 + computer_hdd_4)
|
||||
|
||||
def test_clear_caches(self):
|
||||
"""The goal of this test is to make sure the cache is invalidated when
|
||||
it should be."""
|
||||
attribute_values = self.ssd_256 + self.ram_8 + self.hdd_1
|
||||
|
||||
# CASE: initial result of _get_variant_id_for_combination
|
||||
variant_id = self.computer._get_variant_id_for_combination(attribute_values)
|
||||
self.assertTrue(variant_id)
|
||||
|
||||
# CASE: clear_caches in product.product unlink
|
||||
self.env['product.product'].browse(variant_id).unlink()
|
||||
self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
|
||||
|
||||
# CASE: clear_caches in product.product create
|
||||
variant = self.env['product.product'].create({
|
||||
'product_tmpl_id': self.computer.id,
|
||||
'attribute_value_ids': [(6, 0, attribute_values.ids)],
|
||||
})
|
||||
self.assertEqual(variant.id, self.computer._get_variant_id_for_combination(attribute_values))
|
||||
|
||||
# CASE: clear_caches in product.product write
|
||||
variant.attribute_value_ids = False
|
||||
self.assertFalse(self.computer._get_variant_id_for_combination(attribute_values))
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<field name="list_price" string="Sales Price" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
|
||||
<label for="standard_price" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}"/>
|
||||
<div name="standard_price_uom" groups="base.group_user" attrs="{'invisible': [('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}" class="o_row">
|
||||
<field name="standard_price" widget='monetary' options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="standard_price" widget='monetary' options="{'currency_field': 'cost_currency_id'}"/>
|
||||
<span groups="uom.group_uom" class="oe_read_only">per
|
||||
<field name="uom_name"/>
|
||||
</span>
|
||||
@@ -62,6 +62,7 @@
|
||||
<field name="uom_id" groups="uom.group_uom" options="{'no_create': True}"/>
|
||||
<field name="uom_po_id" groups="uom.group_uom" options="{'no_create': True}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="cost_currency_id" invisible="1"/>
|
||||
<field name="product_variant_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
@@ -244,7 +244,7 @@ class PurchaseRequisitionLine(models.Model):
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.product_uom_id = self.product_id.uom_id
|
||||
self.product_uom_id = self.product_id.uom_po_id
|
||||
self.product_qty = 1.0
|
||||
if not self.schedule_date:
|
||||
self.schedule_date = self.requisition_id.schedule_date
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<div class="o_kanban_record_headings mt4">
|
||||
<strong class="o_kanban_record_title"><field name="name"/></strong>
|
||||
</div>
|
||||
<field name="exclusive" widget="kanban_label_selection"/>
|
||||
<field name="exclusive" widget="label_selection"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
@@ -225,7 +225,7 @@
|
||||
<div class="o_kanban_record_headings mt4">
|
||||
<strong class="o_kanban_record_title"><span><field name="name"/></span></strong>
|
||||
</div>
|
||||
<field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
|
||||
<field name="state" widget="label_selection" options="{'classes': {'draft': 'default', 'in_progress': 'default', 'open': 'success', 'done': 'success', 'close': 'danger'}}"/>
|
||||
</div>
|
||||
<div class="o_kanban_record_body">
|
||||
<span class="text-muted"><field name="type_id"/></span>
|
||||
|
||||
@@ -370,12 +370,11 @@ class PurchaseOrderLine(models.Model):
|
||||
|
||||
@api.multi
|
||||
def _create_stock_moves(self, picking):
|
||||
moves = self.env['stock.move']
|
||||
done = self.env['stock.move'].browse()
|
||||
values = []
|
||||
for line in self:
|
||||
for val in line._prepare_stock_moves(picking):
|
||||
done += moves.create(val)
|
||||
return done
|
||||
values.append(val)
|
||||
return self.env['stock.move'].create(values)
|
||||
|
||||
def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
|
||||
""" Return the record in self where the procument with values passed as
|
||||
|
||||
@@ -208,7 +208,7 @@
|
||||
<strong><span><t t-esc="record.name.value"/></span></strong>
|
||||
</div>
|
||||
<div class="col-6 text-right">
|
||||
<span class="badge badge-secondary"><field name="state" widget="kanban_label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
|
||||
<span class="badge badge-secondary"><field name="state" widget="label_selection" options="{'classes': {'draft': 'info', 'cancel': 'danger', 'done': 'success', 'under_repair': 'dafault'}}"/></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
|
||||
@@ -41,7 +41,10 @@ class ProductConfiguratorController(http.Controller):
|
||||
def get_combination_info(self, product_template_id, product_id, combination, add_qty, pricelist_id, **kw):
|
||||
combination = request.env['product.template.attribute.value'].browse(combination)
|
||||
pricelist = self._get_pricelist(pricelist_id)
|
||||
return request.env['product.template'].browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
|
||||
ProductTemplate = request.env['product.template']
|
||||
if 'context' in kw:
|
||||
ProductTemplate = ProductTemplate.with_context(**kw.get('context'))
|
||||
return ProductTemplate.browse(int(product_template_id))._get_combination_info(combination, int(product_id or 0), int(add_qty or 1), pricelist)
|
||||
|
||||
@http.route(['/product_configurator/create_product_variant'], type='json', auth="user", methods=['POST'])
|
||||
def create_product_variant(self, product_template_id, product_template_attribute_value_ids, **kwargs):
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
</data>
|
||||
<!-- Template and notification section -->
|
||||
<data noupdate="1">
|
||||
<template id="" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
|
||||
<template id="mail_notification_paynow_online" inherit_id="mail.mail_notification_paynow" name="oezirhgoi">
|
||||
<xpath expr="//t[@t-set='access_name']" position="after">
|
||||
<t t-if="record._name == 'sale.order'">
|
||||
<t t-if="record.has_to_be_signed(include_draft=True)">
|
||||
|
||||
@@ -112,7 +112,7 @@ class PaymentTransaction(models.Model):
|
||||
ctx_company = {'company_id': self.acquirer_id.company_id.id,
|
||||
'force_company': self.acquirer_id.company_id.id}
|
||||
for trans in self.filtered(lambda t: t.sale_order_ids):
|
||||
trans = trans.with_context(ctx_company)
|
||||
trans = trans.with_context(**ctx_company)
|
||||
trans.sale_order_ids._force_lines_to_invoice_policy_order()
|
||||
invoices = trans.sale_order_ids._create_invoices()
|
||||
trans.invoice_ids = [(6, 0, invoices)]
|
||||
|
||||
@@ -171,7 +171,7 @@ class ProductTemplate(models.Model):
|
||||
return res
|
||||
|
||||
@api.multi
|
||||
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False):
|
||||
def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
|
||||
""" Return info about a given combination.
|
||||
|
||||
Note: this method does not take into account whether the combination is
|
||||
@@ -196,6 +196,9 @@ class ProductTemplate(models.Model):
|
||||
given, it will try to find the first possible combination, taking
|
||||
into account parent_combination (if set) for the exclusion rules.
|
||||
|
||||
:param only_template: boolean, if set to True, get the info for the
|
||||
template only: ignore combination and don't try to find variant
|
||||
|
||||
:return: dict with product/combination info:
|
||||
|
||||
- product_id: the variant id matching the combination (if it exists)
|
||||
@@ -217,6 +220,8 @@ class ProductTemplate(models.Model):
|
||||
discount applied (price < list_price), else False
|
||||
"""
|
||||
self.ensure_one()
|
||||
# get the name before the change of context to benefit from prefetch
|
||||
display_name = self.name
|
||||
|
||||
quantity = self.env.context.get('quantity', add_qty)
|
||||
context = dict(self.env.context, quantity=quantity, pricelist=pricelist.id if pricelist else False)
|
||||
@@ -224,10 +229,12 @@ class ProductTemplate(models.Model):
|
||||
|
||||
combination = combination or product_template.env['product.template.attribute.value']
|
||||
|
||||
if not product_id and not combination:
|
||||
if not product_id and not combination and not only_template:
|
||||
combination = product_template._get_first_possible_combination(parent_combination)
|
||||
|
||||
if product_id and not combination:
|
||||
if only_template:
|
||||
product = product_template.env['product.product']
|
||||
elif product_id and not combination:
|
||||
product = product_template.env['product.product'].browse(product_id)
|
||||
else:
|
||||
product = product_template._get_variant_for_combination(combination)
|
||||
@@ -255,8 +262,6 @@ class ProductTemplate(models.Model):
|
||||
list_price = product_template.price_compute('list_price')[product_template.id]
|
||||
price = product_template.price if pricelist else list_price
|
||||
|
||||
display_name = product_template.name
|
||||
|
||||
filtered_combination = combination._without_no_variant_attributes()
|
||||
if filtered_combination:
|
||||
display_name = '%s (%s)' % (display_name, ', '.join(filtered_combination.mapped('name')))
|
||||
@@ -283,8 +288,7 @@ class ProductTemplate(models.Model):
|
||||
def _is_add_to_cart_possible(self, parent_combination=None):
|
||||
"""
|
||||
It's possible to add to cart (potentially after configuration) if
|
||||
there is at least one possible combination, or if there is no
|
||||
`product.template.attribute.line` at all.
|
||||
there is at least one possible combination.
|
||||
|
||||
:param parent_combination: the combination from which `self` is an
|
||||
optional or accessory product.
|
||||
@@ -295,9 +299,9 @@ class ProductTemplate(models.Model):
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.active:
|
||||
# for performance: avoid calling `_get_possible_combinations`
|
||||
return False
|
||||
combination = self._get_first_possible_combination(parent_combination)
|
||||
return True if combination else self._is_combination_possible(combination, parent_combination)
|
||||
return next(self._get_possible_combinations(parent_combination), False) is not False
|
||||
|
||||
@api.multi
|
||||
def _get_current_company_fallback(self, **kwargs):
|
||||
|
||||
@@ -74,6 +74,8 @@ class ResConfigSettings(models.TransientModel):
|
||||
|
||||
def set_values(self):
|
||||
super(ResConfigSettings, self).set_values()
|
||||
if self.default_invoice_policy != 'order':
|
||||
self.env['ir.config_parameter'].set_param('sale.automatic_invoice', False)
|
||||
if not self.group_discount_per_so_line:
|
||||
pl = self.env['product.pricelist'].search([('discount_policy', '=', 'without_discount')])
|
||||
pl.write({'discount_policy': 'with_discount'})
|
||||
|
||||
@@ -14,14 +14,20 @@ class ResPartner(models.Model):
|
||||
sale_warn_msg = fields.Text('Message for Sales Order')
|
||||
|
||||
def _compute_sale_order_count(self):
|
||||
sale_data = self.env['sale.order'].read_group(domain=[('partner_id', 'child_of', self.ids)],
|
||||
fields=['partner_id'], groupby=['partner_id'])
|
||||
mapped_data = dict([(m['partner_id'][0], m['partner_id_count']) for m in sale_data])
|
||||
for partner in self:
|
||||
# let's obtain the partner id and all its child ids
|
||||
partner_ids = [partner.id] + partner.child_ids.ids
|
||||
# then we can sum for all the partner's child
|
||||
partner.sale_order_count = sum(mapped_data.get(child, 0) for child in partner_ids)
|
||||
# retrieve all children partners and prefetch 'parent_id' on them
|
||||
all_partners = self.search([('id', 'child_of', self.ids)])
|
||||
all_partners.read(['parent_id'])
|
||||
|
||||
sale_order_groups = self.env['sale.order'].read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids)],
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
)
|
||||
for group in sale_order_groups:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.sale_order_count += group['partner_id_count']
|
||||
partner = partner.parent_id
|
||||
|
||||
def can_edit_vat(self):
|
||||
can_edit_vat = super(ResPartner, self).can_edit_vat()
|
||||
|
||||
+16
-12
@@ -304,7 +304,7 @@ class SaleOrder(models.Model):
|
||||
'payment_term_id': self.partner_id.property_payment_term_id and self.partner_id.property_payment_term_id.id or False,
|
||||
'partner_invoice_id': addr['invoice'],
|
||||
'partner_shipping_id': addr['delivery'],
|
||||
'user_id': self.partner_id.user_id.id or self.env.uid
|
||||
'user_id': self.partner_id.user_id.id or self.partner_id.commercial_partner_id.user_id.id or self.env.uid
|
||||
}
|
||||
if self.env['ir.config_parameter'].sudo().get_param('account.use_invoice_terms') and self.env.user.company_id.invoice_terms:
|
||||
values['note'] = self.with_context(lang=self.partner_id.lang).env.user.company_id.invoice_terms
|
||||
@@ -636,11 +636,15 @@ class SaleOrder(models.Model):
|
||||
if email_act and email_act.get('context'):
|
||||
email_ctx = email_act['context']
|
||||
email_ctx.update(default_email_from=order.company_id.email)
|
||||
order.with_context(email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
|
||||
order.with_context(**email_ctx).message_post_with_template(email_ctx.get('default_template_id'))
|
||||
return True
|
||||
|
||||
@api.multi
|
||||
def action_done(self):
|
||||
tx = self.sudo().transaction_ids.get_last_transaction()
|
||||
if tx and tx.state == 'pending' and tx.acquirer_id.provider == 'transfer':
|
||||
tx._set_transaction_done()
|
||||
tx.write({'is_processed': True})
|
||||
return self.write({'state': 'done'})
|
||||
|
||||
@api.multi
|
||||
@@ -1428,6 +1432,16 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
result = {'domain': domain}
|
||||
|
||||
name = self.get_sale_order_line_multiline_description_sale(product)
|
||||
|
||||
vals.update(name=name)
|
||||
|
||||
self._compute_tax_id()
|
||||
|
||||
if self.order_id.pricelist_id and self.order_id.partner_id:
|
||||
vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
|
||||
self.update(vals)
|
||||
|
||||
title = False
|
||||
message = False
|
||||
warning = {}
|
||||
@@ -1440,16 +1454,6 @@ class SaleOrderLine(models.Model):
|
||||
if product.sale_line_warn == 'block':
|
||||
self.product_id = False
|
||||
|
||||
name = self.get_sale_order_line_multiline_description_sale(product)
|
||||
|
||||
vals.update(name=name)
|
||||
|
||||
self._compute_tax_id()
|
||||
|
||||
if self.order_id.pricelist_id and self.order_id.partner_id:
|
||||
vals['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(self._get_display_price(product), product.taxes_id, self.tax_id, self.company_id)
|
||||
self.update(vals)
|
||||
|
||||
return result
|
||||
|
||||
@api.onchange('product_uom', 'product_uom_qty')
|
||||
|
||||
@@ -192,9 +192,9 @@
|
||||
<p t-if="doc.payment_term_id.note">
|
||||
<span t-field="doc.payment_term_id.note"/>
|
||||
</p>
|
||||
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.note">
|
||||
<p id="fiscal_position_remark" t-if="doc.fiscal_position_id and doc.fiscal_position_id.sudo().note">
|
||||
<strong>Fiscal Position Remark:</strong>
|
||||
<span t-field="doc.fiscal_position_id.note"/>
|
||||
<span t-field="doc.fiscal_position_id.sudo().note"/>
|
||||
</p>
|
||||
|
||||
<div t-if="doc.signature" class="mt32 ml16 mr16" name="signature">
|
||||
|
||||
@@ -183,7 +183,7 @@ var ProductConfiguratorMixin = {
|
||||
*/
|
||||
triggerVariantChange: function ($container) {
|
||||
var self = this;
|
||||
$container.find('ul[data-attribute_exclusions]').trigger('change');
|
||||
$container.find('ul[data-attribute_exclusions]').trigger('change', {$container: $container});
|
||||
$container.find('input.js_variant_change:checked, select.js_variant_change').each(function () {
|
||||
self.handleCustomValues($(this));
|
||||
});
|
||||
@@ -559,14 +559,16 @@ var ProductConfiguratorMixin = {
|
||||
_updateProductImage: function ($productContainer, productId, productTemplateId) {
|
||||
var model = productId ? 'product.product' : 'product.template';
|
||||
var modelId = productId || productTemplateId;
|
||||
var imageSrc = '/web/image/{0}/{1}/image'
|
||||
var imageUrl = '/web/image/{0}/{1}/' + (this._productImageField ? this._productImageField : 'image');
|
||||
var imageSrc = imageUrl
|
||||
.replace("{0}", model)
|
||||
.replace("{1}", modelId);
|
||||
|
||||
var imagesSelectors = [
|
||||
'span[data-oe-model^="product."][data-oe-type="image"] img:first',
|
||||
'img.product_detail_img',
|
||||
'span.variant_image img'
|
||||
'span.variant_image img',
|
||||
'img.variant_image',
|
||||
];
|
||||
|
||||
var $img = $productContainer.find(imagesSelectors.join(', '));
|
||||
|
||||
@@ -69,9 +69,9 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
this.dialogClass = 'oe_optional_products_modal' + (params.isWebsite ? ' oe_website_sale' : '');
|
||||
|
||||
if (this.isWebsite) {
|
||||
delete this.events['change [data-attribute_exclusions]'];
|
||||
delete this.events['click button.js_add_cart_json'];
|
||||
}
|
||||
this._productImageField = 'image_medium';
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
@@ -438,7 +438,17 @@ var OptionalProductsModal = Dialog.extend(ServicesMixin, ProductConfiguratorMixi
|
||||
delete optionalProductsMap[optionId];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_onChangeCombination:function (ev, $parent, combination) {
|
||||
$parent
|
||||
.find('.td-product_name .product-name')
|
||||
.first()
|
||||
.text(combination.display_name);
|
||||
|
||||
ProductConfiguratorMixin._onChangeCombination.apply(this, arguments);
|
||||
},
|
||||
/**
|
||||
* When the quantity of the root product is updated, we need to update
|
||||
* the quantity of all the selected optional products.
|
||||
|
||||
@@ -80,8 +80,13 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
|
||||
|
||||
def test_01_is_combination_possible_archived(self):
|
||||
"""The goal is to test the possibility of archived combinations.
|
||||
|
||||
This test could not be put into product module because there was no
|
||||
field which had product_id as required and without cascade on delete.
|
||||
model which had product_id as required and without cascade on delete.
|
||||
Here we have the sales order line in this situation.
|
||||
|
||||
This is a necessary condition for `create_variant_ids` to archive
|
||||
instead of delete the variants.
|
||||
"""
|
||||
def do_test(self):
|
||||
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
|
||||
@@ -110,6 +115,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
|
||||
|
||||
variant2.active = False
|
||||
# CASE: 1 not archived, 2 archived
|
||||
# Test invalidate_cache on product.product write
|
||||
self.assertTrue(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_1))
|
||||
self.assertFalse(self.computer._is_combination_possible(computer_ssd_256 + computer_ram_8 + computer_hdd_2))
|
||||
# CASE: both archived combination (without no_variant)
|
||||
@@ -261,6 +267,7 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
|
||||
self.assertEqual(res['list_price'], 2222 * currency_ratio)
|
||||
|
||||
# CASE: dynamic combination, no variant existing
|
||||
# Test invalidate_cache on product.template create_variant_ids
|
||||
self._add_keyboard_attribute()
|
||||
self.computer.create_variant_ids()
|
||||
combination += self._get_product_template_attribute_value(self.keyboard_excluded)
|
||||
@@ -272,8 +279,8 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
|
||||
self.assertEqual(res['list_price'], (2222 - 5) * currency_ratio)
|
||||
|
||||
# CASE: pricelist set value to 0, no variant
|
||||
# Test invalidate_cache on product.pricelist write
|
||||
pricelist_item.percent_price = 100
|
||||
self.computer.invalidate_cache() # need o2m to be refetched
|
||||
res = self.computer._get_combination_info(combination, add_qty=2, pricelist=pricelist)
|
||||
self.assertEqual(res['product_template_id'], self.computer.id)
|
||||
self.assertEqual(res['product_id'], False)
|
||||
@@ -346,7 +353,6 @@ class TestSaleProductAttributeValueConfig(TestSaleProductAttributeValueSetup):
|
||||
self.hdd_attribute.create_variant = 'dynamic'
|
||||
self._add_hdd_attribute_line()
|
||||
self.computer.create_variant_ids()
|
||||
self.computer.invalidate_cache()
|
||||
|
||||
computer_ssd_256 = self._get_product_template_attribute_value(self.ssd_256)
|
||||
computer_ram_8 = self._get_product_template_attribute_value(self.ram_8)
|
||||
|
||||
@@ -209,4 +209,5 @@ class SaleOrderOption(models.Model):
|
||||
'product_uom_qty': self.quantity,
|
||||
'product_uom': self.uom_id.id,
|
||||
'discount': self.discount,
|
||||
'company_id': self.order_id.company_id.id,
|
||||
}
|
||||
|
||||
@@ -120,7 +120,10 @@ class SaleOrderTemplateOption(models.Model):
|
||||
return
|
||||
product = self.product_id
|
||||
self.price_unit = product.list_price
|
||||
self.name = product.name
|
||||
name = product.name
|
||||
if self.product_id.description_sale:
|
||||
name += '\n' + self.product_id.description_sale
|
||||
self.name = name
|
||||
self.uom_id = product.uom_id
|
||||
domain = {'uom_id': [('category_id', '=', self.product_id.uom_id.category_id.id)]}
|
||||
return {'domain': domain}
|
||||
|
||||
@@ -3,6 +3,8 @@ odoo.define('sale.sale_product_configurator_advanced_tour', function (require) {
|
||||
|
||||
var tour = require('web_tour.tour');
|
||||
|
||||
var optionVariantImage;
|
||||
|
||||
tour.register('sale_product_configurator_advanced_tour', {
|
||||
url: "/web",
|
||||
test: true,
|
||||
@@ -95,6 +97,25 @@ tour.register('sale_product_configurator_advanced_tour', {
|
||||
trigger: '.main_product div:contains("PA8: PAV1")',
|
||||
extra_trigger: '.oe_optional_products_modal',
|
||||
run: function () {} //check
|
||||
}, {
|
||||
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Steel)")',
|
||||
run: function () {
|
||||
optionVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
|
||||
}
|
||||
}, {
|
||||
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Aluminium"]',
|
||||
}, {
|
||||
trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("Conference Chair (Aluminium)")',
|
||||
run: function () {
|
||||
var newVariantImage = $('.oe_optional_products_modal .js_product:eq(1) img.variant_image').attr('src');
|
||||
if (newVariantImage !== optionVariantImage) {
|
||||
$('<p>').text('image variant option src changed').insertAfter('.oe_optional_products_modal .js_product:eq(1) .product-name');
|
||||
}
|
||||
|
||||
}
|
||||
}, {
|
||||
extra_trigger: '.oe_optional_products_modal .js_product:eq(1) div:contains("image variant option src changed")',
|
||||
trigger: '.oe_optional_products_modal .js_product:eq(1) input[data-value_name="Steel"]',
|
||||
}, {
|
||||
trigger: 'button span:contains(Confirm)',
|
||||
extra_trigger: '.oe_optional_products_modal',
|
||||
|
||||
@@ -8,7 +8,58 @@
|
||||
|
||||
<xpath expr="//page/field[@name='order_line']/.." position="after">
|
||||
<page string="Optional Products" name="optional_products">
|
||||
<field name="sale_order_option_ids">
|
||||
<field name="sale_order_option_ids" mode="tree,form,kanban">
|
||||
<form string="Optional Products">
|
||||
<group>
|
||||
<field name="product_id"/>
|
||||
<field name="name"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id" groups="uom.group_uom"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="discount" groups="sale.group_discount_per_so_line"/>
|
||||
</group>
|
||||
</form>
|
||||
<kanban class="o_kanban_mobile">
|
||||
<field name="product_id"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id" groups="uom.group_uom"/>
|
||||
<field name="price_unit"/>
|
||||
<templates>
|
||||
<t t-name="kanban-box">
|
||||
<div class="oe_kanban_card oe_kanban_global_click">
|
||||
<div class="row">
|
||||
<div class="col-10">
|
||||
<strong>
|
||||
<span>
|
||||
<t t-esc="record.product_id.value"/>
|
||||
</span>
|
||||
</strong>
|
||||
</div>
|
||||
<div class="col-2">
|
||||
<button name="button_add_to_order" class="btn btn-link oe_link fa fa-shopping-cart" string="Add to order lines" type="object"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 text-muted">
|
||||
<span>
|
||||
Quantity:
|
||||
<t t-esc="record.quantity.value"/>
|
||||
<t t-esc="record.uom_id.value"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-12 text-muted">
|
||||
<span>
|
||||
Unit Price:
|
||||
<t t-esc="record.price_unit.value"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
</kanban>
|
||||
<tree string="Sales Quotation Template Lines" editable="bottom">
|
||||
<control>
|
||||
<create name="add_product_control" string="Add a product"/>
|
||||
|
||||
@@ -267,7 +267,7 @@ class Inventory(models.Model):
|
||||
args += (self.package_id.id,)
|
||||
#case 5: Filter on One product category + Exahausted Products
|
||||
if self.category_id:
|
||||
categ_products = Product.search([('categ_id', '=', self.category_id.id)])
|
||||
categ_products = Product.search([('categ_id', 'child_of', self.category_id.id)])
|
||||
domain += ' AND product_id = ANY (%s)'
|
||||
args += (categ_products.ids,)
|
||||
products_to_filter |= categ_products
|
||||
|
||||
@@ -193,9 +193,9 @@ class Route(models.Model):
|
||||
'res.company', 'Company',
|
||||
default=lambda self: self.env['res.company']._company_default_get('stock.location.route'), index=True,
|
||||
help='Leave this field empty if this route is shared between all companies')
|
||||
product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products')
|
||||
categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories')
|
||||
warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses')
|
||||
product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products', copy=False)
|
||||
categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories', copy=False)
|
||||
warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses', copy=False)
|
||||
|
||||
@api.onchange('warehouse_selectable')
|
||||
def _onchange_warehouse_selectable(self):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user